Browse Source

Merge branch 'master' of github.com:Squidex/squidex

pull/800/head
Sebastian 4 years ago
parent
commit
d426bfcab3
  1. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs
  2. 4
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppCommandMiddleware.cs
  3. 6
      backend/src/Squidex.Domain.Apps.Entities/Assets/ImageAssetMetadataSource.cs
  4. 4
      backend/src/Squidex.Infrastructure/Commands/DomainObjectGrainFormatter.cs
  5. 2
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  6. 13
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
  7. 2
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs
  8. 8
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetContentQueryDto.cs
  9. 19
      backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs
  10. 10
      backend/src/Squidex/Config/Domain/AssetServices.cs
  11. 15
      backend/src/Squidex/Config/Domain/InfrastructureServices.cs
  12. 10
      backend/src/Squidex/Squidex.csproj
  13. 8
      backend/src/Squidex/appsettings.json
  14. 53
      backend/src/Squidex/wwwroot/scripts/editor-plain.html
  15. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppCommandMiddlewareTests.cs
  16. 18
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs
  17. 2
      backend/tests/docker-compose.yml
  18. 110
      backend/tools/TestSuite/TestSuite.ApiTests/AssetFormatTests.cs
  19. BIN
      backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleGIFImage_40kbmb.gif
  20. BIN
      backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_100kb.webp
  21. BIN
      backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_150kb.gif
  22. BIN
      backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_400kb.png
  23. BIN
      backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_400kb.tiff
  24. BIN
      backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_600kb.tga
  25. BIN
      backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_62kb.jpg
  26. BIN
      backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_700kb.bmp
  27. BIN
      backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleJPGImage_50kbmb.jpg
  28. BIN
      backend/tools/TestSuite/TestSuite.ApiTests/Assets/SamplePNGImage_100kbmb.png
  29. 29
      backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj
  30. 6
      backend/tools/TestSuite/TestSuite.Shared/Fixtures/AssetFixture.cs
  31. 10
      frontend/app/features/content/shared/forms/array-editor.component.ts
  32. 2
      frontend/app/features/content/shared/forms/array-item.component.html
  33. 4
      frontend/app/features/content/shared/forms/array-item.component.ts
  34. 6
      frontend/app/features/content/shared/forms/component.component.html
  35. 6
      frontend/app/features/dashboard/pages/cards/api-calls-card.component.ts
  36. 6
      frontend/app/features/dashboard/pages/cards/api-performance-card.component.ts
  37. 6
      frontend/app/features/dashboard/pages/cards/api-traffic-card.component.ts
  38. 2
      frontend/app/features/schemas/pages/schema/fields/forms/field-form.component.html
  39. 4
      frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.html
  40. 4
      frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.ts
  41. 2
      frontend/app/features/settings/pages/roles/role.component.html
  42. 6
      frontend/app/features/settings/pages/roles/role.component.ts
  43. 8
      frontend/app/features/settings/pages/settings/settings-page.component.html
  44. 153
      frontend/app/framework/angular/forms/templated-form-array.spec.ts
  45. 86
      frontend/app/framework/angular/forms/templated-form-array.ts
  46. 64
      frontend/app/framework/angular/forms/templated-form-group.spec.ts
  47. 50
      frontend/app/framework/angular/forms/templated-form-group.ts
  48. 14
      frontend/app/framework/angular/forms/undefinable-form-array.ts
  49. 16
      frontend/app/framework/angular/forms/undefinable-form-group.ts
  50. 1
      frontend/app/framework/declarations.ts
  51. 2
      frontend/app/framework/services/dialog.service.spec.ts
  52. 3
      frontend/app/framework/utils/types.ts
  53. 4
      frontend/app/shared/components/assets/asset-dialog.component.html
  54. 1
      frontend/app/shared/components/assets/asset.component.scss
  55. 4
      frontend/app/shared/services/auth.service.ts
  56. 4
      frontend/app/shared/services/usages.service.ts
  57. 108
      frontend/app/shared/state/apps.forms.ts
  58. 54
      frontend/app/shared/state/assets.forms.ts
  59. 157
      frontend/app/shared/state/contents.form-rules.ts
  60. 139
      frontend/app/shared/state/contents.forms-helpers.ts
  61. 3
      frontend/app/shared/state/contents.forms.spec.ts
  62. 424
      frontend/app/shared/state/contents.forms.ts
  63. 2
      frontend/app/shared/state/queries.ts
  64. 32
      frontend/app/shared/state/roles.forms.ts
  65. 128
      frontend/app/shared/state/schemas.forms.ts

