diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs index 9b5056582..59be6d3f4 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs @@ -224,6 +224,10 @@ namespace Squidex.Domain.Apps.Core.Scripting { throw new ValidationException(T.Get("common.jsError", new { message = ex.Message })); } + catch (OperationCanceledException) + { + throw new ValidationException(T.Get("common.jsError", new { message = "Timeout" })); + } catch (DomainException) { throw; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppCommandMiddleware.cs index e224442bf..20f070088 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppCommandMiddleware.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppCommandMiddleware.cs @@ -1,4 +1,4 @@ -// ========================================================================== +// ========================================================================== // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex UG (haftungsbeschraenkt) @@ -58,7 +58,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject await using (var uploadStream = file.OpenRead()) { - var image = await assetThumbnailGenerator.GetImageInfoAsync(uploadStream); + var image = await assetThumbnailGenerator.GetImageInfoAsync(uploadStream, file.MimeType); if (image == null) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/ImageAssetMetadataSource.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/ImageAssetMetadataSource.cs index 5fbdd97c2..40a0621c0 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/ImageAssetMetadataSource.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/ImageAssetMetadataSource.cs @@ -55,11 +55,13 @@ namespace Squidex.Domain.Apps.Entities.Assets { if (command.Type == AssetType.Unknown || command.Type == AssetType.Image) { + var mimeType = command.File.MimeType; + ImageInfo? imageInfo = null; await using (var uploadStream = command.File.OpenRead()) { - imageInfo = await assetThumbnailGenerator.GetImageInfoAsync(uploadStream); + imageInfo = await assetThumbnailGenerator.GetImageInfoAsync(uploadStream, mimeType); } if (imageInfo != null) @@ -72,7 +74,7 @@ namespace Squidex.Domain.Apps.Entities.Assets await using (var uploadStream = command.File.OpenRead()) { - imageInfo = await assetThumbnailGenerator.FixOrientationAsync(uploadStream, tempFile.Stream); + imageInfo = await assetThumbnailGenerator.FixOrientationAsync(uploadStream, mimeType, tempFile.Stream); } command.File.Dispose(); diff --git a/backend/src/Squidex.Infrastructure/Commands/DomainObjectGrainFormatter.cs b/backend/src/Squidex.Infrastructure/Commands/DomainObjectGrainFormatter.cs index 9cfdf1db1..239890a0b 100644 --- a/backend/src/Squidex.Infrastructure/Commands/DomainObjectGrainFormatter.cs +++ b/backend/src/Squidex.Infrastructure/Commands/DomainObjectGrainFormatter.cs @@ -11,7 +11,7 @@ namespace Squidex.Infrastructure.Commands { public static class DomainObjectGrainFormatter { - public static string Format(IIncomingGrainCallContext context) + public static readonly Func Format = context => { if (context.InterfaceMethod == null) { @@ -34,6 +34,6 @@ namespace Squidex.Infrastructure.Commands } return context.InterfaceMethod.Name; - } + }; } } diff --git a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index 98bea9307..4069161d9 100644 --- a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -31,7 +31,7 @@ - + diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index fe2e4fdf4..635eb0ffc 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -29,7 +29,12 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiExplorerSettings(GroupName = nameof(Apps))] public sealed class AppsController : ApiController { - private static readonly ResizeOptions ResizeOptions = new ResizeOptions { Width = 50, Height = 50, Mode = ResizeMode.Crop }; + private static readonly ResizeOptions ResizeOptions = new ResizeOptions + { + TargetWidth = 50, + TargetHeight = 50, + Mode = ResizeMode.Crop + }; private readonly IAppImageStore appImageStore; private readonly IAppProvider appProvider; private readonly IAssetStore assetStore; @@ -218,7 +223,7 @@ namespace Squidex.Areas.Api.Controllers.Apps { await using (var destinationStream = GetTempStream()) { - await ResizeAsync(resizedAsset, destinationStream); + await ResizeAsync(resizedAsset, App.Image.MimeType, destinationStream); await destinationStream.CopyToAsync(body, ct); } @@ -232,7 +237,7 @@ namespace Squidex.Areas.Api.Controllers.Apps }; } - private async Task ResizeAsync(string resizedAsset, FileStream destinationStream) + private async Task ResizeAsync(string resizedAsset, string mimeType, FileStream destinationStream) { #pragma warning disable MA0040 // Flow the cancellation token await using (var sourceStream = GetTempStream()) @@ -245,7 +250,7 @@ namespace Squidex.Areas.Api.Controllers.Apps using (Telemetry.Activities.StartActivity("ResizeImage")) { - await assetThumbnailGenerator.CreateThumbnailAsync(sourceStream, destinationStream, ResizeOptions); + await assetThumbnailGenerator.CreateThumbnailAsync(sourceStream, mimeType, destinationStream, ResizeOptions); destinationStream.Position = 0; } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs index e1041be22..87add2bd5 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs @@ -224,7 +224,7 @@ namespace Squidex.Areas.Api.Controllers.Assets { try { - await assetThumbnailGenerator.CreateThumbnailAsync(sourceStream, stream, resizeOptions); + await assetThumbnailGenerator.CreateThumbnailAsync(sourceStream, asset.MimeType, stream, resizeOptions); stream.Position = 0; } catch diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetContentQueryDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetContentQueryDto.cs index 8000f8360..bc7139114 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetContentQueryDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetContentQueryDto.cs @@ -81,12 +81,6 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models [FromQuery(Name = "nofocus")] public bool IgnoreFocus { get; set; } - /// - /// True to not use JPEG encoding when quality is set and the image is not a JPEG. Default: false. - /// - [FromQuery(Name = "keepformat")] - public bool KeepFormat { get; set; } - /// /// True to force a new resize even if it already stored. /// @@ -109,6 +103,8 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models result.FocusX = x; result.FocusY = y; + result.TargetWidth = Width; + result.TargetHeight = Height; return result; } diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs index 7f26d291d..258da762e 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs @@ -26,7 +26,12 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile [Authorize] public sealed class ProfileController : IdentityServerController { - private static readonly ResizeOptions ResizeOptions = new ResizeOptions { Width = 128, Height = 128, Mode = ResizeMode.Crop }; + private static readonly ResizeOptions ResizeOptions = new ResizeOptions + { + TargetWidth = 128, + TargetHeight = 128, + Mode = ResizeMode.Crop + }; private readonly IUserPictureStore userPictureStore; private readonly IUserService userService; private readonly IAssetThumbnailGenerator assetThumbnailGenerator; @@ -144,9 +149,9 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile await userService.AddLoginAsync(id, externalLogin, HttpContext.RequestAborted); } - private async Task UpdatePictureAsync(List file, string id) + private async Task UpdatePictureAsync(List files, string id) { - if (file.Count != 1) + if (files.Count != 1) { throw new ValidationException(T.Get("validation.onlyOneFile")); } @@ -155,7 +160,13 @@ namespace Squidex.Areas.IdentityServer.Controllers.Profile { try { - await assetThumbnailGenerator.CreateThumbnailAsync(file[0].OpenReadStream(), thumbnailStream, ResizeOptions); + var file = files[0]; + + await using (var stream = file.OpenReadStream()) + { + await assetThumbnailGenerator.CreateThumbnailAsync(stream, file.ContentType, thumbnailStream, ResizeOptions, + HttpContext.RequestAborted); + } thumbnailStream.Position = 0; } diff --git a/backend/src/Squidex/Config/Domain/AssetServices.cs b/backend/src/Squidex/Config/Domain/AssetServices.cs index 8d5c2ba2b..00e26f5af 100644 --- a/backend/src/Squidex/Config/Domain/AssetServices.cs +++ b/backend/src/Squidex/Config/Domain/AssetServices.cs @@ -9,6 +9,7 @@ using FluentFTP; using MongoDB.Driver; using MongoDB.Driver.GridFS; using Squidex.Assets; +using Squidex.Assets.ImageMagick; using Squidex.Assets.ImageSharp; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Assets; @@ -177,7 +178,14 @@ namespace Squidex.Config.Domain } }); - services.AddSingletonAs() + var thumbnailGenerator = new CompositeThumbnailGenerator( + new IAssetThumbnailGenerator[] + { + new ImageSharpThumbnailGenerator(), + new ImageMagickThumbnailGenerator() + }); + + services.AddSingletonAs(c => thumbnailGenerator) .As(); services.AddSingletonAs(c => new DelegateInitializer( diff --git a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs index e8ca292f2..e5b6d9558 100644 --- a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs +++ b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs @@ -51,6 +51,19 @@ namespace Squidex.Config.Domain services.AddAsyncLocalCache(); services.AddBackgroundCache(); + var timeoutExecution = config.GetValue("scripting:timeoutExecution"); + var timeoutScript = config.GetValue("scripting:timeoutScript"); + + services.AddSingletonAs(c => + new JintScriptEngine( + c.GetRequiredService(), + c.GetRequiredService>()) + { + TimeoutExecution = timeoutExecution, + TimeoutScript = timeoutScript + + }).As(); + services.AddSingletonAs(_ => SystemClock.Instance) .As(); @@ -96,7 +109,7 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); - services.AddSingleton>(DomainObjectGrainFormatter.Format); + services.AddSingleton(DomainObjectGrainFormatter.Format); } public static void AddSquidexUsageTracking(this IServiceCollection services, IConfiguration config) diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index 95156c3ae..e27d06728 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -70,11 +70,11 @@ - - - - - + + + + + diff --git a/backend/src/Squidex/appsettings.json b/backend/src/Squidex/appsettings.json index 62af22d7b..4d1538ab7 100644 --- a/backend/src/Squidex/appsettings.json +++ b/backend/src/Squidex/appsettings.json @@ -75,6 +75,14 @@ } }, + "scripting": { + // The timeout for the whole script execution. + "timeoutExecution": "00:00:04", + + // The timeout for the synchronous part of the script. + "timeoutScript": "00:00:00.200" + }, + "languages": { // Use custom languages where the key is the language code and the value is the english name. "custom": "" diff --git a/backend/src/Squidex/wwwroot/scripts/editor-plain.html b/backend/src/Squidex/wwwroot/scripts/editor-plain.html new file mode 100644 index 000000000..9aa472084 --- /dev/null +++ b/backend/src/Squidex/wwwroot/scripts/editor-plain.html @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppCommandMiddlewareTests.cs index a94f395df..12d723367 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppCommandMiddlewareTests.cs @@ -62,8 +62,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject { var file = new NoopAssetFile(); - A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(A._)) - .Returns(new ImageInfo(100, 100, false)); + A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(A._, file.MimeType, default)) + .Returns(new ImageInfo(100, 100, false, ImageFormat.PNG)); await HandleAsync(new UploadAppImage { File = file }, None.Value); @@ -78,7 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject var command = new UploadAppImage { File = file }; - A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(A._)) + A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(A._, file.MimeType, default)) .Returns(Task.FromResult(null)); await Assert.ThrowsAsync(() => HandleAsync(sut, command)); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs index d6ba0909e..5ae393426 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs @@ -35,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Assets await sut.EnhanceAsync(command); - A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(A._)) + A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(A._, file.MimeType, default)) .MustHaveHappened(); } @@ -54,8 +54,8 @@ namespace Squidex.Domain.Apps.Entities.Assets { var command = new CreateAsset { File = file }; - A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(stream)) - .Returns(new ImageInfo(800, 600, false)); + A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(stream, file.MimeType, default)) + .Returns(new ImageInfo(800, 600, false, ImageFormat.PNG)); await sut.EnhanceAsync(command); @@ -63,7 +63,7 @@ namespace Squidex.Domain.Apps.Entities.Assets Assert.Equal(600, command.Metadata.GetPixelHeight()); Assert.Equal(AssetType.Image, command.Type); - A.CallTo(() => assetThumbnailGenerator.FixOrientationAsync(stream, A._)) + A.CallTo(() => assetThumbnailGenerator.FixOrientationAsync(stream, file.MimeType, A._, default)) .MustNotHaveHappened(); } @@ -72,11 +72,11 @@ namespace Squidex.Domain.Apps.Entities.Assets { var command = new CreateAsset { File = file }; - A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(stream)) - .Returns(new ImageInfo(600, 800, true)); + A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(stream, file.MimeType, default)) + .Returns(new ImageInfo(600, 800, true, ImageFormat.PNG)); - A.CallTo(() => assetThumbnailGenerator.FixOrientationAsync(stream, A._)) - .Returns(new ImageInfo(800, 600, true)); + A.CallTo(() => assetThumbnailGenerator.FixOrientationAsync(stream, file.MimeType, A._, default)) + .Returns(new ImageInfo(800, 600, true, ImageFormat.PNG)); await sut.EnhanceAsync(command); @@ -84,7 +84,7 @@ namespace Squidex.Domain.Apps.Entities.Assets Assert.Equal(600, command.Metadata.GetPixelHeight()); Assert.Equal(AssetType.Image, command.Type); - A.CallTo(() => assetThumbnailGenerator.FixOrientationAsync(stream, A._)) + A.CallTo(() => assetThumbnailGenerator.FixOrientationAsync(stream, file.MimeType, A._, default)) .MustHaveHappened(); } diff --git a/backend/tests/docker-compose.yml b/backend/tests/docker-compose.yml index 3b4d513d9..ff834456a 100644 --- a/backend/tests/docker-compose.yml +++ b/backend/tests/docker-compose.yml @@ -18,6 +18,8 @@ services: - IDENTITY__ADMINCLIENTSECRET=xeLd6jFxqbXJrfmNLlO2j1apagGGGSyZJhFnIuHp4I0= - STORE__MONGODB__CONFIGURATION=mongodb://mongo - STORE__TYPE=MongoDB + - SCRIPTING__TIMEOUTSCRIPT=00:00:10 + - SCRIPTING__TIMEOUTEXECUTION=00:00:10 - GRAPHQL__CACHEDURATION=0 networks: - internal diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs index 3441de956..a490a124e 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs @@ -9,6 +9,7 @@ using Squidex.ClientLibrary.Management; using TestSuite.Fixtures; using Xunit; +#pragma warning disable xUnit1004 // Test methods should not be skipped #pragma warning disable SA1300 // Element should begin with upper-case letter #pragma warning disable CS0618 // Type or member is obsolete @@ -24,60 +25,82 @@ namespace TestSuite.ApiTests } [Fact] - public async Task Should_upload_image_gif() + public async Task Should_upload_image_gif_without_extension() { - var asset = await _.UploadFileAsync("Assets/SampleGIFImage_40kbmb.gif", "image/gif"); + var asset = await _.UploadFileAsync("Assets/SampleImage_150kb.gif", "image/gif", Guid.NewGuid().ToString()); // Should parse image metadata. Assert.True(asset.IsImage); - Assert.Equal(312, asset.PixelHeight); - Assert.Equal(312, asset.PixelWidth); - Assert.Equal(312L, asset.Metadata["pixelHeight"]); - Assert.Equal(312L, asset.Metadata["pixelWidth"]); + Assert.Equal(600L, (long)asset.PixelWidth); + Assert.Equal(600L, asset.Metadata["pixelWidth"]); + Assert.Equal(400L, (long)asset.PixelHeight); + Assert.Equal(400L, asset.Metadata["pixelHeight"]); Assert.Equal(AssetType.Image, asset.Type); } [Fact] - public async Task Should_upload_image_gif_without_extension() + public async Task Should_upload_image_gif_and_resize() { - var asset = await _.UploadFileAsync("Assets/SampleGIFImage_40kbmb.gif", "image/gif", Guid.NewGuid().ToString()); + var asset = await _.UploadFileAsync("Assets/SampleImage_150kb.gif", "image/gif"); - // Should parse image metadata. - Assert.True(asset.IsImage); - Assert.Equal(312, asset.PixelHeight); - Assert.Equal(312, asset.PixelWidth); - Assert.Equal(312L, asset.Metadata["pixelHeight"]); - Assert.Equal(312L, asset.Metadata["pixelWidth"]); - Assert.Equal(AssetType.Image, asset.Type); + await AssetImageAsync(asset); } [Fact] - public async Task Should_upload_image_png() + public async Task Should_upload_image_png_and_resize() { - var asset = await _.UploadFileAsync("Assets/SamplePNGImage_100kbmb.png", "image/png"); + var asset = await _.UploadFileAsync("Assets/SampleImage_400kb.png", "image/png"); - // Should parse image metadata. - Assert.True(asset.IsImage); - Assert.Equal(170, asset.PixelHeight); - Assert.Equal(272, asset.PixelWidth); - Assert.Equal(170L, asset.Metadata["pixelHeight"]); - Assert.Equal(272L, asset.Metadata["pixelWidth"]); - Assert.Equal(AssetType.Image, asset.Type); + await AssetImageAsync(asset); + } + + [Fact] + public async Task Should_upload_image_jpg_and_resize() + { + var asset = await _.UploadFileAsync("Assets/SampleImage_62kb.jpg", "image/jpg"); + + await AssetImageAsync(asset); + + Assert.Equal(79L, asset.Metadata["imageQuality"]); + } + + [Fact] + public async Task Should_upload_image_webp_and_resize() + { + var asset = await _.UploadFileAsync("Assets/SampleImage_100kb.webp", "image/jpg"); + + await AssetImageAsync(asset); } [Fact] - public async Task Should_upload_image_jpg() + public async Task Should_upload_image_tiff_and_resize() + { + var asset = await _.UploadFileAsync("Assets/SampleImage_400kb.tiff", "image/jpg"); + + await AssetImageAsync(asset); + } + + [Fact(Skip = "Not supported yet.")] + public async Task Should_upload_image_tg_and_resize() { - var asset = await _.UploadFileAsync("Assets/SampleJPGImage_50kbmb.jpg", "image/jpg"); + var asset = await _.UploadFileAsync("Assets/SampleImage_600kb.tga", "image/jpg"); + await AssetImageAsync(asset); + } + + private async Task AssetImageAsync(AssetDto asset) + { // Should parse image metadata. Assert.True(asset.IsImage); - Assert.Equal(300, asset.PixelHeight); - Assert.Equal(300, asset.PixelWidth); - Assert.Equal(300L, asset.Metadata["pixelHeight"]); - Assert.Equal(300L, asset.Metadata["pixelWidth"]); - Assert.Equal(96L, asset.Metadata["imageQuality"]); + Assert.Equal(600L, (long)asset.PixelWidth); + Assert.Equal(600L, asset.Metadata["pixelWidth"]); + Assert.Equal(400L, (long)asset.PixelHeight); + Assert.Equal(400L, asset.Metadata["pixelHeight"]); Assert.Equal(AssetType.Image, asset.Type); + + var resized = await GetResizedLengthAsync(asset.Id, 100, 100); + + Assert.True(resized < asset.FileSize * .25); } [Fact] @@ -87,10 +110,10 @@ namespace TestSuite.ApiTests // Should parse image metadata and fix orientation. Assert.True(asset.IsImage); - Assert.Equal(135, asset.PixelHeight); - Assert.Equal(600, asset.PixelWidth); - Assert.Equal(135L, asset.Metadata["pixelHeight"]); + Assert.Equal(600L, (long)asset.PixelWidth); Assert.Equal(600L, asset.Metadata["pixelWidth"]); + Assert.Equal(135L, (long)asset.PixelHeight); + Assert.Equal(135L, asset.Metadata["pixelHeight"]); Assert.Equal(79L, asset.Metadata["imageQuality"]); Assert.Equal(AssetType.Image, asset.Type); } @@ -151,5 +174,24 @@ namespace TestSuite.ApiTests // Should not parse yet. Assert.Equal(AssetType.Unknown, asset.Type); } + + private async Task GetResizedLengthAsync(string imageId, int width, int height) + { + var url = $"{_.ClientManager.GenerateImageUrl(imageId)}?width={width}&height={height}"; + + using (var httpClient = _.ClientManager.CreateHttpClient()) + { + var response = await httpClient.GetAsync(url); + + await using (var stream = await response.Content.ReadAsStreamAsync()) + { + var buffer = new MemoryStream(); + + await stream.CopyToAsync(buffer); + + return buffer.Length; + } + } + } } } diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleGIFImage_40kbmb.gif b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleGIFImage_40kbmb.gif deleted file mode 100644 index d41b6a157..000000000 Binary files a/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleGIFImage_40kbmb.gif and /dev/null differ diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_100kb.webp b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_100kb.webp new file mode 100644 index 000000000..e91c03168 Binary files /dev/null and b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_100kb.webp differ diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_150kb.gif b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_150kb.gif new file mode 100644 index 000000000..f70d7a417 Binary files /dev/null and b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_150kb.gif differ diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_400kb.png b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_400kb.png new file mode 100644 index 000000000..a3527365f Binary files /dev/null and b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_400kb.png differ diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_400kb.tiff b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_400kb.tiff new file mode 100644 index 000000000..860eb6ee1 Binary files /dev/null and b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_400kb.tiff differ diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_600kb.tga b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_600kb.tga new file mode 100644 index 000000000..192b75263 Binary files /dev/null and b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_600kb.tga differ diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_62kb.jpg b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_62kb.jpg new file mode 100644 index 000000000..9a25ecd6c Binary files /dev/null and b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_62kb.jpg differ diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_700kb.bmp b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_700kb.bmp new file mode 100644 index 000000000..bb60b1a8d Binary files /dev/null and b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_700kb.bmp differ diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleJPGImage_50kbmb.jpg b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleJPGImage_50kbmb.jpg deleted file mode 100644 index 3a52a95df..000000000 Binary files a/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleJPGImage_50kbmb.jpg and /dev/null differ diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SamplePNGImage_100kbmb.png b/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SamplePNGImage_100kbmb.png deleted file mode 100644 index 22392090a..000000000 Binary files a/backend/tools/TestSuite/TestSuite.ApiTests/Assets/SamplePNGImage_100kbmb.png and /dev/null differ diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj b/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj index f25b62f4f..eab04cc20 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj +++ b/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj @@ -5,6 +5,14 @@ 10.0 enable + + + + + + + + all @@ -40,6 +48,27 @@ PreserveNewest + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + PreserveNewest diff --git a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/AssetFixture.cs b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/AssetFixture.cs index d2e9164af..3970e56a4 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/AssetFixture.cs +++ b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/AssetFixture.cs @@ -30,7 +30,7 @@ namespace TestSuite.Fixtures response.EnsureSuccessStatusCode(); - using (var stream = await response.Content.ReadAsStreamAsync()) + await using (var stream = await response.Content.ReadAsStreamAsync()) { await stream.CopyToAsync(temp); } @@ -43,7 +43,7 @@ namespace TestSuite.Fixtures { var fileInfo = new FileInfo(path); - using (var stream = fileInfo.OpenRead()) + await using (var stream = fileInfo.OpenRead()) { var upload = new FileParameter(stream, fileName ?? RandomName(fileInfo.Extension), asset.MimeType); @@ -55,7 +55,7 @@ namespace TestSuite.Fixtures { var fileInfo = new FileInfo(path); - using (var stream = fileInfo.OpenRead()) + await using (var stream = fileInfo.OpenRead()) { var upload = new FileParameter(stream, fileName ?? RandomName(fileInfo.Extension), mimeType); diff --git a/frontend/app/features/content/shared/forms/array-editor.component.ts b/frontend/app/features/content/shared/forms/array-editor.component.ts index aa83e44cb..e96ac2f24 100644 --- a/frontend/app/features/content/shared/forms/array-editor.component.ts +++ b/frontend/app/features/content/shared/forms/array-editor.component.ts @@ -7,7 +7,7 @@ import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { ChangeDetectionStrategy, Component, Input, OnChanges, QueryList, SimpleChanges, ViewChildren } from '@angular/core'; -import { AppLanguageDto, ComponentsFieldPropertiesDto, disabled$, EditContentForm, fadeAnimation, FieldArrayForm, LocalStoreService, ModalModel, ObjectForm, SchemaDto, Settings, sorted, Types } from '@app/shared'; +import { AppLanguageDto, ComponentsFieldPropertiesDto, disabled$, EditContentForm, fadeAnimation, FieldArrayForm, LocalStoreService, ModalModel, ObjectFormBase, SchemaDto, Settings, sorted, Types } from '@app/shared'; import { combineLatest, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { ArrayItemComponent } from './array-item.component'; @@ -89,7 +89,7 @@ export class ArrayEditorComponent implements OnChanges { this.formModel.removeItemAt(index); } - public addCopy(value: ObjectForm) { + public addCopy(value: ObjectFormBase) { this.formModel.addCopy(value); } @@ -102,16 +102,16 @@ export class ArrayEditorComponent implements OnChanges { } public clear() { - this.formModel.reset(); + this.formModel.setValue([]); } - public sort(event: CdkDragDrop>) { + public sort(event: CdkDragDrop>) { this.formModel.sort(sorted(event)); this.reset(); } - public move(item: ObjectForm, index: number) { + public move(item: ObjectFormBase, index: number) { this.formModel.move(index, item); this.reset(); diff --git a/frontend/app/features/content/shared/forms/array-item.component.html b/frontend/app/features/content/shared/forms/array-item.component.html index 73f58edbc..d966b8d87 100644 --- a/frontend/app/features/content/shared/forms/array-item.component.html +++ b/frontend/app/features/content/shared/forms/array-item.component.html @@ -43,7 +43,7 @@
-
+
implements OnCh public formContext: any; @Input() - public formModel: ObjectForm; + public formModel: ObjectFormBase; @Input() public canUnset?: boolean | null; diff --git a/frontend/app/features/content/shared/forms/component.component.html b/frontend/app/features/content/shared/forms/component.component.html index 4ec81c67f..c1ddadaf0 100644 --- a/frontend/app/features/content/shared/forms/component.component.html +++ b/frontend/app/features/content/shared/forms/component.component.html @@ -1,10 +1,10 @@
-
+
- {{formModel.schema.displayName}} + {{schema.displayName}} -
+
( + datasets: Object.entries(this.usage.details).map(([key, value], i) => ( { - label: ChartHelpers.label(k), + label: ChartHelpers.label(key), backgroundColor: ChartHelpers.getBackgroundColor(i), borderColor: ChartHelpers.getBorderColor(i), borderWidth: 1, - data: this.usage.details[k].map(x => x.totalCalls), + data: value.map(x => x.totalCalls), })), }; } diff --git a/frontend/app/features/dashboard/pages/cards/api-performance-card.component.ts b/frontend/app/features/dashboard/pages/cards/api-performance-card.component.ts index 5b414ab97..088aa7614 100644 --- a/frontend/app/features/dashboard/pages/cards/api-performance-card.component.ts +++ b/frontend/app/features/dashboard/pages/cards/api-performance-card.component.ts @@ -44,13 +44,13 @@ export class ApiPerformanceCardComponent implements OnChanges { this.chartData = { labels, - datasets: Object.keys(this.usage.details).map((k, i) => ( + datasets: Object.entries(this.usage.details).map(([key, value], i) => ( { - label: ChartHelpers.label(k), + label: ChartHelpers.label(key), backgroundColor: ChartHelpers.getBackgroundColor(i), borderColor: ChartHelpers.getBorderColor(i), borderWidth: 1, - data: this.usage.details[k].map(x => x.averageElapsedMs), + data: value.map(x => x.averageElapsedMs), })), }; diff --git a/frontend/app/features/dashboard/pages/cards/api-traffic-card.component.ts b/frontend/app/features/dashboard/pages/cards/api-traffic-card.component.ts index 1f5e9a7bb..7651cb887 100644 --- a/frontend/app/features/dashboard/pages/cards/api-traffic-card.component.ts +++ b/frontend/app/features/dashboard/pages/cards/api-traffic-card.component.ts @@ -44,13 +44,13 @@ export class ApiTrafficCardComponent implements OnChanges { this.chartData = { labels, - datasets: Object.keys(this.usage.details).map((k, i) => ( + datasets: Object.entries(this.usage.details).map(([key, value], i) => ( { - label: ChartHelpers.label(k), + label: ChartHelpers.label(key), backgroundColor: ChartHelpers.getBackgroundColor(i), borderColor: ChartHelpers.getBorderColor(i), borderWidth: 1, - data: this.usage.details[k].map(x => Math.round(100 * (x.totalBytes / (1024 * 1024))) / 100), + data: value.map(x => Math.round(100 * (x.totalBytes / (1024 * 1024))) / 100), })), }; diff --git a/frontend/app/features/schemas/pages/schema/fields/forms/field-form.component.html b/frontend/app/features/schemas/pages/schema/fields/forms/field-form.component.html index 7f7a86431..5bdfa23cb 100644 --- a/frontend/app/features/schemas/pages/schema/fields/forms/field-form.component.html +++ b/frontend/app/features/schemas/pages/schema/fields/forms/field-form.component.html @@ -10,7 +10,7 @@ {{ 'schemas.field.tabValidation' | sqxTranslate }} -
-
diff --git a/frontend/app/features/settings/pages/roles/role.component.ts b/frontend/app/features/settings/pages/roles/role.component.ts index b55f92fe9..46306daa7 100644 --- a/frontend/app/features/settings/pages/roles/role.component.ts +++ b/frontend/app/features/settings/pages/roles/role.component.ts @@ -101,15 +101,11 @@ export class RoleComponent implements OnChanges { this.rolesState.delete(this.role); } - public removePermission(index: number) { - this.editForm.remove(index); - } - public addPermission() { const value = this.addPermissionForm.submit(); if (value) { - this.editForm.add(value.permission); + this.editForm.form.add(value.permission); this.addPermissionForm.submitCompleted(); this.addPermissionInput.focus(); diff --git a/frontend/app/features/settings/pages/settings/settings-page.component.html b/frontend/app/features/settings/pages/settings/settings-page.component.html index bb9e4bdef..9160f7be2 100644 --- a/frontend/app/features/settings/pages/settings/settings-page.component.html +++ b/frontend/app/features/settings/pages/settings/settings-page.component.html @@ -46,7 +46,7 @@
-
@@ -104,7 +104,7 @@
-
diff --git a/frontend/app/framework/angular/forms/templated-form-array.spec.ts b/frontend/app/framework/angular/forms/templated-form-array.spec.ts new file mode 100644 index 000000000..d9124acbe --- /dev/null +++ b/frontend/app/framework/angular/forms/templated-form-array.spec.ts @@ -0,0 +1,153 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { FormControl, FormGroup } from '@angular/forms'; +import { FormArrayTemplate, TemplatedFormArray } from './templated-form-array'; + +describe('TemplatedFormArray', () => { + class Template implements FormArrayTemplate { + public clearCalled = 0; + public removeCalled: number[] = []; + + public createControl() { + return new FormGroup({ + value: new FormControl(), + }); + } + + public clearControls() { + this.clearCalled++; + } + + public removeControl(index: number) { + this.removeCalled.push(index); + } + } + + let formTemplate: Template; + let formArray: TemplatedFormArray; + + beforeEach(() => { + formTemplate = new Template(); + formArray = new TemplatedFormArray(formTemplate); + }); + + type Test = [ (value: any) => void, string]; + + const methods: Test[] = [ + [x => formArray.setValue(x), 'setValue'], + [x => formArray.patchValue(x), 'patchValue'], + [x => formArray.reset(x), 'reset'], + ]; + + methods.forEach(([method, name]) => { + it(`Should call template to construct items for ${name}`, () => { + const value1 = [{ + value: 1, + }, { + value: 2, + }]; + + method(value1); + + expect(formArray.value).toEqual(value1); + }); + + it(`Should call template to remove items for ${name}`, () => { + const value1 = [{ + value: 1, + }, { + value: 2, + }, { + value: 3, + }, { + value: 4, + }]; + + const value2 = [{ + value: 1, + }, { + value: 2, + }]; + + method(value1); + method(value2); + + expect(formArray.value).toEqual(value2); + expect(formTemplate.clearCalled).toEqual(0); + expect(formTemplate.removeCalled).toEqual([3, 2]); + }); + + it(`Should call template to clear items with undefined for ${name}`, () => { + const value1 = [{ + value: 1, + }, { + value: 2, + }]; + + method(value1); + method(undefined); + + expect(formArray.value).toEqual(undefined); + expect(formTemplate.clearCalled).toEqual(1); + expect(formTemplate.removeCalled).toEqual([]); + }); + + it(`Should call template to clear items with empty array for ${name}`, () => { + const value1 = [{ + value: 1, + }, { + value: 2, + }]; + + method(value1); + method([]); + + expect(formArray.value).toEqual([]); + expect(formTemplate.clearCalled).toEqual(1); + expect(formTemplate.removeCalled).toEqual([]); + }); + }); + + it('should add control', () => { + formArray.add(); + formArray.add(); + + expect(formArray.value).toEqual([{ + value: null, + }, { + value: null, + }]); + }); + + it('should call template when cleared', () => { + formArray.add(); + formArray.clear(); + + expect(formTemplate.clearCalled).toEqual(1); + }); + + it('should not call template when clearing empty form', () => { + formArray.clear(); + + expect(formTemplate.clearCalled).toEqual(0); + }); + + it('should call template when item removed', () => { + formArray.add(); + formArray.removeAt(0); + + expect(formTemplate.removeCalled).toEqual([0]); + }); + + it('should not call template when item to remove out of bounds', () => { + formArray.add(); + formArray.removeAt(1); + + expect(formTemplate.removeCalled).toEqual([]); + }); +}); diff --git a/frontend/app/framework/angular/forms/templated-form-array.ts b/frontend/app/framework/angular/forms/templated-form-array.ts new file mode 100644 index 000000000..4b2493567 --- /dev/null +++ b/frontend/app/framework/angular/forms/templated-form-array.ts @@ -0,0 +1,86 @@ +/* +* Squidex Headless CMS +* +* @license +* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. +*/ + +import { AbstractControl, AbstractControlOptions, AsyncValidatorFn, ValidatorFn } from '@angular/forms'; +import { Types } from '@app/framework/internal'; +import { UndefinableFormArray } from './undefinable-form-array'; + +export interface FormArrayTemplate { + createControl(value: any, initialValue?: any): AbstractControl; + + removeControl?(index: number, control: AbstractControl) : void; + + clearControls?(): void; +} + +export class TemplatedFormArray extends UndefinableFormArray { + constructor(public readonly template: FormArrayTemplate, + validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null, + ) { + super([], validatorOrOpts, asyncValidator); + } + + public setValue(value?: any[], options?: { onlySelf?: boolean; emitEvent?: boolean }) { + this.prepare(value); + + super.setValue(value, options); + } + + public patchValue(value?: any[], options?: { onlySelf?: boolean; emitEvent?: boolean }) { + this.prepare(value); + + super.patchValue(value, options); + } + + public reset(value?: any[], options?: { onlySelf?: boolean; emitEvent?: boolean }) { + this.prepare(value); + + super.reset(value, options); + } + + public add(initialValue?: any) { + const control = this.template.createControl({}, initialValue); + + this.push(control); + + return control; + } + + public removeAt(index: number, options?: { emitEvent?: boolean }) { + if (this.template.removeControl && index >= 0 && index < this.controls.length) { + this.template.removeControl(index, this.controls[index]); + } + + super.removeAt(index, options); + } + + public clear(options?: { emitEvent?: boolean }) { + if (this.template.clearControls && this.controls.length > 0) { + this.template.clearControls(); + } + + super.clear(options); + } + + private prepare(value?: any[]) { + if (Types.isArray(value) && value.length > 0) { + let index = this.controls.length; + + while (this.controls.length < value.length) { + this.add(value[index]); + + index++; + } + + while (this.controls.length > value.length) { + this.removeAt(this.controls.length - 1, { emitEvent: false }); + } + } else { + this.clear(); + } + } +} diff --git a/frontend/app/framework/angular/forms/templated-form-group.spec.ts b/frontend/app/framework/angular/forms/templated-form-group.spec.ts new file mode 100644 index 000000000..0ce7b1c30 --- /dev/null +++ b/frontend/app/framework/angular/forms/templated-form-group.spec.ts @@ -0,0 +1,64 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { FormControl, FormGroup } from '@angular/forms'; +import { FormGroupTemplate, TemplatedFormGroup } from './templated-form-group'; + +describe('TemplatedFormGroup', () => { + class Template implements FormGroupTemplate { + public clearCalled = 0; + public removeCalled: number[] = []; + + public setControls(form: FormGroup) { + form.setControl('value', new FormControl()); + } + + public clearControls() { + this.clearCalled++; + } + } + + let formTemplate: Template; + let formArray: TemplatedFormGroup; + + beforeEach(() => { + formTemplate = new Template(); + formArray = new TemplatedFormGroup(formTemplate); + }); + + type Test = [ (value: any) => void, string]; + + const methods: Test[] = [ + [x => formArray.setValue(x), 'setValue'], + [x => formArray.patchValue(x), 'patchValue'], + [x => formArray.reset(x), 'reset'], + ]; + + methods.forEach(([method, name]) => { + it(`Should call template to construct controls for ${name}`, () => { + const value1 = { + value: 1, + }; + + method(value1); + + expect(formArray.value).toEqual(value1); + }); + it(`Should call template to clear items with for ${name}`, () => { + const value1 = { + value: 1, + }; + + method(value1); + method(undefined); + + expect(formArray.value).toEqual(undefined); + expect(formTemplate.clearCalled).toEqual(1); + expect(formTemplate.removeCalled).toEqual([]); + }); + }); +}); diff --git a/frontend/app/framework/angular/forms/templated-form-group.ts b/frontend/app/framework/angular/forms/templated-form-group.ts new file mode 100644 index 000000000..097d49db3 --- /dev/null +++ b/frontend/app/framework/angular/forms/templated-form-group.ts @@ -0,0 +1,50 @@ +/* +* Squidex Headless CMS +* +* @license +* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. +*/ + +import { AbstractControlOptions, AsyncValidatorFn, FormGroup, ValidatorFn } from '@angular/forms'; +import { Types } from '@app/framework/internal'; +import { UndefinableFormGroup } from './undefinable-form-group'; + +export interface FormGroupTemplate { + setControls(form: FormGroup, value: any): void; + + clearControls?(): void; +} + +export class TemplatedFormGroup extends UndefinableFormGroup { + constructor(public readonly template: FormGroupTemplate, + validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null, + ) { + super({}, validatorOrOpts, asyncValidator); + } + + public setValue(value?: {}, options?: { onlySelf?: boolean; emitEvent?: boolean }) { + this.build(value); + + super.setValue(value, options); + } + + public patchValue(value?: {}, options?: { onlySelf?: boolean; emitEvent?: boolean }) { + this.build(value); + + super.patchValue(value, options); + } + + public reset(value?: {}, options?: { onlySelf?: boolean; emitEvent?: boolean }) { + this.build(value); + + super.reset(value, options); + } + + public build(value?: {}) { + if (Types.isObject(value)) { + this.template?.setControls(this, value); + } else if (this.template?.clearControls) { + this.template?.clearControls(); + } + } +} diff --git a/frontend/app/framework/angular/forms/undefinable-form-array.ts b/frontend/app/framework/angular/forms/undefinable-form-array.ts index 6f4b1ee79..b86e29063 100644 --- a/frontend/app/framework/angular/forms/undefinable-form-array.ts +++ b/frontend/app/framework/angular/forms/undefinable-form-array.ts @@ -47,7 +47,7 @@ export class UndefinableFormArray extends FormArray { } public setValue(value?: any[], options?: { onlySelf?: boolean; emitEvent?: boolean }) { - this.isUndefined = Types.isUndefined(value); + this.checkUndefined(value); if (this.isUndefined) { super.reset([], options); @@ -57,7 +57,7 @@ export class UndefinableFormArray extends FormArray { } public patchValue(value?: any[], options?: { onlySelf?: boolean; emitEvent?: boolean }) { - this.isUndefined = Types.isUndefined(value); + this.checkUndefined(value); if (this.isUndefined) { super.reset([], options); @@ -67,11 +67,19 @@ export class UndefinableFormArray extends FormArray { } public reset(value?: any[], options?: { onlySelf?: boolean; emitEvent?: boolean }) { - this.isUndefined = Types.isUndefined(value); + this.checkUndefined(value); super.reset(value || [], options); } + private checkUndefined(value?: any[]) { + this.isUndefined = Types.isUndefined(value); + + if (this.isUndefined) { + this.clear({ emitEvent: false }); + } + } + public updateValueAndValidity(opts: { onlySelf?: boolean; emitEvent?: boolean } = {}) { super.updateValueAndValidity({ emitEvent: false, onlySelf: true }); diff --git a/frontend/app/framework/angular/forms/undefinable-form-group.ts b/frontend/app/framework/angular/forms/undefinable-form-group.ts index 74b60b6f4..d3632c335 100644 --- a/frontend/app/framework/angular/forms/undefinable-form-group.ts +++ b/frontend/app/framework/angular/forms/undefinable-form-group.ts @@ -24,8 +24,6 @@ export class UndefinableFormGroup extends FormGroup { return reduce.apply(this); } }; - - this.setValue(undefined); } public getRawValue() { @@ -37,31 +35,35 @@ export class UndefinableFormGroup extends FormGroup { } public setValue(value?: {}, options?: { onlySelf?: boolean; emitEvent?: boolean }) { - this.isUndefined = Types.isUndefined(value); + this.checkUndefined(value); if (this.isUndefined) { - super.reset([], options); + super.reset({}, options); } else { super.setValue(value!, options); } } public patchValue(value?: {}, options?: { onlySelf?: boolean; emitEvent?: boolean }) { - this.isUndefined = Types.isUndefined(value); + this.checkUndefined(value); if (this.isUndefined) { - super.reset([], options); + super.reset({}, options); } else { super.patchValue(value!, options); } } public reset(value?: {}, options: { onlySelf?: boolean; emitEvent?: boolean } = {}) { - this.isUndefined = Types.isUndefined(value); + this.checkUndefined(value); super.reset(value || {}, options); } + private checkUndefined(value?: {}) { + this.isUndefined = Types.isUndefined(value); + } + public updateValueAndValidity(opts: { onlySelf?: boolean; emitEvent?: boolean } = {}) { super.updateValueAndValidity({ emitEvent: false, onlySelf: true }); diff --git a/frontend/app/framework/declarations.ts b/frontend/app/framework/declarations.ts index 08fb49255..126b840c0 100644 --- a/frontend/app/framework/declarations.ts +++ b/frontend/app/framework/declarations.ts @@ -31,6 +31,7 @@ export * from './angular/forms/forms-helper'; export * from './angular/forms/indeterminate-value.directive'; export * from './angular/forms/model'; export * from './angular/forms/progress-bar.component'; +export * from './angular/forms/templated-form-array'; export * from './angular/forms/transform-input.directive'; export * from './angular/forms/undefinable-form-array'; export * from './angular/forms/undefinable-form-group'; diff --git a/frontend/app/framework/services/dialog.service.spec.ts b/frontend/app/framework/services/dialog.service.spec.ts index 57366580c..33bcb8299 100644 --- a/frontend/app/framework/services/dialog.service.spec.ts +++ b/frontend/app/framework/services/dialog.service.spec.ts @@ -160,7 +160,7 @@ describe('DialogService', () => { it('should publish tooltip', () => { const dialogService = new DialogService(localStore.object); - const tooltip = new Tooltip('target', 'text', 'left'); + const tooltip = new Tooltip('target', 'text', 'left-center'); let publishedTooltip: Tooltip; diff --git a/frontend/app/framework/utils/types.ts b/frontend/app/framework/utils/types.ts index e71b7a034..5727bbf93 100644 --- a/frontend/app/framework/utils/types.ts +++ b/frontend/app/framework/utils/types.ts @@ -197,9 +197,8 @@ export module Types { return source; } - Object.keys(source).forEach(key => { + Object.entries(source).forEach(([key, sourceValue]) => { const targetValue = target[key]; - const sourceValue = source[key]; if (Types.isArray(targetValue) && Types.isArray(sourceValue)) { target[key] = targetValue.concat(sourceValue); diff --git a/frontend/app/shared/components/assets/asset-dialog.component.html b/frontend/app/shared/components/assets/asset-dialog.component.html index a9ced0f81..2041f397a 100644 --- a/frontend/app/shared/components/assets/asset-dialog.component.html +++ b/frontend/app/shared/components/assets/asset-dialog.component.html @@ -151,7 +151,7 @@
-
diff --git a/frontend/app/shared/components/assets/asset.component.scss b/frontend/app/shared/components/assets/asset.component.scss index ffbf38534..232b70d33 100644 --- a/frontend/app/shared/components/assets/asset.component.scss +++ b/frontend/app/shared/components/assets/asset.component.scss @@ -138,6 +138,7 @@ $list-height: 2.25rem; .overlay { @include overlay; + cursor: pointer; font-size: $font-smallest; font-weight: normal; diff --git a/frontend/app/shared/services/auth.service.ts b/frontend/app/shared/services/auth.service.ts index f56310901..b5103c468 100644 --- a/frontend/app/shared/services/auth.service.ts +++ b/frontend/app/shared/services/auth.service.ts @@ -69,9 +69,7 @@ export class Profile { user: this.user, }; - for (const key of Object.keys(this.user.profile)) { - result[key] = this.user.profile[key]; - } + Object.assign(result, this.user.profile); return result; } diff --git a/frontend/app/shared/services/usages.service.ts b/frontend/app/shared/services/usages.service.ts index ab8764493..58c58f5fc 100644 --- a/frontend/app/shared/services/usages.service.ts +++ b/frontend/app/shared/services/usages.service.ts @@ -88,8 +88,8 @@ export class UsagesService { map(body => { const details: { [category: string]: CallsUsagePerDateDto[] } = {}; - for (const category of Object.keys(body.details)) { - details[category] = body.details[category].map((item: any) => + for (const [category, value] of Object.entries(body.details)) { + details[category] = (value as any).map((item: any) => new CallsUsagePerDateDto( DateTime.parseISO(item.date), item.totalBytes, diff --git a/frontend/app/shared/state/apps.forms.ts b/frontend/app/shared/state/apps.forms.ts index 32a3b467d..569f10554 100644 --- a/frontend/app/shared/state/apps.forms.ts +++ b/frontend/app/shared/state/apps.forms.ts @@ -7,8 +7,8 @@ /* eslint-disable no-useless-escape */ -import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { Form, ValidatorsEx } from '@app/framework'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Form, TemplatedFormArray, ValidatorsEx } from '@app/framework'; import { AppDto, AppSettingsDto, CreateAppDto, UpdateAppDto, UpdateAppSettingsDto } from './../services/apps.service'; export class CreateAppForm extends Form { @@ -39,95 +39,67 @@ export class UpdateAppForm extends Form { } export class EditAppSettingsForm extends Form { - public get patterns(): FormArray { - return this.form.controls['patterns']! as FormArray; + public get patterns() { + return this.form.controls['patterns']! as TemplatedFormArray; } public get patternsControls(): ReadonlyArray { return this.patterns.controls as any; } - public get editors(): FormArray { - return this.form.controls['editors']! as FormArray; + public get editors() { + return this.form.controls['editors']! as TemplatedFormArray; } public get editorsControls(): ReadonlyArray { return this.editors.controls as any; } - constructor( - private readonly formBuilder: FormBuilder, - ) { + constructor(formBuilder: FormBuilder) { super(formBuilder.group({ - patterns: formBuilder.array([]), + patterns: new TemplatedFormArray(new PatternTemplate(formBuilder)), hideScheduler: false, hideDateTimeButtons: false, - editors: formBuilder.array([]), + editors: new TemplatedFormArray(new EditorTemplate(formBuilder)), })); } +} - public addPattern() { - this.patterns.push( - this.formBuilder.group({ - name: ['', - [ - Validators.required, - ], - ], - regex: ['', - [ - Validators.required, - ], - ], - message: '', - })); - } +class PatternTemplate { + constructor(private readonly formBuilder: FormBuilder) {} - public addEditor() { - this.editors.push( - this.formBuilder.group({ - name: ['', - [ - Validators.required, - ], + public createControl() { + return this.formBuilder.group({ + name: ['', + [ + Validators.required, ], - url: ['', - [ - Validators.required, - ], + ], + regex: ['', + [ + Validators.required, ], - })); - } - - public removePattern(index: number) { - this.patterns.removeAt(index); - } - - public removeEditor(index: number) { - this.editors.removeAt(index); + ], + message: '', + }); } +} - public transformLoad(value: AppSettingsDto) { - const patterns = this.patterns; - - while (patterns.controls.length < value.patterns.length) { - this.addPattern(); - } - - while (patterns.controls.length > value.patterns.length) { - this.removePattern(patterns.controls.length - 1); - } - - const editors = this.editors; - - while (editors.controls.length < value.editors.length) { - this.addEditor(); - } - - while (editors.controls.length > value.editors.length) { - this.removeEditor(editors.controls.length - 1); - } +class EditorTemplate { + constructor(private readonly formBuilder: FormBuilder) {} - return value; + public createControl() { + return this.formBuilder.group({ + name: ['', + [ + Validators.required, + ], + ], + url: ['', + [ + Validators.required, + ], + ], + }); } } diff --git a/frontend/app/shared/state/assets.forms.ts b/frontend/app/shared/state/assets.forms.ts index 74ff66d86..a96245331 100644 --- a/frontend/app/shared/state/assets.forms.ts +++ b/frontend/app/shared/state/assets.forms.ts @@ -5,22 +5,21 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { Form, Mutable, Types } from '@app/framework'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Form, Mutable, TemplatedFormArray, Types } from '@app/framework'; import slugify from 'slugify'; import { AnnotateAssetDto, AssetDto, AssetFolderDto, RenameAssetFolderDto, RenameAssetTagDto } from './../services/assets.service'; export class AnnotateAssetForm extends Form { public get metadata() { - return this.form.get('metadata')! as FormArray; + return this.form.get('metadata')! as TemplatedFormArray; } + public get metadataControls(): ReadonlyArray { return this.metadata.controls as any; } - constructor( - private readonly formBuilder: FormBuilder, - ) { + constructor(formBuilder: FormBuilder) { super(formBuilder.group({ isProtected: [false, [ @@ -42,26 +41,10 @@ export class AnnotateAssetForm extends Form length) { - this.removeMetadata(this.metadata.controls.length - 1); - } - result.metadata = []; for (const name in value.metadata) { @@ -194,6 +167,21 @@ export class AnnotateAssetForm extends Form { constructor(formBuilder: FormBuilder) { super(formBuilder.group({ diff --git a/frontend/app/shared/state/contents.form-rules.ts b/frontend/app/shared/state/contents.form-rules.ts new file mode 100644 index 000000000..723dcf572 --- /dev/null +++ b/frontend/app/shared/state/contents.form-rules.ts @@ -0,0 +1,157 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +/* eslint-disable @typescript-eslint/no-implied-eval */ +/* eslint-disable no-useless-return */ + +import { Types } from '@app/framework'; +import { FieldRule, SchemaDto } from './../services/schemas.service'; + +export type RuleContext = { data: any; user?: any }; +export type RuleForm = { fieldPath: string }; + +export interface CompiledRules { + get rules(): ReadonlyArray; +} + +export interface RulesProvider { + compileRules(schema: SchemaDto): ReadonlyArray; + + getRules(form: RuleForm): CompiledRules; +} + +export class CompiledRule { + private readonly function: Function; + + public get field() { + return this.rule.field; + } + + public get action() { + return this.rule.action; + } + + constructor( + private readonly rule: FieldRule, + private readonly useItemData: boolean, + ) { + try { + this.function = new Function(`return function(user, ctx, data, itemData) { return ${rule.condition} }`)(); + } catch { + this.function = () => false; + } + } + + public eval(context: RuleContext, itemData: any) { + try { + const data = this.useItemData ? itemData || context.data : context.data; + + return this.function(context.user, context, data, itemData); + } catch { + return false; + } + } +} +const EMPTY_RULES: CompiledRule[] = []; +const EMPTY_RULES_STATIC = { rules: EMPTY_RULES }; + +class ComponentRules implements ComponentRules { + private previouSchema: SchemaDto; + private compiledRules: ReadonlyArray = []; + + public get rules() { + const schema = this.schema(); + + if (schema !== this.previouSchema) { + if (schema) { + this.compiledRules = Types.fastMerge(this.parent.getRules(this.form).rules, this.getRelativeRules(this.form, schema)); + } else { + this.compiledRules = EMPTY_RULES; + } + } + + return this.compiledRules; + } + + constructor( + private readonly form: RuleForm, + private readonly parentPath: string, + private readonly parent: RulesProvider, + private readonly schema: () => SchemaDto | undefined, + ) { + } + + private getRelativeRules(form: RuleForm, schema: SchemaDto) { + const rules = this.parent.compileRules(schema); + + if (rules.length === 0) { + return EMPTY_RULES; + } + + const pathField = form.fieldPath.substr(this.parentPath.length + 1); + const pathSimplified = pathField.replace('.iv.', '.'); + + return rules.filter(x => x.field === pathField || x.field === pathSimplified); + } +} + +export class ComponentRulesProvider implements RulesProvider { + constructor( + private readonly parentPath: string, + private readonly parent: RulesProvider, + private readonly schema: () => SchemaDto | undefined, + ) { + } + + public compileRules(schema: SchemaDto) { + return this.parent.compileRules(schema); + } + + public getRules(form: RuleForm) { + return new ComponentRules(form, this.parentPath, this.parent, this.schema); + } +} + +export class RootRulesProvider implements RulesProvider { + private readonly rulesCache: { [id: string]: ReadonlyArray } = {}; + private readonly rules: ReadonlyArray; + + constructor(schema: SchemaDto) { + this.rules = this.compileRules(schema); + } + + public compileRules(schema: SchemaDto) { + if (!schema) { + return EMPTY_RULES; + } + + let result = this.rulesCache[schema.id]; + + if (!result) { + result = schema.fieldRules.map(x => new CompiledRule(x, true)); + + this.rulesCache[schema.id] = result; + } + + return result; + } + + public getRules(form: RuleForm) { + const allRules = this.rules; + + if (allRules.length === 0) { + return EMPTY_RULES_STATIC; + } + + const pathField = form.fieldPath; + const pathSimplified = pathField.replace('.iv.', '.'); + + const rules = allRules.filter(x => x.field === pathField || x.field === pathSimplified); + + return { rules }; + } +} diff --git a/frontend/app/shared/state/contents.forms-helpers.ts b/frontend/app/shared/state/contents.forms-helpers.ts index 02c295d19..fd67c9dba 100644 --- a/frontend/app/shared/state/contents.forms-helpers.ts +++ b/frontend/app/shared/state/contents.forms-helpers.ts @@ -9,12 +9,13 @@ /* eslint-disable no-useless-return */ import { AbstractControl, ValidatorFn } from '@angular/forms'; -import { getRawValue, Types } from '@app/framework'; +import { getRawValue } from '@app/framework'; import { BehaviorSubject, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { AppLanguageDto } from './../services/app-languages.service'; -import { FieldDto, FieldRule, RootFieldDto, SchemaDto } from './../services/schemas.service'; +import { FieldDto, RootFieldDto, SchemaDto } from './../services/schemas.service'; import { fieldInvariant } from './../services/schemas.types'; +import { CompiledRules, RuleContext, RulesProvider } from './contents.form-rules'; export abstract class Hidden { private readonly hidden$ = new BehaviorSubject(false); @@ -106,41 +107,6 @@ export class PartitionConfig { } } -type RuleContext = { data: any; user?: any }; - -export class CompiledRule { - private readonly function: Function; - - public get field() { - return this.rule.field; - } - - public get action() { - return this.rule.action; - } - - constructor( - private readonly rule: FieldRule, - private readonly useItemData: boolean, - ) { - try { - this.function = new Function(`return function(user, ctx, data, itemData) { return ${rule.condition} }`)(); - } catch { - this.function = () => false; - } - } - - public eval(context: RuleContext, itemData: any) { - try { - const data = this.useItemData ? itemData || context.data : context.data; - - return this.function(context.user, context, data, itemData); - } catch { - return false; - } - } -} - export type AbstractContentFormState = { isDisabled?: boolean; isHidden?: boolean; @@ -154,96 +120,9 @@ export interface FormGlobals { remoteValidator?: ValidatorFn; } -const EMPTY_RULES: CompiledRule[] = []; - -export interface RulesProvider { - compileRules(schema: SchemaDto | undefined): ReadonlyArray; - - setSchema(schema?: SchemaDto): void; - - getRules(form: AbstractContentForm): ReadonlyArray; -} - -export class ComponentRulesProvider implements RulesProvider { - private schema?: SchemaDto; - - constructor( - private readonly parentPath: string, - private readonly parent: RulesProvider, - ) { - } - - public setSchema(schema?: SchemaDto) { - this.schema = schema; - } - - public compileRules(schema: SchemaDto | undefined): ReadonlyArray { - return this.parent.compileRules(schema); - } - - public getRules(form: AbstractContentForm) { - return Types.fastMerge(this.parent.getRules(form), this.getRelativeRules(form)); - } - - private getRelativeRules(form: AbstractContentForm) { - const rules = this.compileRules(this.schema); - - if (rules.length === 0) { - return EMPTY_RULES; - } - - const pathField = form.fieldPath.substr(this.parentPath.length + 1); - const pathSimplified = pathField.replace('.iv.', '.'); - - return rules.filter(x => x.field === pathField || x.field === pathSimplified); - } -} - -export class RootRulesProvider implements RulesProvider { - private readonly compiledRules: { [id: string]: ReadonlyArray } = {}; - private readonly rules: ReadonlyArray; - - constructor(schema: SchemaDto) { - this.rules = schema.fieldRules.map(x => new CompiledRule(x, false)); - } - - public setSchema() { - return; - } - - public compileRules(schema: SchemaDto | undefined) { - if (!schema) { - return EMPTY_RULES; - } - - let result = this.compileRules[schema.id]; - - if (!result) { - result = schema.fieldRules.map(x => new CompiledRule(x, true)); - - this.compiledRules[schema.id] = result; - } - - return result; - } - - public getRules(form: AbstractContentForm) { - const rules = this.rules; - - if (rules.length === 0) { - return EMPTY_RULES; - } - - const pathField = form.fieldPath; - const pathSimplified = pathField.replace('.iv.', '.'); - - return rules.filter(x => x.field === pathField || x.field === pathSimplified); - } -} - export abstract class AbstractContentForm extends Hidden { private readonly disabled$ = new BehaviorSubject(false); - private readonly currentRules: ReadonlyArray; + private readonly ruleSet: CompiledRules; public get disabled() { return this.disabled$.value; @@ -263,7 +142,7 @@ export abstract class AbstractContentForm { it('should hide components fields based on condition', () => { const componentId = MathHelper.guid(); const component = createSchema({ + id: 2, fields: [ createField({ id: 1, @@ -553,7 +554,7 @@ describe('ContentForm', () => { it('should reset array item', () => { const { array } = createArrayFormWith2Items(); - array.reset(); + array.setValue([]); expectLength(array, 0); expect(array.form.value).toEqual([]); diff --git a/frontend/app/shared/state/contents.forms.ts b/frontend/app/shared/state/contents.forms.ts index 03e4e87fc..5233f7cc8 100644 --- a/frontend/app/shared/state/contents.forms.ts +++ b/frontend/app/shared/state/contents.forms.ts @@ -6,13 +6,15 @@ */ import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; -import { debounceTimeSafe, Form, getRawValue, Types, UndefinableFormArray, UndefinableFormGroup, value$ } from '@app/framework'; +import { debounceTimeSafe, Form, FormArrayTemplate, getRawValue, TemplatedFormArray, Types, value$ } from '@app/framework'; +import { FormGroupTemplate, TemplatedFormGroup } from '@app/framework/angular/forms/templated-form-group'; import { BehaviorSubject, distinctUntilChanged, Observable } from 'rxjs'; import { AppLanguageDto } from './../services/app-languages.service'; import { LanguageDto } from './../services/languages.service'; import { FieldDto, RootFieldDto, SchemaDto, TableField } from './../services/schemas.service'; import { ComponentFieldPropertiesDto, fieldInvariant } from './../services/schemas.types'; -import { AbstractContentForm, AbstractContentFormState, ComponentRulesProvider, FieldSection, FormGlobals, groupFields, PartitionConfig, RootRulesProvider, RulesProvider } from './contents.forms-helpers'; +import { ComponentRulesProvider, RootRulesProvider, RulesProvider } from './contents.form-rules'; +import { AbstractContentForm, AbstractContentFormState, FieldSection, FormGlobals, groupFields, PartitionConfig } from './contents.forms-helpers'; import { FieldDefaultValue, FieldsValidators } from './contents.forms.visitors'; type SaveQueryFormType = { name: string; user: boolean }; @@ -150,10 +152,6 @@ export class EditContentForm extends Form { } public load(value: any, isInitial?: boolean) { - for (const key of Object.keys(this.fields)) { - this.fields[key].prepareLoad(value?.[key]); - } - super.load(value); if (isInitial) { @@ -238,12 +236,6 @@ export class FieldForm extends AbstractContentForm { } } - public prepareLoad(value: any) { - for (const key of Object.keys(this.partitions)) { - this.partitions[key].prepareLoad(value?.[key]); - } - } - protected updateCustomState(context: any, fieldData: any, itemData: any, state: AbstractContentFormState) { const isRequired = state.isRequired === true; @@ -270,8 +262,8 @@ export class FieldForm extends AbstractContentForm { } } - for (const key of Object.keys(this.partitions)) { - this.partitions[key].updateState(context, fieldData?.[key], itemData, state); + for (const [key, partition] of Object.entries(this.partitions)) { + partition.updateState(context, fieldData?.[key], itemData, state); } } @@ -283,14 +275,7 @@ export class FieldForm extends AbstractContentForm { export class FieldValueForm extends AbstractContentForm { private isRequired = false; - constructor( - globals: FormGlobals, - field: FieldDto, - fieldPath: string, - isOptional: boolean, - rules: RulesProvider, - partition: string, - ) { + constructor(globals: FormGlobals, field: FieldDto, fieldPath: string, isOptional: boolean, rules: RulesProvider, partition: string) { super(globals, field, fieldPath, FieldValueForm.buildControl(field, isOptional, partition, globals), isOptional, rules); @@ -330,10 +315,10 @@ export class FieldValueForm extends AbstractContentForm { } } -export class FieldArrayForm extends AbstractContentForm { - private readonly item$ = new BehaviorSubject>([]); +export class FieldArrayForm extends AbstractContentForm { + private readonly item$ = new BehaviorSubject>([]); - public get itemChanges(): Observable> { + public get itemChanges(): Observable> { return this.item$; } @@ -341,83 +326,42 @@ export class FieldArrayForm extends AbstractContentForm) { + public set items(value: ReadonlyArray) { this.item$.next(value); } - constructor( - globals: FormGlobals, - field: FieldDto, - fieldPath: string, - isOptional: boolean, - rules: RulesProvider, - private readonly partition: string, - private readonly isComponents: boolean, + constructor(globals: FormGlobals, field: FieldDto, fieldPath: string, isOptional: boolean, rules: RulesProvider, + public readonly partition: string, + public readonly isComponents: boolean, ) { super(globals, field, fieldPath, FieldArrayForm.buildControl(field, isOptional), isOptional, rules); + + this.form.template['form'] = this; } public get(index: number) { return this.items[index]; } - public addCopy(source: ObjectForm) { - if (this.isComponents) { - const child = this.createComponent(); - - child.load(getRawValue(source.form)); - - this.addChild(child); - } else { - const child = this.createItem(); - - child.load(getRawValue(source.form)); - - this.addChild(child); - } + public addCopy(source: ObjectFormBase) { + this.form.add().reset(getRawValue(source.form)); } - public addComponent(schemaId?: string) { - const child = this.createComponent(schemaId); - - this.addChild(child); + public addComponent(schemaId: string) { + this.form.add().reset({ schemaId }); } public addItem() { - const child = this.createItem(); - - this.addChild(child); - } - - public addChild(child: ObjectForm) { - this.items = [...this.items, child]; - - this.form.push(child.form); - } - - public unset() { - this.items = []; - - super.unset(); - - this.form.clear(); - } - - public reset() { - this.items = []; - - this.form.clear(); + this.form.add(); } public removeItemAt(index: number) { - this.items = this.items.filter((_, i) => i !== index); - this.form.removeAt(index); } - public move(index: number, item: ObjectForm) { + public move(index: number, item: ObjectFormBase) { const children = [...this.items]; children.splice(children.indexOf(item), 1); @@ -428,86 +372,102 @@ export class FieldArrayForm extends AbstractContentForm) { + public sort(children: ReadonlyArray) { for (let i = 0; i < children.length; i++) { this.form.setControl(i, children[i].form); } } - public prepareLoad(value: any) { - if (Types.isArray(value)) { - while (this.items.length < value.length) { - if (this.isComponents) { - this.addComponent(); - } else { - this.addItem(); - } - } - - while (this.items.length > value.length) { - this.removeItemAt(this.items.length - 1); - } - } - - for (let i = 0; i < this.items.length; i++) { - this.items[i].prepareLoad(value?.[i]); - } - } - protected updateCustomState(context: any, fieldData: any, itemData: any, state: AbstractContentFormState) { for (let i = 0; i < this.items.length; i++) { this.items[i].updateState(context, fieldData?.[i], itemData, state); } } + private static buildControl(field: FieldDto, isOptional: boolean) { + return new TemplatedFormArray(new ArrayTemplate(), FieldsValidators.create(field, isOptional)); + } +} + +class ArrayTemplate implements FormArrayTemplate { + public form: FieldArrayForm; + + public createControl() { + const child = this.form.isComponents ? + this.createComponent() : + this.createItem(); + + this.form.items = [...this.form.items, child]; + + return child.form; + } + + public removeControl(index: number) { + this.form.items = this.form.items.filter((_, i) => i !== index); + } + + public clearControls() { + this.form.items = []; + } + private createItem() { return new ArrayItemForm( - this.globals, - this.field as RootFieldDto, - this.fieldPath, - this.isOptional, - this.rules, - this.partition); + this.form.globals, + this.form.field as RootFieldDto, + this.form.fieldPath, + this.form.isOptional, + this.form.rules, + this.form.partition); } - private createComponent(schemaId?: string) { + private createComponent() { return new ComponentForm( - this.globals, - this.field as RootFieldDto, - this.fieldPath, - this.isOptional, - this.rules, - this.partition, - schemaId); + this.form.globals, + this.form.field as RootFieldDto, + this.form.fieldPath, + this.form.isOptional, + this.form.rules, + this.form.partition); } +} - private static buildControl(field: FieldDto, isOptional: boolean) { - const validators = FieldsValidators.create(field, isOptional); +export type FieldItemForm = ComponentForm | FieldValueForm | FieldArrayForm; + +type FieldMap = { [name: string]: FieldItemForm }; - return new UndefinableFormArray([], validators); +export class ObjectFormBase extends AbstractContentForm { + private readonly fieldSections$ = new BehaviorSubject>>([]); + private readonly fields$ = new BehaviorSubject({}); + + public get fieldSectionsChanges(): Observable>> { + return this.fieldSections$; } -} -export type FieldItemForm = ComponentForm | FieldValueForm | FieldArrayForm; + public get fieldSections() { + return this.fieldSections$.value; + } -export class ObjectForm extends AbstractContentForm { - private fields: { [key: string]: FieldItemForm } = {}; - private fieldSections: FieldSection[] = []; + public set fieldSections(value: ReadonlyArray>) { + this.fieldSections$.next(value); + } - public get sections() { - return this.fieldSections; + public get fieldsChanges(): Observable { + return this.fields$; } - constructor( - globals: FormGlobals, - field: TField, - fieldPath: string, - isOptional: boolean, - rules: RulesProvider, - private readonly partition: string, + public get fields() { + return this.fields$.value; + } + + public set fields(value: FieldMap) { + this.fields$.next(value); + } + + constructor(globals: FormGlobals, field: TField, fieldPath: string, isOptional: boolean, rules: RulesProvider, template: ObjectTemplate, + public readonly partition: string, ) { super(globals, field, fieldPath, - ObjectForm.buildControl(field, isOptional, false), + ObjectFormBase.buildControl(template), isOptional, rules); } @@ -515,146 +475,162 @@ export class ObjectForm extends AbstractCont return this.fields[field['name'] || field]; } - protected init(schema?: ReadonlyArray) { - this.fields = {}; - this.fieldSections = []; + protected updateCustomState(context: any, fieldData: any, _: any, state: AbstractContentFormState) { + for (const [key, field] of Object.entries(this.fields)) { + field.updateState(context, fieldData?.[key], fieldData, state); + } - for (const key of Object.keys(this.form.controls)) { - this.form.removeControl(key); + for (const section of this.fieldSections) { + section.updateHidden(); } + } - if (schema) { - this.form.reset({}); + private static buildControl(template: ObjectTemplate) { + return new TemplatedFormGroup(template); + } +} - for (const { separator, fields } of groupFields(schema)) { - const forms: FieldItemForm[] = []; +abstract class ObjectTemplate implements FormGroupTemplate { + private currentSchema: ReadonlyArray | undefined; + + protected get model() { + return this.modelProvider(); + } - for (const field of fields) { - const childForm = - buildForm( - this.globals, - field, - this.path(field.name), - this.isOptional, - this.rules, - this.partition); + constructor( + private readonly modelProvider: () => T, + ) { + } - this.form.setControl(field.name, childForm.form); + protected abstract getSchema(value: any, model: T): ReadonlyArray | undefined; - forms.push(childForm); + public setControls(form: FormGroup, value: any) { + const schema = this.getSchema(value, this.model); - this.fields[field.name] = childForm; - } + if (this.currentSchema !== schema) { + this.clearControlsCore(this.model); - this.fieldSections.push(new FieldSection(separator, forms)); + if (schema) { + this.setControlsCore(schema, value, this.model, form); } - } else { - this.form.reset(undefined); + + this.currentSchema = schema; } } - public load(data: any) { - this.prepareLoad(data); - - this.form.reset(data); - } + public clearControls() { + if (this.currentSchema !== undefined) { + this.clearControlsCore(this.model); - public prepareLoad(value: any) { - for (const key of Object.keys(this.fields)) { - this.fields[key].prepareLoad(value?.[key]); + this.currentSchema = undefined; } } - protected updateCustomState(context: any, fieldData: any, _: any, state: AbstractContentFormState) { - for (const key of Object.keys(this.fields)) { - this.fields[key].updateState(context, fieldData?.[key], fieldData, state); - } + protected setControlsCore(schema: ReadonlyArray, value: any, model: T, form: FormGroup) { + const fieldMap: FieldMap = {}; + const fieldSections: FieldSection[] = []; - for (const section of this.sections) { - section.updateHidden(); + for (const { separator, fields } of groupFields(schema)) { + const forms: FieldItemForm[] = []; + + for (const field of fields) { + const childForm = buildForm( + model.globals, + field, + model.path(field.name), + model.isOptional, + model.rules, + model.partition); + + form.setControl(field.name, childForm.form); + + forms.push(childForm); + + fieldMap[field.name] = childForm; + } + + fieldSections.push(new FieldSection(separator, forms)); } - } - private static buildControl(field: FieldDto, isOptional: boolean, validate: boolean) { - let validators = [Validators.nullValidator]; + model.fields = fieldMap; + model.fieldSections = fieldSections; + } - if (validate) { - validators = FieldsValidators.create(field, isOptional); + protected clearControlsCore(model: T) { + for (const name of Object.keys(model.form.controls)) { + model.form.removeControl(name); } - return new UndefinableFormGroup({}, validators); + model.fields = {}; + model.fieldSections = []; } } -export class ArrayItemForm extends ObjectForm { - constructor( - globals: FormGlobals, - field: RootFieldDto, - fieldPath: string, - isOptional: boolean, - rules: RulesProvider, - partition: string, - ) { - super(globals, field, fieldPath, isOptional, rules, partition); +export class ArrayItemForm extends ObjectFormBase { + constructor(globals: FormGlobals, field: RootFieldDto, fieldPath: string, isOptional: boolean, rules: RulesProvider, partition: string) { + super(globals, field, fieldPath, isOptional, rules, + new ArrayItemTemplate(() => this), partition); - this.init(field.nested); + this.form.build({}); } } -export class ComponentForm extends ObjectForm { - private schemaId?: string; +class ArrayItemTemplate extends ObjectTemplate { + public getSchema() { + return this.model.field.nested; + } +} - public readonly properties: ComponentFieldPropertiesDto; +export class ComponentForm extends ObjectFormBase { + private readonly schema$ = new BehaviorSubject(undefined); - public get schema() { - return this.globals.schemas[this.schemaId!]; + public get schemaChanges(): Observable { + return this.schema$; } - constructor( - globals: FormGlobals, - field: FieldDto, - fieldPath: string, - isOptional: boolean, - rules: RulesProvider, - partition: string, - schemaId?: string, - ) { - super(globals, field, fieldPath, isOptional, - new ComponentRulesProvider(fieldPath, rules), partition); + public get schema() { + return this.schema$.value; + } - this.properties = field.properties as ComponentFieldPropertiesDto; + public set schema(value: SchemaDto | undefined) { + this.schema$.next(value); + } - if (schemaId) { - this.selectSchema(schemaId); - } + public get properties() { + return this.field.properties as ComponentFieldPropertiesDto; } - public selectSchema(schemaId?: string) { - if (this.schemaId !== schemaId) { - this.schemaId = schemaId; + constructor(globals: FormGlobals, field: FieldDto, fieldPath: string, isOptional: boolean, rules: RulesProvider, partition: string) { + super(globals, field, fieldPath, isOptional, + new ComponentRulesProvider(fieldPath, rules, () => this.schema), + new ComponentTemplate(() => this), + partition); - if (this.schema) { - this.rules.setSchema(this.schema); + this.form.build(); + } - this.init(this.schema.fields); + public selectSchema(schemaId: string) { + this.form.reset({ schemaId }); + } +} - this.form.setControl('schemaId', new FormControl(schemaId)); - } else { - this.init(undefined); - } - } +class ComponentTemplate extends ObjectTemplate { + public getSchema(value: any, model: ComponentForm) { + return model.globals.schemas[value?.schemaId].fields; } - public unset() { - this.selectSchema(undefined); + protected setControlsCore(schema: ReadonlyArray, value: any, model: ComponentForm, form: FormGroup) { + form.setControl('schemaId', new FormControl()); + + this.model.schema = model.globals.schemas[value?.schemaId]; - super.unset(); + super.setControlsCore(schema, value, model, form); } - public prepareLoad(value: any) { - this.selectSchema(value?.['schemaId']); + protected clearControlsCore(model: ComponentForm) { + this.model.schema = undefined; - super.prepareLoad(value); + super.clearControlsCore(model); } } diff --git a/frontend/app/shared/state/queries.ts b/frontend/app/shared/state/queries.ts index 0ae0a406e..af640d82b 100644 --- a/frontend/app/shared/state/queries.ts +++ b/frontend/app/shared/state/queries.ts @@ -97,7 +97,7 @@ export class Queries { } function parseQueries(settings: {}) { - const queries = Object.keys(settings).map(name => parseStored(name, settings[name])); + const queries = Object.entries(settings).map(([name, value]) => parseStored(name, value as any)); return queries.sort((a, b) => compareStrings(a.name, b.name)); } diff --git a/frontend/app/shared/state/roles.forms.ts b/frontend/app/shared/state/roles.forms.ts index ee0af2bca..07bc9c15e 100644 --- a/frontend/app/shared/state/roles.forms.ts +++ b/frontend/app/shared/state/roles.forms.ts @@ -5,25 +5,17 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; -import { Form, hasNoValue$, hasValue$ } from '@app/framework'; +import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +import { Form, hasNoValue$, hasValue$, TemplatedFormArray } from '@app/framework'; import { CreateRoleDto, RoleDto, UpdateRoleDto } from './../services/roles.service'; -export class EditRoleForm extends Form { +export class EditRoleForm extends Form { public get controls() { return this.form.controls as FormControl[]; } constructor() { - super(new FormArray([])); - } - - public add(value?: string) { - this.form.push(new FormControl(value, Validators.required)); - } - - public remove(index: number) { - this.form.removeAt(index); + super(new TemplatedFormArray(new PermissionTemplate())); } public transformSubmit(value: any) { @@ -31,17 +23,13 @@ export class EditRoleForm extends Form { } public transformLoad(value: Partial) { - const permissions = value.permissions || []; - - while (this.form.controls.length < permissions.length) { - this.add(); - } - - while (permissions.length > this.form.controls.length) { - this.form.removeAt(this.form.controls.length - 1); - } + return value.permissions || []; + } +} - return value.permissions; +class PermissionTemplate { + public createControl(_: any, initialValue: string) { + return new FormControl(initialValue, Validators.required); } } diff --git a/frontend/app/shared/state/schemas.forms.ts b/frontend/app/shared/state/schemas.forms.ts index 9cc5ee09b..c4573ace2 100644 --- a/frontend/app/shared/state/schemas.forms.ts +++ b/frontend/app/shared/state/schemas.forms.ts @@ -7,8 +7,8 @@ /* eslint-disable no-useless-escape */ -import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { Form, ValidatorsEx, value$ } from '@app/framework'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { Form, TemplatedFormArray, ValidatorsEx, value$ } from '@app/framework'; import { map } from 'rxjs/operators'; import { AddFieldDto, CreateSchemaDto, FieldRule, SchemaDto, SchemaPropertiesDto, SynchronizeSchemaDto, UpdateSchemaDto } from './../services/schemas.service'; import { createProperties, FieldPropertiesDto, FieldPropertiesVisitor } from './../services/schemas.types'; @@ -78,36 +78,17 @@ export class SynchronizeSchemaForm extends Form } } -export class ConfigureFieldRulesForm extends Form, SchemaDto> { +export class ConfigureFieldRulesForm extends Form, SchemaDto> { public get rulesControls(): ReadonlyArray { return this.form.controls as any; } - constructor( - private readonly formBuilder: FormBuilder, - ) { - super(formBuilder.array([])); + constructor(formBuilder: FormBuilder) { + super(new TemplatedFormArray(new FieldRuleTemplate(formBuilder))); } public add(fieldNames: ReadonlyArray) { - this.form.push( - this.formBuilder.group({ - action: ['Disable', - [ - Validators.required, - ], - ], - field: [fieldNames[0], - [ - Validators.required, - ], - ], - condition: ['', - [ - Validators.required, - ], - ], - })); + this.form.add(fieldNames); } public remove(index: number) { @@ -115,71 +96,51 @@ export class ConfigureFieldRulesForm extends Form) { - const result = value.fieldRules || []; - - while (this.form.controls.length < result.length) { - this.add([]); - } + return value.fieldRules || []; + } +} - while (this.form.controls.length > result.length) { - this.remove(this.form.controls.length - 1); - } +class FieldRuleTemplate { + constructor(private readonly formBuilder: FormBuilder) {} - return result; + public createControl(_: any, fieldNames?: ReadonlyArray) { + return this.formBuilder.group({ + action: ['Disable', + [ + Validators.required, + ], + ], + field: [fieldNames?.[0], + [ + Validators.required, + ], + ], + condition: ['', + [ + Validators.required, + ], + ], + }); } } type ConfigurePreviewUrlsFormType = { [name: string]: string }; -export class ConfigurePreviewUrlsForm extends Form { +export class ConfigurePreviewUrlsForm extends Form { public get previewControls(): ReadonlyArray { return this.form.controls as any; } - constructor( - private readonly formBuilder: FormBuilder, - ) { - super(formBuilder.array([])); - } - - public add() { - this.form.push( - this.formBuilder.group({ - name: ['', - [ - Validators.required, - ], - ], - url: ['', - [ - Validators.required, - ], - ], - })); - } - - public remove(index: number) { - this.form.removeAt(index); + constructor(formBuilder: FormBuilder) { + super(new TemplatedFormArray(new PreviewUrlTemplate(formBuilder))); } public transformLoad(value: Partial) { const result = []; - const previewUrls = value.previewUrls || {}; - - const length = Object.keys(previewUrls).length; - - while (this.form.controls.length < length) { - this.add(); - } - - while (this.form.controls.length > length) { - this.remove(this.form.controls.length - 1); - } - - for (const key in previewUrls) { - if (previewUrls.hasOwnProperty(key)) { - result.push({ name: key, url: previewUrls[key] }); + if (value.previewUrls) { + for (const [name, url] of Object.entries(value.previewUrls)) { + result.push({ name, url }); } } @@ -197,6 +158,25 @@ export class ConfigurePreviewUrlsForm extends Form { constructor(formBuilder: FormBuilder) { super(formBuilder.group({