4
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;

4
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)
{

6
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();

4
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<IIncomingGrainCallContext, string> Format = context =>
{
if (context.InterfaceMethod == null)
{
@ -34,6 +34,6 @@ namespace Squidex.Infrastructure.Commands
}
return context.InterfaceMethod.Name;
}
};
}
}

2
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -31,7 +31,7 @@
<PackageReference Include="NJsonSchema" Version="10.5.2" />
<PackageReference Include="OpenTelemetry.Api" Version="1.1.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets" Version="1.10.0" />
<PackageReference Include="Squidex.Assets" Version="2.0.0" />
<PackageReference Include="Squidex.Caching" Version="1.8.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="2.3.0" />
<PackageReference Include="Squidex.Log" Version="1.6.0" />

13
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;
}

2
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

8
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; }
/// <summary>
/// True to not use JPEG encoding when quality is set and the image is not a JPEG. Default: false.
/// </summary>
[FromQuery(Name = "keepformat")]
public bool KeepFormat { get; set; }
/// <summary>
/// True to force a new resize even if it already stored.
/// </summary>
@ -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;
}

19
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<IFormFile> file, string id)
private async Task UpdatePictureAsync(List<IFormFile> 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;
}

10
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<ImageSharpAssetThumbnailGenerator>()
var thumbnailGenerator = new CompositeThumbnailGenerator(
new IAssetThumbnailGenerator[]
{
new ImageSharpThumbnailGenerator(),
new ImageMagickThumbnailGenerator()
});
services.AddSingletonAs(c => thumbnailGenerator)
.As<IAssetThumbnailGenerator>();
services.AddSingletonAs(c => new DelegateInitializer(

15
backend/src/Squidex/Config/Domain/InfrastructureServices.cs

@ -51,6 +51,19 @@ namespace Squidex.Config.Domain
services.AddAsyncLocalCache();
services.AddBackgroundCache();
var timeoutExecution = config.GetValue<TimeSpan>("scripting:timeoutExecution");
var timeoutScript = config.GetValue<TimeSpan>("scripting:timeoutScript");
services.AddSingletonAs(c =>
new JintScriptEngine(
c.GetRequiredService<IMemoryCache>(),
c.GetRequiredService<IEnumerable<IJintExtension>>())
{
TimeoutExecution = timeoutExecution,
TimeoutScript = timeoutScript
}).As<IScriptEngine>();
services.AddSingletonAs(_ => SystemClock.Instance)
.As<IClock>();
@ -96,7 +109,7 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<UserFluidExtension>()
.As<IFluidExtension>();
services.AddSingleton<Func<IIncomingGrainCallContext, string>>(DomainObjectGrainFormatter.Format);
services.AddSingleton(DomainObjectGrainFormatter.Format);
}
public static void AddSquidexUsageTracking(this IServiceCollection services, IConfiguration config)

10
backend/src/Squidex/Squidex.csproj

@ -70,11 +70,11 @@
<PackageReference Include="OrleansDashboard.EmbeddedAssets" Version="3.6.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="5.0.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets.Azure" Version="1.10.0" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="1.10.0" />
<PackageReference Include="Squidex.Assets.FTP" Version="1.10.0" />
<PackageReference Include="Squidex.Assets.Mongo" Version="1.10.0" />
<PackageReference Include="Squidex.Assets.S3" Version="1.10.0" />
<PackageReference Include="Squidex.Assets.Azure" Version="2.0.0" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="2.0.0" />
<PackageReference Include="Squidex.Assets.FTP" Version="2.0.0" />
<PackageReference Include="Squidex.Assets.Mongo" Version="2.0.0" />
<PackageReference Include="Squidex.Assets.S3" Version="2.0.0" />
<PackageReference Include="Squidex.Caching.Orleans" Version="1.8.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="7.5.0" />
<PackageReference Include="Squidex.Hosting" Version="2.3.0" />

8
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": ""

53
backend/src/Squidex/wwwroot/scripts/editor-plain.html

@ -0,0 +1,53 @@
<html>
<head>
<meta charset="utf-8">
<!-- Load the editor sdk from the local folder or https://cloud.squidex.io/scripts/editor-sdk.js -->
<script src="editor-sdk.js"></script>
<style>
.editor {
border: 1px solid #eee;
border-radius: 4px;
height: 500px;
width: 100%;
}
</style>
</head>
<body style="margin: 0px; padding: 0px;">
<textarea name="content" class="editor" id="editor"></textarea>
<script>
var element = document.getElementById('editor');
// When the field is instantiated it notifies the UI that it has been loaded.
//
// Furthermore it sends the current size to the parent.
var field = new SquidexFormField();
field.onValueChanged(function (value) {
if (value) {
element.value = JSON.stringify(value);
} else {
element.value = '';
}
});
field.onDisabled(function (disabled) {
element.disabled = disabled;
});
element.addEventListener('change', function () {
var value = element.value;
if (value) {
field.valueChanged(JSON.parse(value));
} else {
field.valueChanged(undefined);
}
});
</script>
</body>
</html>

6
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<Stream>._))
.Returns(new ImageInfo(100, 100, false));
A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(A<Stream>._, 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<Stream>._))
A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(A<Stream>._, file.MimeType, default))
.Returns(Task.FromResult<ImageInfo?>(null));
await Assert.ThrowsAsync<ValidationException>(() => HandleAsync(sut, command));

18
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<Stream>._))
A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(A<Stream>._, 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<Stream>._))
A.CallTo(() => assetThumbnailGenerator.FixOrientationAsync(stream, file.MimeType, A<Stream>._, 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<Stream>._))
.Returns(new ImageInfo(800, 600, true));
A.CallTo(() => assetThumbnailGenerator.FixOrientationAsync(stream, file.MimeType, A<Stream>._, 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<Stream>._))
A.CallTo(() => assetThumbnailGenerator.FixOrientationAsync(stream, file.MimeType, A<Stream>._, default))
.MustHaveHappened();
}

2
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

110
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<long> 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;
}
}
}
}
}

BIN
backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleGIFImage_40kbmb.gif

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

BIN
backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_100kb.webp

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

BIN
backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_150kb.gif

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

BIN
backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_400kb.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

BIN
backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_400kb.tiff

Binary file not shown.

BIN
backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_600kb.tga

Binary file not shown.

BIN
backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_62kb.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

BIN
backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleImage_700kb.bmp

Binary file not shown.

After

Width:  |  Height:  |  Size: 703 KiB

BIN
backend/tools/TestSuite/TestSuite.ApiTests/Assets/SampleJPGImage_50kbmb.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

BIN
backend/tools/TestSuite/TestSuite.ApiTests/Assets/SamplePNGImage_100kbmb.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

29
backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj

@ -5,6 +5,14 @@
<LangVersion>10.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<None Remove="Assets\SampleImage_BMP_3MB - Copy.bmp" />
<None Remove="Assets\SampleImage_GIF_500kb - Copy.gif" />
<None Remove="Assets\SampleImage_JPG_200kb - Copy.jpg" />
<None Remove="Assets\SampleImage_PNG_1mb - Copy.png" />
<None Remove="Assets\SampleImage_TGA_2mb - Copy.tga" />
<None Remove="Assets\SampleImage_WEBP_350kb - Copy.webp" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" Version="1.0.676">
<PrivateAssets>all</PrivateAssets>
@ -40,6 +48,27 @@
<None Update="Assets\SampleGIFImage_40kbmb.gif">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Assets\SampleImage_100kb.webp">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Assets\SampleImage_150kb.gif">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Assets\SampleImage_400kb.tiff">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Assets\SampleImage_400kb.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Assets\SampleImage_600kb.tga">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Assets\SampleImage_62kb.jpg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Assets\SampleImage_700kb.bmp">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Assets\SampleJPGImage_50kbmb.jpg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>

6
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);

10
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<ReadonlyArray<ObjectForm>>) {
public sort(event: CdkDragDrop<ReadonlyArray<ObjectFormBase>>) {
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();

2
frontend/app/features/content/shared/forms/array-item.component.html

@ -43,7 +43,7 @@
</div>
<div class="card-body" [class.hidden]="snapshot.isCollapsed">
<div class="form-group" *ngFor="let section of formModel.sections">
<div class="form-group" *ngFor="let section of formModel.fieldSectionsChanges | async">
<sqx-component-section
[canUnset]="canUnset"
[form]="form"

4
frontend/app/features/content/shared/forms/array-item.component.ts

@ -6,7 +6,7 @@
*/
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnInit, Output, QueryList, SimpleChanges, ViewChildren } from '@angular/core';
import { AppLanguageDto, ComponentForm, EditContentForm, FieldDto, FieldFormatter, FieldSection, invalid$, ObjectForm, RootFieldDto, StatefulComponent, Types, valueProjection$ } from '@app/shared';
import { AppLanguageDto, ComponentForm, EditContentForm, FieldDto, FieldFormatter, FieldSection, invalid$, ObjectFormBase, RootFieldDto, StatefulComponent, Types, valueProjection$ } from '@app/shared';
import { Observable } from 'rxjs';
import { ComponentSectionComponent } from './component-section.component';
@ -38,7 +38,7 @@ export class ArrayItemComponent extends StatefulComponent<State> implements OnCh
public formContext: any;
@Input()
public formModel: ObjectForm;
public formModel: ObjectFormBase;
@Input()
public canUnset?: boolean | null;

6
frontend/app/features/content/shared/forms/component.component.html

@ -1,10 +1,10 @@
<div class="component">
<div *ngIf="formModel.schema; else noSchema">
<div *ngIf="formModel.schemaChanges | async; let schema; else noSchema">
<sqx-form-hint>
{{formModel.schema.displayName}}
{{schema.displayName}}
</sqx-form-hint>
<div class="form-group" *ngFor="let section of formModel.sections">
<div class="form-group" *ngFor="let section of formModel.fieldSectionsChanges | async">
<sqx-component-section
[canUnset]="canUnset"
[form]="form"

6
frontend/app/features/dashboard/pages/cards/api-calls-card.component.ts

@ -39,13 +39,13 @@ export class ApiCallsCardComponent 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.totalCalls),
data: value.map(x => x.totalCalls),
})),
};
}

6
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),
})),
};

6
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),
})),
};

2
frontend/app/features/schemas/pages/schema/fields/forms/field-form.component.html

@ -10,7 +10,7 @@
{{ 'schemas.field.tabValidation' | sqxTranslate }}
</a>
</li>
<li class="nav-item" [class.hidden]="!field.properties.isContentField || field.properties.fieldType === 'Array'">
<li class="nav-item" [class.hidden]="!field.properties.isContentField">
<a class="nav-link" (click)="selectTab(2)" [class.active]="selectedTab === 2">
{{ 'schemas.field.tabEditing' | sqxTranslate }}
</a>

4
frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.html

@ -27,7 +27,7 @@
<div class="col-auto">
<button type="button" class="btn btn-text-danger" [disabled]="!isEditable"
(sqxConfirmClick)="editForm.remove(i)"
(sqxConfirmClick)="editForm.form.removeAt(i)"
confirmTitle="i18n:schemas.deleteUrlConfirmTitle"
confirmText="i18n:schemas.deleteUrlConfirmText"
confirmRememberKey="removePreviewUrl">
@ -46,7 +46,7 @@
</div>
<div class="col-auto col-options">
<button type="button" class="btn btn-success" (click)="add()">
<button type="button" class="btn btn-success" (click)="editForm.form.add()">
<i class="icon-add"></i>
</button>
</div>

4
frontend/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.ts

@ -35,10 +35,6 @@ export class SchemaPreviewUrlsFormComponent implements OnChanges {
this.editForm.setEnabled(this.isEditable);
}
public add() {
this.editForm.add();
}
public saveSchema() {
if (!this.isEditable) {
return;

2
frontend/app/features/settings/pages/roles/role.component.html

@ -59,7 +59,7 @@
<sqx-autocomplete [formControl]="control" [source]="allPermissions"></sqx-autocomplete>
</div>
<div class="col-auto" *ngIf="isEditable">
<button type="button" class="btn btn-text-danger" (click)="removePermission(i)">
<button type="button" class="btn btn-text-danger" (click)="editForm.form.removeAt(i)">
<i class="icon-bin2"></i>
</button>
</div>

6
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();

8
frontend/app/features/settings/pages/settings/settings-page.component.html

@ -46,7 +46,7 @@
<div class="col-auto">
<button type="button" class="btn btn-text-danger" [disabled]="!isEditable"
(sqxConfirmClick)="editForm.removePattern(i)"
(sqxConfirmClick)="editForm.patterns.removeAt(i)"
confirmTitle="i18n:appSettings.patterns.deleteConfirmTitle"
confirmText="i18n:appSettings.patterns.deleteConfirmText"
confirmRememberKey="deletePattern">
@ -69,7 +69,7 @@
</div>
<div class="col-auto">
<button type="button" class="btn btn-success" (click)="editForm.addPattern()">
<button type="button" class="btn btn-success" (click)="editForm.patterns.add()">
<i class="icon-add"></i>
</button>
</div>
@ -104,7 +104,7 @@
<div class="col-auto">
<button type="button" class="btn btn-text-danger" [disabled]="!isEditable"
(sqxConfirmClick)="editForm.removeEditor(i)"
(sqxConfirmClick)="editForm.editors.removeAt(i)"
confirmTitle="i18n:appSettings.editors.deleteConfirmTitle"
confirmText="i18n:appSettings.editors.deleteConfirmText"
confirmRememberKey="deleteEditor">
@ -123,7 +123,7 @@
</div>
<div class="col-auto">
<button type="button" class="btn btn-success" (click)="editForm.addEditor()">
<button type="button" class="btn btn-success" (click)="editForm.editors.add()">
<i class="icon-add"></i>
</button>
</div>

153
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([]);
});
});

86
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();
}
}
}

64
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([]);
});
});
});

50
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();
}
}
}

14
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 });

16
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 });

1
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';

2
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;

3
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);

4
frontend/app/shared/components/assets/asset-dialog.component.html

@ -151,7 +151,7 @@
<div class="col-auto col-options">
<button type="button" class="btn btn-text-danger" [disabled]="!isEditable"
(sqxConfirmClick)="annotateForm.removeMetadata(i)"
(sqxConfirmClick)="annotateForm.metadata.removeAt(i)"
confirmTitle="i18n:assets.deleteMetadataConfirmTitle"
confirmText="i18n:assets.deleteMetadataConfirmText"
confirmRememberKey="removeAssetMetadata">
@ -161,7 +161,7 @@
</div>
<div class="form-group">
<button type="button" class="btn btn-success" (click)="annotateForm.addMetadata()" [disabled]="!isEditable">
<button type="button" class="btn btn-success" (click)="annotateForm.metadata.add()" [disabled]="!isEditable">
{{ 'assets.metadataAdd' | sqxTranslate }}
</button>
</div>

1
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;

4
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;
}

4
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,

108
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<FormGroup, CreateAppDto> {
@ -39,95 +39,67 @@ export class UpdateAppForm extends Form<FormGroup, UpdateAppDto, AppDto> {
}
export class EditAppSettingsForm extends Form<FormGroup, UpdateAppSettingsDto, AppSettingsDto> {
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<FormGroup> {
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<FormGroup> {
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,
],
],
});
}
}

54
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<FormGroup, AnnotateAssetDto, AssetDto> {
public get metadata() {
return this.form.get('metadata')! as FormArray;
return this.form.get('metadata')! as TemplatedFormArray;
}
public get metadataControls(): ReadonlyArray<FormGroup> {
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<FormGroup, AnnotateAssetDto, AssetDt
Validators.nullValidator,
],
],
metadata: formBuilder.array([]),
metadata: new TemplatedFormArray(new MetadataTemplate(formBuilder)),
}));
}
public addMetadata() {
this.metadata.push(
this.formBuilder.group({
name: ['',
[
Validators.required,
],
],
value: [''],
}));
}
public removeMetadata(index: number) {
this.metadata.removeAt(index);
}
public transformSubmit(value: any) {
const result = { ...value, metadata: {} };
@ -143,16 +126,6 @@ export class AnnotateAssetForm extends Form<FormGroup, AnnotateAssetDto, AssetDt
}
if (Types.isObject(value.metadata)) {
const length = Object.keys(value.metadata).length;
while (this.metadata.controls.length < length) {
this.addMetadata();
}
while (this.metadata.controls.length > 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<FormGroup, AnnotateAssetDto, AssetDt
}
}
class MetadataTemplate {
constructor(private readonly formBuilder: FormBuilder) {}
public createControl() {
return this.formBuilder.group({
name: ['',
[
Validators.required,
],
],
value: [''],
});
}
}
export class EditAssetScriptsForm extends Form<FormGroup, {}, object> {
constructor(formBuilder: FormBuilder) {
super(formBuilder.group({

157
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<CompiledRule>;
}
export interface RulesProvider {
compileRules(schema: SchemaDto): ReadonlyArray<CompiledRule>;
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<CompiledRule> = [];
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<CompiledRule> } = {};
private readonly rules: ReadonlyArray<CompiledRule>;
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 };
}
}

139
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<boolean>(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<CompiledRule>;
setSchema(schema?: SchemaDto): void;
getRules(form: AbstractContentForm<any, any>): ReadonlyArray<CompiledRule>;
}
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<CompiledRule> {
return this.parent.compileRules(schema);
}
public getRules(form: AbstractContentForm<any, any>) {
return Types.fastMerge(this.parent.getRules(form), this.getRelativeRules(form));
}
private getRelativeRules(form: AbstractContentForm<any, any>) {
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<CompiledRule> } = {};
private readonly rules: ReadonlyArray<CompiledRule>;
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<any, any>) {
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<T extends FieldDto, TForm extends AbstractControl> extends Hidden {
private readonly disabled$ = new BehaviorSubject<boolean>(false);
private readonly currentRules: ReadonlyArray<CompiledRule>;
private readonly ruleSet: CompiledRules;
public get disabled() {
return this.disabled$.value;
@ -263,7 +142,7 @@ export abstract class AbstractContentForm<T extends FieldDto, TForm extends Abst
) {
super();
this.currentRules = rules.getRules(this);
this.ruleSet = rules.getRules(this);
}
public path(relative: string) {
@ -277,7 +156,7 @@ export abstract class AbstractContentForm<T extends FieldDto, TForm extends Abst
isRequired: this.field.properties.isRequired && !this.isOptional,
};
for (const rule of this.currentRules) {
for (const rule of this.ruleSet.rules) {
if (rule.eval(context, itemData)) {
if (rule.action === 'Disable') {
state.isDisabled = true;
@ -307,8 +186,6 @@ export abstract class AbstractContentForm<T extends FieldDto, TForm extends Abst
}
public setValue(value: any) {
this.prepareLoad(value);
this.form.reset(value);
}
@ -319,10 +196,6 @@ export abstract class AbstractContentForm<T extends FieldDto, TForm extends Abst
protected updateCustomState(_context: RuleContext, _fieldData: any, _itemData: any, _state: AbstractContentFormState): void {
return;
}
public prepareLoad(_data: any): void {
return;
}
}
const SELF = { onlySelf: true };

3
frontend/app/shared/state/contents.forms.spec.ts

@ -462,6 +462,7 @@ describe('ContentForm', () => {
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([]);

424
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<FormGroup, any> {
}
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<RootFieldDto, FormGroup> {
}
}
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<RootFieldDto, FormGroup> {
}
}
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<RootFieldDto, FormGroup> {
export class FieldValueForm extends AbstractContentForm<FieldDto, FormControl> {
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<FieldDto, FormControl> {
}
}
export class FieldArrayForm extends AbstractContentForm<FieldDto, UndefinableFormArray> {
private readonly item$ = new BehaviorSubject<ReadonlyArray<ObjectForm>>([]);
export class FieldArrayForm extends AbstractContentForm<FieldDto, TemplatedFormArray> {
private readonly item$ = new BehaviorSubject<ReadonlyArray<ObjectFormBase>>([]);
public get itemChanges(): Observable<ReadonlyArray<ObjectForm>> {
public get itemChanges(): Observable<ReadonlyArray<ObjectFormBase>> {
return this.item$;
}
@ -341,83 +326,42 @@ export class FieldArrayForm extends AbstractContentForm<FieldDto, UndefinableFor
return this.item$.value;
}
public set items(value: ReadonlyArray<ObjectForm>) {
public set items(value: ReadonlyArray<ObjectFormBase>) {
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<FieldDto, UndefinableFor
this.sort(children);
}
public sort(children: ReadonlyArray<ObjectForm>) {
public sort(children: ReadonlyArray<ObjectFormBase>) {
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<TField extends FieldDto = FieldDto> extends AbstractContentForm<TField, TemplatedFormGroup> {
private readonly fieldSections$ = new BehaviorSubject<ReadonlyArray<FieldSection<FieldDto, FieldItemForm>>>([]);
private readonly fields$ = new BehaviorSubject<FieldMap>({});
public get fieldSectionsChanges(): Observable<ReadonlyArray<FieldSection<FieldDto, FieldItemForm>>> {
return this.fieldSections$;
}
}
export type FieldItemForm = ComponentForm | FieldValueForm | FieldArrayForm;
public get fieldSections() {
return this.fieldSections$.value;
}
export class ObjectForm<TField extends FieldDto = FieldDto> extends AbstractContentForm<TField, UndefinableFormGroup> {
private fields: { [key: string]: FieldItemForm } = {};
private fieldSections: FieldSection<FieldDto, FieldItemForm>[] = [];
public set fieldSections(value: ReadonlyArray<FieldSection<FieldDto, FieldItemForm>>) {
this.fieldSections$.next(value);
}
public get sections() {
return this.fieldSections;
public get fieldsChanges(): Observable<FieldMap> {
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<TField extends FieldDto = FieldDto> extends AbstractCont
return this.fields[field['name'] || field];
}
protected init(schema?: ReadonlyArray<FieldDto>) {
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<T extends ObjectFormBase = ObjectFormBase> implements FormGroupTemplate {
private currentSchema: ReadonlyArray<FieldDto> | 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<FieldDto> | 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<FieldDto, FieldItemForm>(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<FieldDto>, value: any, model: T, form: FormGroup) {
const fieldMap: FieldMap = {};
const fieldSections: FieldSection<FieldDto, FieldItemForm>[] = [];
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<FieldDto, FieldItemForm>(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<RootFieldDto> {
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<RootFieldDto> {
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<ArrayItemForm> {
public getSchema() {
return this.model.field.nested;
}
}
public readonly properties: ComponentFieldPropertiesDto;
export class ComponentForm extends ObjectFormBase {
private readonly schema$ = new BehaviorSubject<SchemaDto | undefined>(undefined);
public get schema() {
return this.globals.schemas[this.schemaId!];
public get schemaChanges(): Observable<SchemaDto | undefined> {
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<ComponentForm> {
public getSchema(value: any, model: ComponentForm) {
return model.globals.schemas[value?.schemaId].fields;
}
public unset() {
this.selectSchema(undefined);
protected setControlsCore(schema: ReadonlyArray<FieldDto>, 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);
}
}

2
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));
}

32
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<FormArray, UpdateRoleDto, RoleDto> {
export class EditRoleForm extends Form<TemplatedFormArray, UpdateRoleDto, RoleDto> {
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<FormArray, UpdateRoleDto, RoleDto> {
}
public transformLoad(value: Partial<UpdateRoleDto>) {
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);
}
}

128
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<FormGroup, SynchronizeSchemaDto>
}
}
export class ConfigureFieldRulesForm extends Form<FormArray, ReadonlyArray<FieldRule>, SchemaDto> {
export class ConfigureFieldRulesForm extends Form<TemplatedFormArray, ReadonlyArray<FieldRule>, SchemaDto> {
public get rulesControls(): ReadonlyArray<FormGroup> {
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<string>) {
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<FormArray, ReadonlyArray<Field
}
public transformLoad(value: Partial<SchemaDto>) {
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<string>) {
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<FormArray, ConfigurePreviewUrlsFormType, SchemaDto> {
export class ConfigurePreviewUrlsForm extends Form<TemplatedFormArray, ConfigurePreviewUrlsFormType, SchemaDto> {
public get previewControls(): ReadonlyArray<FormGroup> {
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<SchemaDto>) {
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<FormArray, ConfigurePreviewUr
}
}
class PreviewUrlTemplate {
constructor(private readonly formBuilder: FormBuilder) {}
public createControl() {
return this.formBuilder.group({
name: ['',
[
Validators.required,
],
],
url: ['',
[
Validators.required,
],
],
});
}
}
export class EditSchemaScriptsForm extends Form<FormGroup, {}, object> {
constructor(formBuilder: FormBuilder) {
super(formBuilder.group({

Loading…
Cancel
Save