Browse Source

Use auto generated code (#1213)

* Improve tests.

Fix

# Conflicts:
#	backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs
#	backend/src/Squidex.Data.MongoDb/ServiceExtensions.cs

* Fix all tests

* Add missing files.

* More stuff.

* Fix workflows stuff.

* Fix state.

* Temp

* Build fix.

* Fixes
pull/1214/head
Sebastian Stehle 1 year ago
committed by GitHub
parent
commit
64f411a89b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 30
      backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs
  2. 3
      backend/src/Squidex/Areas/Api/Config/OpenApi/ScopesProcessor.cs
  3. 1
      backend/src/Squidex/Areas/Api/Config/OpenApi/TagByGroupNameProcessor.cs
  4. 31
      backend/src/Squidex/Areas/Api/Config/OpenApi/TagXmlProcessor.cs
  5. 6
      backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/OperationBuilder.cs
  6. 18
      backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs
  7. 2
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateContentsDto.cs
  8. 2
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/EnrichContentDefaultsDto.cs
  9. 27
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/DynamicCreateRuleDto.cs
  10. 81
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/DynamicRuleDto.cs
  11. 24
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/DynamicRulesDto.cs
  12. 34
      backend/src/Squidex/Areas/Api/Controllers/Rules/Models/DynamicUpdateRuleDto.cs
  13. 6
      backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsageDto.cs
  14. 8
      backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs
  15. 1
      backend/src/Squidex/Startup.cs
  16. 22
      frontend/generator/Generator.sln
  17. 30
      frontend/generator/Generator/Generator.csproj
  18. 165
      frontend/generator/Generator/Program.cs
  19. 205
      frontend/generator/Generator/Templates/Class.liquid
  20. 65
      frontend/generator/Generator/Templates/ConvertToClass.liquid
  21. 45
      frontend/generator/Generator/Templates/ConvertToJavaScript.liquid
  22. 10
      frontend/generator/Generator/Templates/Enum.StringLiteral.liquid
  23. 4
      frontend/src/app/features/administration/pages/event-consumers/event-consumer.component.ts
  24. 4
      frontend/src/app/features/administration/pages/event-consumers/event-consumers-page.component.ts
  25. 25
      frontend/src/app/features/administration/pages/restore/restore-page.component.ts
  26. 51
      frontend/src/app/features/administration/pages/users/user-page.component.ts
  27. 60
      frontend/src/app/features/administration/services/event-consumers.service.spec.ts
  28. 61
      frontend/src/app/features/administration/services/event-consumers.service.ts
  29. 100
      frontend/src/app/features/administration/services/users.service.spec.ts
  30. 99
      frontend/src/app/features/administration/services/users.service.ts
  31. 8
      frontend/src/app/features/administration/state/event-consumers.state.spec.ts
  32. 4
      frontend/src/app/features/administration/state/event-consumers.state.ts
  33. 7
      frontend/src/app/features/administration/state/users.forms.ts
  34. 32
      frontend/src/app/features/administration/state/users.state.spec.ts
  35. 10
      frontend/src/app/features/administration/state/users.state.ts
  36. 1
      frontend/src/app/features/assets/pages/asset-tag-dialog.component.ts
  37. 4
      frontend/src/app/features/content/pages/calendar/calendar-page.component.ts
  38. 6
      frontend/src/app/features/content/pages/content/content-event.component.ts
  39. 5
      frontend/src/app/features/content/pages/content/content-page.component.ts
  40. 4
      frontend/src/app/features/content/pages/contents/contents-page.component.ts
  41. 12
      frontend/src/app/features/content/shared/due-time-selector.component.ts
  42. 2
      frontend/src/app/features/content/shared/forms/array-editor.component.ts
  43. 6
      frontend/src/app/features/content/shared/forms/array-item.component.ts
  44. 8
      frontend/src/app/features/content/shared/forms/content-field.component.ts
  45. 4
      frontend/src/app/features/content/shared/forms/content-section.component.ts
  46. 21
      frontend/src/app/features/content/shared/forms/field-editor.component.ts
  47. 27
      frontend/src/app/features/content/shared/list/content.component.ts
  48. 36
      frontend/src/app/features/content/shared/references/content-creator.component.ts
  49. 6
      frontend/src/app/features/content/shared/references/reference-dropdown.component.ts
  50. 6
      frontend/src/app/features/content/shared/references/reference-item.component.ts
  51. 4
      frontend/src/app/features/content/shared/references/references-checkboxes.component.ts
  52. 4
      frontend/src/app/features/content/shared/references/references-radio-buttons.component.ts
  53. 6
      frontend/src/app/features/content/shared/references/references-tag-converter.ts
  54. 6
      frontend/src/app/features/content/shared/references/references-tags.component.ts
  55. 4
      frontend/src/app/features/rules/pages/rule/rule-page.component.html
  56. 24
      frontend/src/app/features/rules/pages/rule/rule-page.component.ts
  57. 6
      frontend/src/app/features/rules/pages/rules/rule.component.html
  58. 14
      frontend/src/app/features/rules/pages/rules/rule.component.ts
  59. 8
      frontend/src/app/features/rules/pages/rules/rules-page.component.ts
  60. 2
      frontend/src/app/features/rules/shared/actions/generic-action.component.html
  61. 23
      frontend/src/app/features/schemas/pages/schema/common/schema-edit-form.component.ts
  62. 23
      frontend/src/app/features/schemas/pages/schema/export/schema-export-form.component.ts
  63. 8
      frontend/src/app/features/schemas/pages/schema/fields/field-group.component.ts
  64. 2
      frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html
  65. 21
      frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.ts
  66. 35
      frontend/src/app/features/schemas/pages/schema/fields/field.component.ts
  67. 4
      frontend/src/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.ts
  68. 4
      frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.ts
  69. 6
      frontend/src/app/features/schemas/pages/schema/fields/sortable-field-list.component.ts
  70. 4
      frontend/src/app/features/schemas/pages/schema/fields/types/assets-validation.component.ts
  71. 4
      frontend/src/app/features/schemas/pages/schema/fields/types/boolean-ui.component.ts
  72. 4
      frontend/src/app/features/schemas/pages/schema/fields/types/boolean-validation.component.ts
  73. 4
      frontend/src/app/features/schemas/pages/schema/fields/types/date-time-ui.component.ts
  74. 4
      frontend/src/app/features/schemas/pages/schema/fields/types/date-time-validation.component.ts
  75. 4
      frontend/src/app/features/schemas/pages/schema/fields/types/number-ui.component.ts
  76. 6
      frontend/src/app/features/schemas/pages/schema/fields/types/number-validation.component.ts
  77. 4
      frontend/src/app/features/schemas/pages/schema/fields/types/references-ui.component.ts
  78. 4
      frontend/src/app/features/schemas/pages/schema/fields/types/references-validation.component.ts
  79. 4
      frontend/src/app/features/schemas/pages/schema/fields/types/string-ui.component.ts
  80. 8
      frontend/src/app/features/schemas/pages/schema/fields/types/string-validation.component.ts
  81. 4
      frontend/src/app/features/schemas/pages/schema/fields/types/tags-ui.component.ts
  82. 4
      frontend/src/app/features/schemas/pages/schema/fields/types/tags-validation.component.ts
  83. 25
      frontend/src/app/features/schemas/pages/schema/indexes/index-form.component.ts
  84. 23
      frontend/src/app/features/schemas/pages/schema/preview/schema-preview-urls-form.component.ts
  85. 25
      frontend/src/app/features/schemas/pages/schema/rules/schema-field-rules-form.component.ts
  86. 23
      frontend/src/app/features/schemas/pages/schema/scripts/schema-scripts-form.component.ts
  87. 2
      frontend/src/app/features/schemas/pages/schema/ui/field-list.component.ts
  88. 14
      frontend/src/app/features/schemas/pages/schema/ui/schema-ui-form.component.ts
  89. 23
      frontend/src/app/features/schemas/pages/schemas/schema-form.component.ts
  90. 13
      frontend/src/app/features/schemas/pages/schemas/schemas-page.component.ts
  91. 23
      frontend/src/app/features/settings/pages/asset-scripts/asset-scripts-page.component.ts
  92. 23
      frontend/src/app/features/settings/pages/clients/client-add-form.component.ts
  93. 2
      frontend/src/app/features/settings/pages/clients/client.component.html
  94. 20
      frontend/src/app/features/settings/pages/clients/client.component.ts
  95. 33
      frontend/src/app/features/settings/pages/contributors/contributor-add-form.component.ts
  96. 6
      frontend/src/app/features/settings/pages/contributors/contributor.component.ts
  97. 4
      frontend/src/app/features/settings/pages/contributors/import-contributors-dialog.component.ts
  98. 23
      frontend/src/app/features/settings/pages/languages/language-add-form.component.ts
  99. 31
      frontend/src/app/features/settings/pages/languages/language.component.ts
  100. 54
      frontend/src/app/features/settings/pages/more/more-page.component.ts

30
backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs

@ -12,6 +12,8 @@ using NJsonSchema.Generation.TypeMappers;
using NodaTime; using NodaTime;
using NSwag.Generation; using NSwag.Generation;
using NSwag.Generation.Processors; using NSwag.Generation.Processors;
using NSwag.Generation.Processors.Contexts;
using Squidex.Areas.Api.Controllers.Rules.Models;
using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
@ -88,6 +90,9 @@ public static class OpenApiServices
services.AddOpenApiDocument((settings, services) => services.AddOpenApiDocument((settings, services) =>
{ {
ConfigureSchemaSettings(settings.SchemaSettings, services.GetRequiredService<TypeRegistry>(), false); ConfigureSchemaSettings(settings.SchemaSettings, services.GetRequiredService<TypeRegistry>(), false);
settings.DocumentProcessors.Add(new AddAdditionalTypeProcessor<DynamicCreateRuleDto>());
settings.DocumentProcessors.Add(new AddAdditionalTypeProcessor<DynamicRulesDto>());
settings.DocumentProcessors.Add(new AddAdditionalTypeProcessor<DynamicUpdateRuleDto>());
}); });
} }
@ -103,8 +108,8 @@ public static class OpenApiServices
settings.SchemaProcessors.Add(new RequiredSchemaProcessor()); settings.SchemaProcessors.Add(new RequiredSchemaProcessor());
settings.SchemaType = NJsonSchema.SchemaType.OpenApi3; settings.SchemaType = NJsonSchema.SchemaType.OpenApi3;
settings.TypeMappers = new List<ITypeMapper> settings.TypeMappers =
{ [
CreateAnyMap<FilterNode<JsonValue>>(), CreateAnyMap<FilterNode<JsonValue>>(),
CreateAnyMap<JsonDocument>(), CreateAnyMap<JsonDocument>(),
CreateAnyMap<JsonValue>(), CreateAnyMap<JsonValue>(),
@ -123,15 +128,14 @@ public static class OpenApiServices
CreateStringMap<PropertyPath>(), CreateStringMap<PropertyPath>(),
CreateStringMap<RefToken>(), CreateStringMap<RefToken>(),
CreateStringMap<Status>(), CreateStringMap<Status>(),
}; ];
} }
private static ITypeMapper CreateObjectMap<T>() private static PrimitiveTypeMapper CreateObjectMap<T>()
{ {
return new PrimitiveTypeMapper(typeof(T), schema => return new PrimitiveTypeMapper(typeof(T), schema =>
{ {
schema.Type = JsonObjectType.Object; schema.Type = JsonObjectType.Object;
schema.AdditionalPropertiesSchema = new JsonSchema schema.AdditionalPropertiesSchema = new JsonSchema
{ {
Description = "Any", Description = "Any",
@ -139,12 +143,11 @@ public static class OpenApiServices
}); });
} }
private static ITypeMapper CreateArrayMap<T>(JsonObjectType itemType) private static PrimitiveTypeMapper CreateArrayMap<T>(JsonObjectType itemType)
{ {
return new PrimitiveTypeMapper(typeof(T), schema => return new PrimitiveTypeMapper(typeof(T), schema =>
{ {
schema.Type = JsonObjectType.Array; schema.Type = JsonObjectType.Array;
schema.Item = new JsonSchema schema.Item = new JsonSchema
{ {
Type = itemType, Type = itemType,
@ -152,21 +155,28 @@ public static class OpenApiServices
}); });
} }
private static ITypeMapper CreateStringMap<T>(string? format = null) private static PrimitiveTypeMapper CreateStringMap<T>(string? format = null)
{ {
return new PrimitiveTypeMapper(typeof(T), schema => return new PrimitiveTypeMapper(typeof(T), schema =>
{ {
schema.Type = JsonObjectType.String; schema.Type = JsonObjectType.String;
schema.Format = format; schema.Format = format;
}); });
} }
private static ITypeMapper CreateAnyMap<T>() private static PrimitiveTypeMapper CreateAnyMap<T>()
{ {
return new PrimitiveTypeMapper(typeof(T), schema => return new PrimitiveTypeMapper(typeof(T), schema =>
{ {
schema.Type = JsonObjectType.None; schema.Type = JsonObjectType.None;
}); });
} }
public sealed class AddAdditionalTypeProcessor<T> : IDocumentProcessor where T : class
{
public void Process(DocumentProcessorContext context)
{
context.SchemaResolver.AppendSchema(context.SchemaGenerator.Generate(typeof(T), context.SchemaResolver), null);
}
}
} }

3
backend/src/Squidex/Areas/Api/Config/OpenApi/ScopesProcessor.cs

@ -18,10 +18,9 @@ public sealed class ScopesProcessor : IOperationProcessor
{ {
public bool Process(OperationProcessorContext context) public bool Process(OperationProcessorContext context)
{ {
context.OperationDescription.Operation.Security ??= new List<OpenApiSecurityRequirement>(); context.OperationDescription.Operation.Security ??= [];
var permissionAttribute = context.MethodInfo.GetCustomAttribute<ApiPermissionAttribute>(); var permissionAttribute = context.MethodInfo.GetCustomAttribute<ApiPermissionAttribute>();
if (permissionAttribute != null) if (permissionAttribute != null)
{ {
context.OperationDescription.Operation.Security.Add(new OpenApiSecurityRequirement context.OperationDescription.Operation.Security.Add(new OpenApiSecurityRequirement

1
backend/src/Squidex/Areas/Api/Config/OpenApi/TagByGroupNameProcessor.cs

@ -21,7 +21,6 @@ public sealed class TagByGroupNameProcessor : IOperationProcessor
if (!string.IsNullOrWhiteSpace(groupName)) if (!string.IsNullOrWhiteSpace(groupName))
{ {
context.OperationDescription.Operation.Tags = [groupName]; context.OperationDescription.Operation.Tags = [groupName];
return true; return true;
} }
else else

31
backend/src/Squidex/Areas/Api/Config/OpenApi/TagXmlProcessor.cs

@ -22,25 +22,28 @@ public sealed class TagXmlProcessor : IDocumentProcessor
foreach (var controllerType in context.ControllerTypes) foreach (var controllerType in context.ControllerTypes)
{ {
var attribute = controllerType.GetCustomAttribute<ApiExplorerSettingsAttribute>(); var attribute = controllerType.GetCustomAttribute<ApiExplorerSettingsAttribute>();
if (attribute == null)
{
continue;
}
if (attribute != null) var tag = context.Document.Tags.FirstOrDefault(x => x.Name == attribute.GroupName);
if (tag == null)
{ {
var tag = context.Document.Tags.FirstOrDefault(x => x.Name == attribute.GroupName); continue;
}
if (tag != null) var description = controllerType.GetXmlDocsSummary();
{ if (description == null)
var description = controllerType.GetXmlDocsSummary(); {
continue;
}
if (description != null) tag.Description ??= string.Empty;
{
tag.Description ??= string.Empty;
if (!tag.Description.Contains(description, StringComparison.Ordinal)) if (!tag.Description.Contains(description, StringComparison.Ordinal))
{ {
tag.Description += "\n\n" + description; tag.Description += "\n\n" + description;
}
}
}
} }
} }
} }

6
backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/OperationBuilder.cs

@ -138,13 +138,13 @@ internal sealed class OperationBuilder(OperationsBuilder operations, OpenApiOper
{ {
var fullId = PermissionIds.ForApp(permissionId, operations.Parent.AppName, operations.SchemaName).Id; var fullId = PermissionIds.ForApp(permissionId, operations.Parent.AppName, operations.SchemaName).Id;
operation.Security = new List<OpenApiSecurityRequirement> operation.Security =
{ [
new OpenApiSecurityRequirement new OpenApiSecurityRequirement
{ {
[Constants.SecurityDefinition] = [fullId], [Constants.SecurityDefinition] = [fullId],
}, },
}; ];
return this; return this;
} }

18
backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs

@ -221,18 +221,18 @@ public sealed class SchemasOpenApiGenerator(
var document = new OpenApiDocument var document = new OpenApiDocument
{ {
Schemes = new List<OpenApiSchema> Schemes =
{ [
scheme, scheme,
}, ],
Consumes = new List<string> Consumes =
{ [
"application/json", "application/json",
}, ],
Produces = new List<string> Produces =
{ [
"application/json", "application/json",
}, ],
Info = new OpenApiInfo Info = new OpenApiInfo
{ {
Title = $"Squidex Content API for '{appName}' App", Title = $"Squidex Content API for '{appName}' App",

2
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateContentsDto.cs

@ -34,7 +34,7 @@ public sealed class BulkUpdateContentsDto
public bool DoNotScript { get; set; } = true; public bool DoNotScript { get; set; } = true;
/// <summary> /// <summary>
/// True, to also enrich required fields. Default: false. /// True, to also enrich required fields. Default: false.
/// </summary> /// </summary>
public bool EnrichRequiredFields { get; set; } public bool EnrichRequiredFields { get; set; }

2
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/EnrichContentDefaultsDto.cs

@ -15,7 +15,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models;
public class EnrichContentDefaultsDto public class EnrichContentDefaultsDto
{ {
/// <summary> /// <summary>
/// True, to also enrich required fields. Default: false. /// True, to also enrich required fields. Default: false.
/// </summary> /// </summary>
[FromQuery(Name = "enrichRequiredFields")] [FromQuery(Name = "enrichRequiredFields")]
public bool EnrichRequiredFields { get; set; } public bool EnrichRequiredFields { get; set; }

27
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/DynamicCreateRuleDto.cs

@ -0,0 +1,27 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Validation;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Rules.Models;
[OpenApiRequest]
public sealed class DynamicCreateRuleDto
{
/// <summary>
/// The trigger properties.
/// </summary>
[LocalizedRequired]
public RuleTriggerDto Trigger { get; set; }
/// <summary>
/// The action properties.
/// </summary>
[LocalizedRequired]
public Dictionary<string, object> Action { get; set; }
}

81
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/DynamicRuleDto.cs

@ -0,0 +1,81 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using NodaTime;
using Squidex.Infrastructure;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Rules.Models;
public sealed class DynamicRuleDto : Resource
{
/// <summary>
/// The ID of the rule.
/// </summary>
public DomainId Id { get; set; }
/// <summary>
/// The user that has created the rule.
/// </summary>
public RefToken CreatedBy { get; set; }
/// <summary>
/// The user that has updated the rule.
/// </summary>
public RefToken LastModifiedBy { get; set; }
/// <summary>
/// The date and time when the rule has been created.
/// </summary>
public Instant Created { get; set; }
/// <summary>
/// The date and time when the rule has been modified last.
/// </summary>
public Instant LastModified { get; set; }
/// <summary>
/// The version of the rule.
/// </summary>
public long Version { get; set; }
/// <summary>
/// Determines if the rule is enabled.
/// </summary>
public bool IsEnabled { get; set; }
/// <summary>
/// Optional rule name.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// The trigger properties.
/// </summary>
public RuleTriggerDto Trigger { get; set; }
/// <summary>
/// The action properties.
/// </summary>
public Dictionary<string, object> Action { get; set; }
/// <summary>
/// The number of completed executions.
/// </summary>
public long NumSucceeded { get; set; }
/// <summary>
/// The number of failed executions.
/// </summary>
public long NumFailed { get; set; }
/// <summary>
/// The date and time when the rule was executed the last time.
/// </summary>
[Obsolete("Removed when migrated to new rule statistics.")]
public Instant? LastExecuted { get; set; }
}

24
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/DynamicRulesDto.cs

@ -0,0 +1,24 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Rules.Models;
public sealed class DynamicRulesDto : Resource
{
/// <summary>
/// The rules.
/// </summary>
public DynamicRuleDto[] Items { get; set; }
/// <summary>
/// The ID of the rule that is currently rerunning.
/// </summary>
public DomainId? RunningRuleId { get; set; }
}

34
backend/src/Squidex/Areas/Api/Controllers/Rules/Models/DynamicUpdateRuleDto.cs

@ -0,0 +1,34 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Rules.Models;
[OpenApiRequest]
public sealed class DynamicUpdateRuleDto
{
/// <summary>
/// Optional rule name.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// The trigger properties.
/// </summary>
public RuleTriggerDto? Trigger { get; set; }
/// <summary>
/// The action properties.
/// </summary>
public Dictionary<string, object>? Action { get; set; }
/// <summary>
/// Enable or disable the rule.
/// </summary>
public bool? IsEnabled { get; set; }
}

6
backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsageDtoDto.cs → backend/src/Squidex/Areas/Api/Controllers/Statistics/Models/CallsUsageDto.cs

@ -10,7 +10,7 @@ using Squidex.Infrastructure.UsageTracking;
namespace Squidex.Areas.Api.Controllers.Statistics.Models; namespace Squidex.Areas.Api.Controllers.Statistics.Models;
public sealed class CallsUsageDtoDto public sealed class CallsUsageDto
{ {
/// <summary> /// <summary>
/// The total number of API calls. /// The total number of API calls.
@ -57,9 +57,9 @@ public sealed class CallsUsageDtoDto
/// </summary> /// </summary>
public Dictionary<string, CallsUsagePerDateDto[]> Details { get; set; } public Dictionary<string, CallsUsagePerDateDto[]> Details { get; set; }
public static CallsUsageDtoDto FromDomain(Plan plan, ApiStatsSummary summary, Dictionary<string, List<ApiStats>> details) public static CallsUsageDto FromDomain(Plan plan, ApiStatsSummary summary, Dictionary<string, List<ApiStats>> details)
{ {
return new CallsUsageDtoDto return new CallsUsageDto
{ {
AverageElapsedMs = summary.AverageElapsedMs, AverageElapsedMs = summary.AverageElapsedMs,
BlockingApiCalls = plan.BlockingApiCalls, BlockingApiCalls = plan.BlockingApiCalls,

8
backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs

@ -93,7 +93,7 @@ public sealed class UsagesController(
/// <response code="404">App not found.</response> /// <response code="404">App not found.</response>
[HttpGet] [HttpGet]
[Route("apps/{app}/usages/calls/{fromDate}/{toDate}/")] [Route("apps/{app}/usages/calls/{fromDate}/{toDate}/")]
[ProducesResponseType(typeof(CallsUsageDtoDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(CallsUsageDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(PermissionIds.AppUsage)] [ApiPermissionOrAnonymous(PermissionIds.AppUsage)]
[ApiCosts(0)] [ApiCosts(0)]
public async Task<IActionResult> GetUsages(string app, DateOnly fromDate, DateOnly toDate) public async Task<IActionResult> GetUsages(string app, DateOnly fromDate, DateOnly toDate)
@ -103,7 +103,7 @@ public sealed class UsagesController(
// Use the current app plan to show the limits to the user. // Use the current app plan to show the limits to the user.
var (plan, _, _) = await usageGate.GetPlanForAppAsync(App, false, HttpContext.RequestAborted); var (plan, _, _) = await usageGate.GetPlanForAppAsync(App, false, HttpContext.RequestAborted);
var response = CallsUsageDtoDto.FromDomain(plan, summary, details); var response = CallsUsageDto.FromDomain(plan, summary, details);
return Ok(response); return Ok(response);
} }
@ -118,7 +118,7 @@ public sealed class UsagesController(
/// <response code="404">Team not found.</response> /// <response code="404">Team not found.</response>
[HttpGet] [HttpGet]
[Route("teams/{team}/usages/calls/{fromDate}/{toDate}/")] [Route("teams/{team}/usages/calls/{fromDate}/{toDate}/")]
[ProducesResponseType(typeof(CallsUsageDtoDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(CallsUsageDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(PermissionIds.TeamUsage)] [ApiPermissionOrAnonymous(PermissionIds.TeamUsage)]
[ApiCosts(0)] [ApiCosts(0)]
public async Task<IActionResult> GetUsagesForTeam(string team, DateOnly fromDate, DateOnly toDate) public async Task<IActionResult> GetUsagesForTeam(string team, DateOnly fromDate, DateOnly toDate)
@ -128,7 +128,7 @@ public sealed class UsagesController(
// Use the current team plan to show the limits to the user. // Use the current team plan to show the limits to the user.
var (plan, _) = await usageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted); var (plan, _) = await usageGate.GetPlanForTeamAsync(Team, HttpContext.RequestAborted);
var response = CallsUsageDtoDto.FromDomain(plan, summary, details); var response = CallsUsageDto.FromDomain(plan, summary, details);
return Ok(response); return Ok(response);
} }

1
backend/src/Squidex/Startup.cs

@ -71,7 +71,6 @@ public sealed class Startup(IConfiguration config)
public void Configure(IApplicationBuilder app) public void Configure(IApplicationBuilder app)
{ {
app.UseWebSockets(); app.UseWebSockets();
app.UseCookiePolicy(); app.UseCookiePolicy();
app.UseDefaultPathBase(); app.UseDefaultPathBase();

22
frontend/generator/Generator.sln

@ -0,0 +1,22 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.12.35707.178 d17.12
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Generator", "Generator\Generator.csproj", "{0BD7AC0B-640B-4D26-A9B9-C1CCEE4C14E7}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{0BD7AC0B-640B-4D26-A9B9-C1CCEE4C14E7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0BD7AC0B-640B-4D26-A9B9-C1CCEE4C14E7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0BD7AC0B-640B-4D26-A9B9-C1CCEE4C14E7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0BD7AC0B-640B-4D26-A9B9-C1CCEE4C14E7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

30
frontend/generator/Generator/Generator.csproj

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NJsonSchema.CodeGeneration.TypeScript" Version="11.2.0" />
<PackageReference Include="NSwag.CodeGeneration.TypeScript" Version="14.3.0" />
</ItemGroup>
<ItemGroup>
<None Update="Templates\ConvertToClass.liquid">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Templates\Class.liquid">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Templates\ConvertToJavaScript.liquid">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Templates\Enum.StringLiteral.liquid">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

165
frontend/generator/Generator/Program.cs

@ -0,0 +1,165 @@
using NJsonSchema;
using NJsonSchema.CodeGeneration.TypeScript;
using NSwag;
using NSwag.CodeGeneration.TypeScript;
using System.Text.RegularExpressions;
namespace Generator;
internal partial class Program
{
static async Task Main(string[] args)
{
var cacheFile = "cache.json";
if (!File.Exists(cacheFile) || !args.Contains("--cache"))
{
var httpClient = new HttpClient();
var schemaResponse = await httpClient.GetAsync("https://localhost:5001/api/swagger/v1/swagger.json");
var schemaText = await schemaResponse.Content.ReadAsStringAsync();
File.WriteAllText(cacheFile, schemaText);
}
var codePath = GetCodePath();
var document = await OpenApiDocument.FromJsonAsync(File.ReadAllText(cacheFile));
foreach (var (typeName, schema) in document.Components.Schemas.ToList())
{
if (typeName.Equals("AssetDto"))
{
if (schema.ActualProperties.TryGetValue("tags", out var tags))
{
tags.IsNullableRaw = false;
tags.IsRequired = true;
}
}
if (typeName.Equals("LanguageDto"))
{
schema.Properties.Remove("nativeName");
}
if (typeName.Equals("AppDto") || typeName.Equals("TeamDto"))
{
if (schema.ActualProperties.ContainsKey("created") && !schema.ActualProperties.ContainsKey("createdBy"))
{
schema.Properties["createdBy"] = new JsonSchemaProperty
{
Description = "The user that has created the app.",
Type = JsonObjectType.String,
IsNullableRaw = true,
IsRequired = false,
IsReadOnly = true,
};
}
if (schema.ActualProperties.ContainsKey("lastModified") && !schema.ActualProperties.ContainsKey("lastModifiedBy"))
{
schema.Properties["lastModifiedBy"] = new JsonSchemaProperty
{
Description = "The user that has updated the app.",
Type = JsonObjectType.String,
IsNullableRaw = true,
IsRequired = false,
IsReadOnly = true,
};
}
}
foreach (var (name, property) in schema.ActualProperties)
{
property.IsReadOnly = true;
if (property.Type == JsonObjectType.String && !property.IsRequired)
{
property.IsNullableRaw = true;
}
if (name.Equals("referenceFields"))
{
property.IsNullableRaw = false;
property.IsRequired = true;
}
if (name.Equals("schemaName"))
{
property.IsNullableRaw = false;
property.IsRequired = true;
}
if (name.Equals("schemaDisplayName"))
{
property.IsNullableRaw = false;
property.IsRequired = true;
}
}
if (document.Components.Schemas.TryGetValue("ErrorDto", out var error))
{
document.Components.Schemas.Remove("ErrorDto");
document.Components.Schemas["ServerErrorDto"] = error;
}
if (!typeName.EndsWith("Dto") && schema.Type == JsonObjectType.Object)
{
document.Components.Schemas.Remove(typeName);
document.Components.Schemas[$"{typeName}Dto"] = schema;
}
}
var extensionFile = Path.Combine(codePath, @"..\\..\\src\\app\\shared\\model\\custom.ts");
var extensionCode = File.ReadAllText(extensionFile);
var classes =
ClassNameRegex().Matches(extensionCode)
.Select(m => m.Groups["ClassName"].Value)
.ToArray();
var settings = new TypeScriptClientGeneratorSettings
{
GenerateClientClasses = false,
GenerateClientInterfaces = false,
};
settings.TypeScriptGeneratorSettings.EnumStyle = TypeScriptEnumStyle.StringLiteral;
settings.TypeScriptGeneratorSettings.ExportTypes = true;
settings.TypeScriptGeneratorSettings.ExtendedClasses = classes;
settings.TypeScriptGeneratorSettings.ExtensionCode = extensionCode;
settings.TypeScriptGeneratorSettings.GenerateConstructorInterface = true;
settings.TypeScriptGeneratorSettings.InlineNamedDictionaries = true;
settings.TypeScriptGeneratorSettings.TemplateDirectory = Path.Combine(Directory.GetCurrentDirectory(), "Templates");
var generator = new TypeScriptClientGenerator(document, settings);
var code = generator.GenerateFile();
code = code.Replace("I{ [key: string]", "{ [key: string]");
code = code.Replace(": Date,", ": DateTime,");
code = code.Replace(": Date;", ": DateTime;");
code = code.Replace(": Date |", ": DateTime |");
code = code.Replace("DtoDto", "Dto");
var targetFolder = Path.Combine(codePath, @"..\\..\\src\\app\\shared\\model\\generated.ts");
File.WriteAllText(targetFolder, code);
}
[GeneratedRegex("class (?<ClassName>[^\\)]*) extends generated\\.")]
private static partial Regex ClassNameRegex();
private static string GetCodePath()
{
var folder = new DirectoryInfo(Directory.GetCurrentDirectory());
while (true)
{
if (folder.Name.Equals("Generator"))
{
return folder.FullName;
}
folder = folder.Parent!;
}
}
}

205
frontend/generator/Generator/Templates/Class.liquid

@ -0,0 +1,205 @@
{% if HasDescription -%}
/** {{ Description }} */
{% endif -%}
{% if ExportTypes %}export {% endif %}{% if IsAbstract %}abstract {% endif %}class {{ ClassName }}{{ Inheritance }} {
{% if HasDiscriminator -%}
/** The discriminator. */
public readonly {{ BaseDiscriminator }}!: string;
{% endif -%}
{% unless HasInheritance -%}
/** Uses the cache values because the actual object is frozen. */
private readonly cachedValues: { [key: string]: any } = {};
{% endunless -%}
{% for property in Properties -%}
{% if property.HasDescription -%}
/** {{ property.Description | strip }} */
{% endif -%}
{% if property.IsReadOnly %}readonly {% endif %}{{ property.PropertyName }}{% if property.IsOptional %}?{% elsif RequiresStrictPropertyInitialization %}!{% endif %}: {{ property.Type }}{{ property.TypePostfix }};
{% endfor -%}
{% if HasIndexerProperty -%}
[key: string]: {{ IndexerPropertyValueType }};
{% endif -%}
{% assign condition_temp = HasInheritance == false or ConvertConstructorInterfaceData -%}
{% if GenerateConstructorInterface or HasBaseDiscriminator -%}
constructor({% if GenerateConstructorInterface %}data?: I{{ ClassName }}{% endif %}) {
{% if HasInheritance -%}
super({% if GenerateConstructorInterface %}data{% endif %});
{% endif -%}
{% if GenerateConstructorInterface and condition_temp -%}
if (data) {
{% if HasInheritance == false -%}
for (var property in data) {
if (data.hasOwnProperty(property))
(<any>this)[property] = (<any>data)[property];
}
{% endif -%}
{% if ConvertConstructorInterfaceData -%}
{% for property in Properties -%}
{% if property.SupportsConstructorConversion -%}
{% if property.IsArray -%}
if (data.{{ property.PropertyName }}) {
this.{{ property.PropertyName }} = [];
for (let i = 0; i < data.{{ property.PropertyName }}.length; i++) {
let item = data.{{ property.PropertyName }}[i];
this.{{ property.PropertyName }}[i] = item && !(<any>item).toJSON ? new {{ property.ArrayItemType }}(item) : <{{ property.ArrayItemType }}>item;
}
}
{% elsif property.IsDictionary -%}
if (data.{{ property.PropertyName }}) {
this.{{ property.PropertyName }} = {};
for (let key in data.{{ property.PropertyName }}) {
if (data.{{ property.PropertyName }}.hasOwnProperty(key)) {
let item = data.{{ property.PropertyName }}[key];
this.{{ property.PropertyName }}[key] = item && !(<any>item).toJSON ? new {{ property.DictionaryItemType }}(item) : <{{ property.DictionaryItemType }}>item;
}
}
}
{% else -%}
this.{{ property.PropertyName }} = data.{{ property.PropertyName }} && !(<any>data.{{ property.PropertyName }}).toJSON ? new {{ property.Type }}(data.{{ property.PropertyName }}) : <{{ property.Type }}>this.{{ property.PropertyName }};
{% endif -%}
{% endif -%}
{% endfor -%}
{% endif -%}
}
{% endif -%}
{% if HasBaseDiscriminator -%}
(<any>this).{{ BaseDiscriminator }} = "{{ DiscriminatorName }}";
{% endif -%}
}
{% endif -%}
{% if HasInheritance and SupportsOverrideKeyword %}override {% endif %}init(_data: any{% if HandleReferences %}, _mappings?: any{% endif %}) {
{% if HasInheritance -%}
super.init(_data);
{% endif -%}
{% if HasIndexerProperty or HasProperties -%}
{% if HasIndexerProperty -%}
for (var property in _data) {
if (_data.hasOwnProperty(property))
this[property] = _data[property];
}
{% endif -%}
{% for property in Properties -%}
{{ property.ConvertToClassCode | strip | tab }}
{% endfor -%}
{% endif -%}
this.cleanup(this);
return this;
}
static {% if HasInheritance and SupportsOverrideKeyword %}override {% endif %}fromJSON(data: any{% if HandleReferences %}, _mappings?: any{% endif %}): {{ ClassName }} {
{% if HandleReferences -%}
{% if HasBaseDiscriminator -%}
{% for derivedClass in DerivedClasses -%}
if (data["{{ BaseDiscriminator }}"] === "{{ derivedClass.Discriminator }}")
{% if derivedClass.IsAbstract -%}
throw new Error("The abstract class '{{ derivedClass.ClassName }}' cannot be instantiated.");
{% else -%}
return createInstance<{{ derivedClass.ClassName }}>(data, _mappings, {{ derivedClass.ClassName }});
{% endif -%}
{% endfor -%}
{% endif -%}
{% if IsAbstract -%}
throw new Error("The abstract class '{{ ClassName }}' cannot be instantiated.");
{% else -%}
return createInstance<{{ ClassName }}>(data, _mappings, {{ ClassName }});
{% endif -%}
{% else -%}
{% if HasBaseDiscriminator -%}
{% for derivedClass in DerivedClasses -%}
if (data["{{ BaseDiscriminator }}"] === "{{ derivedClass.Discriminator }}") {
{% if derivedClass.IsAbstract -%}
throw new Error("The abstract class '{{ derivedClass.ClassName }}' cannot be instantiated.");
{% else -%}
return new {{ derivedClass.ClassName }}().init(data);
{% endif -%}
}
{% endfor -%}
{% endif -%}
{% if IsAbstract -%}
throw new Error("The abstract class '{{ ClassName }}' cannot be instantiated.");
{% else -%}
const result = new {{ ClassName }}().init(data);
result.cleanup(this);
return result;
{% endif -%}
{% endif -%}
}
{% if HasInheritance and SupportsOverrideKeyword %}override {% endif %}toJSON(data?: any) {
data = typeof data === 'object' ? data : {};
{% if HasIndexerProperty -%}
for (var property in this) {
if (this.hasOwnProperty(property))
data[property] = this[property];
}
{% endif -%}
{% if HasDiscriminator -%}
data["{{ BaseDiscriminator }}"] = this.{{ BaseDiscriminator }};
{% endif -%}
{% for property in Properties -%}
{{ property.ConvertToJavaScriptCode | tab }}
{% endfor -%}
{% if HasInheritance -%}
super.toJSON(data);
{% endif -%}
this.cleanup(data);
return data;
}
{% if GenerateCloneMethod -%}
clone(): {{ ClassName }} {
{% if IsAbstract -%}
throw new Error("The abstract class '{{ ClassName }}' cannot be instantiated.");
{% else -%}
const json = this.toJSON();
let result = new {{ ClassName }}();
result.init(json);
return result;
{% endif -%}
}
{% endif -%}
{% unless HasInheritance -%}
protected cleanup(target: any) {
for (var property in target) {
if (target.hasOwnProperty(property)) {
const value = target[property];
if (value === undefined) {
delete target[property];
}
}
}
}
protected compute<T>(key: string, action: () => T): T {
if (!this.cachedValues.hasOwnProperty(key)) {
const value = action();
this.cachedValues[key] = value;
return value;
} else {
return this.cachedValues[key] as any;
}
}
{% endunless -%}
}
{% if GenerateConstructorInterface -%}
{% if HasDescription -%}
/** {{ Description }} */
{% endif -%}
{% if ExportTypes %}export {% endif %}interface I{{ ClassName }}{{ InterfaceInheritance }} {
{% for property in Properties -%}
{% if property.HasDescription -%}
/** {{ property.Description | strip }} */
{% endif -%}
readonly {{ property.PropertyName }}{% if property.IsOptional %}?{% endif %}: {{ property.ConstructorInterfaceType }}{{ property.TypePostfix }};
{% endfor -%}
{% if HasIndexerProperty -%}
[key: string]: {{ IndexerPropertyValueType }};
{% endif -%}
}
{% endif -%}

65
frontend/generator/Generator/Templates/ConvertToClass.liquid

@ -0,0 +1,65 @@
{% if IsNewableObject -%}
{% if CheckNewableObject -%}
{{ Variable }} = {{ Value }} ? {{ Type }}.fromJSON({{ Value }}{% if HandleReferences -%}, _mappings{% endif %}) : {% if HasDefaultValue %}{{ DefaultValue }}{% else %}<any>{{ NullValue }}{% endif %};
{% else -%}
{{ Variable }} = {{ Type }}.fromJSON({{ Value }}{% if HandleReferences -%}, _mappings{% endif %});
{% endif -%}
{% elsif IsArray -%}
if (Array.isArray({{ Value }})) {
{{ Variable }} = [] as any;
for (let item of {{ Value }})
{% if IsArrayItemNewableObject -%}
{{ Variable }}{% if RequiresStrictPropertyInitialization %}!{% endif %}.push({{ ArrayItemType }}.fromJSON(item{% if HandleReferences %}, _mappings{% endif %}));
{% else -%}
{% if IsArrayItemDate -%}
{{ Variable }}{% if RequiresStrictPropertyInitialization %}!{% endif %}.push(DateTime.parseISO(item));
{% elsif IsArrayItemDateTime -%}
{{ Variable }}{% if RequiresStrictPropertyInitialization %}!{% endif %}.push(DateTime.parseISO(item));
{% else -%}
{{ Variable }}{% if RequiresStrictPropertyInitialization %}!{% endif %}.push(item);
{% endif -%}
{% endif -%}
}
{% if NullValue != "undefined" %}else {
{{ Variable }} = <any>{{ NullValue }};
}
{% endif -%}
{% elsif IsDictionary -%}
if ({{ Value }}) {
{{ Variable }} = {} as any;
for (let key in {{ Value }}) {
if ({{ Value }}.hasOwnProperty(key))
{% if IsDictionaryValueNewableObject -%}
(<any>{{ Variable }}){% if RequiresStrictPropertyInitialization %}!{% endif %}[key] = {{ Value }}[key] ? {{ DictionaryValueType }}.fromJSON({{ Value }}[key]{% if HandleReferences %}, _mappings{% endif %}) : {% if HasDictionaryValueDefaultValue %}{{ DictionaryValueDefaultValue }}{% else %}<any>{{ NullValue }}{% endif %};
{% elsif IsDictionaryValueNewableArray -%}
(<any>{{ Variable }}){% if RequiresStrictPropertyInitialization %}!{% endif %}[key] = {{ Value }}[key] ? {{ Value }}[key].map((i: any) => {{ DictionaryValueArrayItemType }}.fromJSON(i{% if HandleReferences %}, _mappings{% endif %})) : {% if HasDictionaryValueDefaultValue %}{{ DictionaryValueDefaultValue }}{% else %}<any>{{ NullValue }}{% endif %};
{% elsif IsDictionaryValueDate -%}
(<any>{{ Variable }}){% if RequiresStrictPropertyInitialization %}!{% endif %}[key] = {{ Value }}[key] ? DateTime.parseISO({{ Value }}[key].toString()) : {% if HasDictionaryValueDefaultValue %}{{ DictionaryValueDefaultValue }}{% else %}<any>{{ NullValue }}{% endif %};
{% elsif IsDictionaryValueDateTime -%}
(<any>{{ Variable }}){% if RequiresStrictPropertyInitialization %}!{% endif %}[key] = {{ Value }}[key] ? DateTime.parseISO({{ Value }}[key].toString()) : {% if HasDictionaryValueDefaultValue %}{{ DictionaryValueDefaultValue }}{% else %}<any>{{ NullValue }}{% endif %};
{% else -%}
{% if HasDictionaryValueDefaultValue or NullValue != "undefined" -%}
(<any>{{ Variable }}){% if RequiresStrictPropertyInitialization %}!{% endif %}[key] = {{ Value }}[key] !== undefined ? {{ Value }}[key] : {% if HasDictionaryValueDefaultValue %}{{ DictionaryValueDefaultValue }}{% else %}<any>{{ NullValue }}{% endif %};
{% else -%}
(<any>{{ Variable }}){% if RequiresStrictPropertyInitialization %}!{% endif %}[key] = {{ Value }}[key];
{% endif -%}
{% endif -%}
}
}
{% if NullValue != "undefined" %}else {
{{ Variable }} = <any>{{ NullValue }};
}
{% endif -%}
{% else -%}
{% if IsDate -%}
{{ Variable }} = {{ Value }} ? DateTime.parseISO({{ Value }}.toString()) : {% if HasDefaultValue %}DateTime.parseISO({{ DefaultValue }}){% else %}<any>{{ NullValue }}{% endif %};
{% elsif IsDateTime -%}
{{ Variable }} = {{ Value }} ? DateTime.parseISO({{ Value }}.toString()) : {% if HasDefaultValue %}DateTime.parseISO({{ DefaultValue }}){% else %}<any>{{ NullValue }}{% endif %};
{% else -%}
{% if HasDefaultValue or NullValue != "undefined" -%}
{{ Variable }} = {{ Value }} !== undefined ? {{ Value }} : {% if HasDefaultValue %}{{ DefaultValue }}{% else %}<any>{{ NullValue }}{% endif %};
{% else -%}
{{ Variable }} = {{ Value }};
{% endif -%}
{% endif -%}
{% endif -%}

45
frontend/generator/Generator/Templates/ConvertToJavaScript.liquid

@ -0,0 +1,45 @@
{%- if IsNewableObject -%}
{{ Variable }} = {{ Value }} ? {{ Value }}.toJSON() : <any>{{ NullValue }};
{%- elsif IsArray -%}
if (Array.isArray({{ Value }})) {
{{ Variable }} = [];
for (let item of {{ Value }})
{%- if IsArrayItemNewableObject -%}
{{ Variable }}.push(item.toJSON());
{%- elsif IsArrayItemDate -%}
{{ Variable }}.push({% if UseJsDate %}formatDate(item){% else %}item.toISOString(){% endif %});
{%- elsif IsArrayItemDateTime -%}
{{ Variable }}.push(item.toISOString());
{%- else -%}
{{ Variable }}.push(item);
{%- endif -%}
}
{%- elsif IsDictionary -%}
if ({{ Value }}) {
{{ Variable }} = {};
for (let key in {{ Value }}) {
if ({{ Value }}.hasOwnProperty(key))
{%- if IsDictionaryValueNewableObject -%}
(<any>{{ Variable }})[key] = {{ Value }}[key] ? {{ Value }}[key].toJSON() : <any>{{ NullValue }};
{%- elsif IsDictionaryValueDate -%}
(<any>{{ Variable }})[key] = {{ Value }}[key] ? {{ Value }}[key].toISOString() : <any>{{ NullValue }};
{%- elsif IsDictionaryValueDateTime -%}
(<any>{{ Variable }})[key] = {{ Value }}[key] ? {{ Value }}[key].toISOString() : <any>{{ NullValue }};
{%- else -%}
{%- if NullValue != "undefined" -%}
(<any>{{ Variable }})[key] = {{ Value }}[key] !== undefined ? {{ Value }}[key] : <any>{{ NullValue }};
{%- else -%}
(<any>{{ Variable }})[key] = (<any>{{ Value }})[key];
{%- endif -%}
{%- endif -%}
}
}
{%- elsif IsDate -%}
{{ Variable }} = {{ Value }} ? {{ Value }}.toISOString() : {% if HasDefaultValue %}{{ DefaultValue }}{% else %}<any>{{ NullValue }}{% endif %};
{%- elsif IsDateTime -%}
{{ Variable }} = {{ Value }} ? {{ Value }}.toISOString() : {% if HasDefaultValue %}{{ DefaultValue }}{% else %}<any>{{ NullValue }}{% endif %};
{%- elsif NullValue != "undefined" -%}
{{ Variable }} = {{ Value }} !== undefined ? {{ Value }} : <any>{{ NullValue }};
{%- else -%}
{{ Variable }} = {{ Value }};
{%- endif %}

10
frontend/generator/Generator/Templates/Enum.StringLiteral.liquid

@ -0,0 +1,10 @@
{%- if HasDescription -%}
/** {{ Description }} */
{%- endif -%}
{%- if ExportTypes %}export {% endif %}type {{ Name }} = {% for enumeration in Enums %}{%- if Enums.first.Value != enumeration.Value %} | {% endif %}{{ enumeration.Value }}{% endfor %};
export const {{ Name | uppercamelcase }}Values: ReadonlyArray<{{ Name }}> = [
{% for enumeration in Enums -%}
{{ enumeration.Value }}{% unless forloop.last %},{% endunless %}
{% endfor -%}
];

4
frontend/src/app/features/administration/pages/event-consumers/event-consumer.component.ts

@ -9,8 +9,8 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { TooltipDirective } from '@app/shared'; import { EventConsumerDto, TooltipDirective } from '@app/shared';
import { EventConsumerDto, EventConsumersState } from '../../internal'; import { EventConsumersState } from '../../internal';
@Component({ @Component({
standalone: true, standalone: true,

4
frontend/src/app/features/administration/pages/event-consumers/event-consumers-page.component.ts

@ -10,8 +10,8 @@ import { Component, OnInit } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { timer } from 'rxjs'; import { timer } from 'rxjs';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { DialogModel, LayoutComponent, ListViewComponent, ModalDialogComponent, ModalDirective, ShortcutDirective, SidebarMenuDirective, Subscriptions, SyncWidthDirective, TitleComponent, TooltipDirective, TourStepDirective, TranslatePipe } from '@app/shared'; import { DialogModel, EventConsumerDto, LayoutComponent, ListViewComponent, ModalDialogComponent, ModalDirective, ShortcutDirective, SidebarMenuDirective, Subscriptions, SyncWidthDirective, TitleComponent, TooltipDirective, TourStepDirective, TranslatePipe } from '@app/shared';
import { EventConsumerDto, EventConsumersState } from '../../internal'; import { EventConsumersState } from '../../internal';
import { EventConsumerComponent } from './event-consumer.component'; import { EventConsumerComponent } from './event-consumer.component';
@Component({ @Component({

25
frontend/src/app/features/administration/pages/restore/restore-page.component.ts

@ -50,19 +50,20 @@ export class RestorePageComponent {
public restore() { public restore() {
const value = this.restoreForm.submit(); const value = this.restoreForm.submit();
if (!value) {
return;
}
if (value) { this.restoreForm.submitCompleted();
this.restoreForm.submitCompleted();
this.jobsService.postRestore(value) this.jobsService.postRestore(value)
.subscribe({ .subscribe({
next: () => { next: () => {
this.dialogs.notifyInfo('i18n:jobs.restoreStarted'); this.dialogs.notifyInfo('i18n:jobs.restoreStarted');
}, },
error: error => { error: error => {
this.dialogs.notifyError(error); this.dialogs.notifyError(error);
}, },
}); });
}
} }
} }

51
frontend/src/app/features/administration/pages/users/user-page.component.ts

@ -9,8 +9,8 @@ import { AsyncPipe } from '@angular/common';
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { ControlErrorsComponent, FormErrorComponent, LayoutComponent, ShortcutDirective, Subscriptions, TitleComponent, TooltipDirective, TranslatePipe } from '@app/shared'; import { ControlErrorsComponent, FormErrorComponent, LayoutComponent, ShortcutDirective, Subscriptions, TitleComponent, TooltipDirective, TranslatePipe, UpdateUserDto } from '@app/shared';
import { UpsertUserDto, UserDto, UserForm, UsersState } from '../../internal'; import { UserDto, UserForm, UsersState } from '../../internal';
@Component({ @Component({
standalone: true, standalone: true,
@ -66,29 +66,32 @@ export class UserPageComponent implements OnInit {
} }
const value = this.userForm.submit(); const value = this.userForm.submit();
if (!value) {
return;
}
if (this.user) {
const request = new UpdateUserDto({ ...value });
if (value) { this.usersState.update(this.user, request)
if (this.user) { .subscribe({
this.usersState.update(this.user, value) next: user => {
.subscribe({ this.userForm.submitCompleted({ newValue: user });
next: user => { },
this.userForm.submitCompleted({ newValue: user }); error: error => {
}, this.userForm.submitFailed(error);
error: error => { },
this.userForm.submitFailed(error); });
}, } else {
}); this.usersState.create(value)
} else { .subscribe({
this.usersState.create(<UpsertUserDto>value) next: () => {
.subscribe({ this.back();
next: () => { },
this.back(); error: error => {
}, this.userForm.submitFailed(error);
error: error => { },
this.userForm.submitFailed(error); });
},
});
}
} }
} }

60
frontend/src/app/features/administration/services/event-consumers.service.spec.ts

@ -8,8 +8,9 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { ApiUrlConfig, Resource, ResourceLinks } from '@app/shared'; import { ApiUrlConfig, EventConsumerDto, EventConsumersDto } from '@app/shared';
import { EventConsumerDto, EventConsumersDto, EventConsumersService } from './event-consumers.service'; import { IResourceDto, ResourceLinkDto } from '@app/shared/model';
import { EventConsumersService } from './event-consumers.service';
describe('EventConsumersService', () => { describe('EventConsumersService', () => {
beforeEach(() => { beforeEach(() => {
@ -31,7 +32,6 @@ describe('EventConsumersService', () => {
it('should make get request to get event consumers', it('should make get request to get event consumers',
inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => {
let eventConsumers: EventConsumersDto; let eventConsumers: EventConsumersDto;
eventConsumersService.getEventConsumers().subscribe(result => { eventConsumersService.getEventConsumers().subscribe(result => {
eventConsumers = result; eventConsumers = result;
}); });
@ -46,26 +46,28 @@ describe('EventConsumersService', () => {
eventConsumerResponse(12), eventConsumerResponse(12),
eventConsumerResponse(13), eventConsumerResponse(13),
], ],
_links: {},
}); });
expect(eventConsumers!).toEqual({ expect(eventConsumers!).toEqual(
items: [ new EventConsumersDto({
createEventConsumer(12), items: [
createEventConsumer(13), createEventConsumer(12),
], createEventConsumer(13),
}); ],
_links: {},
}));
})); }));
it('should make put request to start event consumer', it('should make put request to start event consumer',
inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => {
const resource: Resource = { const resource: IResourceDto = {
_links: { _links: {
start: { method: 'PUT', href: 'api/event-consumers/event-consumer123/start' }, start: new ResourceLinkDto({ method: 'PUT', href: 'api/event-consumers/event-consumer123/start' }),
}, },
}; };
let eventConsumer: EventConsumerDto; let eventConsumer: EventConsumerDto;
eventConsumersService.putStart(resource).subscribe(response => { eventConsumersService.putStart(resource).subscribe(response => {
eventConsumer = response; eventConsumer = response;
}); });
@ -82,14 +84,13 @@ describe('EventConsumersService', () => {
it('should make put request to stop event consumer', it('should make put request to stop event consumer',
inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => {
const resource: Resource = { const resource: IResourceDto = {
_links: { _links: {
stop: { method: 'PUT', href: 'api/event-consumers/event-consumer123/stop' }, stop: new ResourceLinkDto({ method: 'PUT', href: 'api/event-consumers/event-consumer123/stop' }),
}, },
}; };
let eventConsumer: EventConsumerDto; let eventConsumer: EventConsumerDto;
eventConsumersService.putStop(resource).subscribe(response => { eventConsumersService.putStop(resource).subscribe(response => {
eventConsumer = response; eventConsumer = response;
}); });
@ -106,14 +107,13 @@ describe('EventConsumersService', () => {
it('should make put request to reset event consumer', it('should make put request to reset event consumer',
inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => {
const resource: Resource = { const resource: IResourceDto = {
_links: { _links: {
reset: { method: 'PUT', href: 'api/event-consumers/event-consumer123/reset' }, reset: new ResourceLinkDto({ method: 'PUT', href: 'api/event-consumers/event-consumer123/reset' }),
}, },
}; };
let eventConsumer: EventConsumerDto; let eventConsumer: EventConsumerDto;
eventConsumersService.putReset(resource).subscribe(response => { eventConsumersService.putReset(resource).subscribe(response => {
eventConsumer = response; eventConsumer = response;
}); });
@ -146,17 +146,17 @@ describe('EventConsumersService', () => {
}); });
export function createEventConsumer(id: number, suffix = '') { export function createEventConsumer(id: number, suffix = '') {
const links: ResourceLinks = {
reset: { method: 'PUT', href: `/event-consumers/${id}/reset` },
};
const key = `${id}${suffix}`; const key = `${id}${suffix}`;
return new EventConsumerDto(links, return new EventConsumerDto({
`event-consumer${id}`, name: `event-consumer${id}`,
id, position: `position${key}`,
true, count: id,
true, isStopped: true,
`failure${key}`, isResetting: true,
`position${key}`); error: `failure${key}`,
} _links: {
reset: new ResourceLinkDto({ method: 'PUT', href: `/event-consumers/${id}/reset` }),
},
});
}

61
frontend/src/app/features/administration/services/event-consumers.service.ts

@ -9,35 +9,7 @@ import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { ApiUrlConfig, hasAnyLink, pretifyError, Resource, ResourceLinks } from '@app/shared'; import { ApiUrlConfig, EventConsumerDto, EventConsumersDto, IResourceDto, pretifyError } from '@app/shared';
export class EventConsumerDto implements Resource {
public readonly _links: ResourceLinks;
public readonly canReset: boolean;
public readonly canStart: boolean;
public readonly canStop: boolean;
constructor(links: ResourceLinks,
public readonly name: string,
public readonly count: number,
public readonly isStopped?: boolean,
public readonly isResetting?: boolean,
public readonly error?: string,
public readonly position?: string,
) {
this._links = links;
this.canReset = hasAnyLink(links, 'reset');
this.canStart = hasAnyLink(links, 'start');
this.canStop = hasAnyLink(links, 'stop');
}
}
export type EventConsumersDto = Readonly<{
// The list of event consumers.
items: ReadonlyArray<EventConsumerDto>;
}>;
@Injectable() @Injectable()
export class EventConsumersService { export class EventConsumersService {
@ -52,61 +24,44 @@ export class EventConsumersService {
return this.http.get<any>(url).pipe( return this.http.get<any>(url).pipe(
map(body => { map(body => {
return parseEventConsumers(body); return EventConsumersDto.fromJSON(body);
}), }),
pretifyError('i18n:eventConsumers.loadFailed')); pretifyError('i18n:eventConsumers.loadFailed'));
} }
public putStart(eventConsumer: Resource): Observable<EventConsumerDto> { public putStart(eventConsumer: IResourceDto): Observable<EventConsumerDto> {
const link = eventConsumer._links['start']; const link = eventConsumer._links['start'];
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url).pipe( return this.http.request(link.method, url).pipe(
map(body => { map(body => {
return parseEventConsumer(body); return EventConsumerDto.fromJSON(body);
}), }),
pretifyError('i18n:eventConsumers.startFailed')); pretifyError('i18n:eventConsumers.startFailed'));
} }
public putStop(eventConsumer: Resource): Observable<EventConsumerDto> { public putStop(eventConsumer: IResourceDto): Observable<EventConsumerDto> {
const link = eventConsumer._links['stop']; const link = eventConsumer._links['stop'];
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url).pipe( return this.http.request(link.method, url).pipe(
map(body => { map(body => {
return parseEventConsumer(body); return EventConsumerDto.fromJSON(body);
}), }),
pretifyError('i18n:eventConsumers.stopFailed')); pretifyError('i18n:eventConsumers.stopFailed'));
} }
public putReset(eventConsumer: Resource): Observable<EventConsumerDto> { public putReset(eventConsumer: IResourceDto): Observable<EventConsumerDto> {
const link = eventConsumer._links['reset']; const link = eventConsumer._links['reset'];
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url).pipe( return this.http.request(link.method, url).pipe(
map(body => { map(body => {
return parseEventConsumer(body); return EventConsumerDto.fromJSON(body);
}), }),
pretifyError('i18n:eventConsumers.resetFailed')); pretifyError('i18n:eventConsumers.resetFailed'));
} }
} }
function parseEventConsumers(response: { items: any[] } & Resource): EventConsumersDto {
const { items: list } = response;
const items = list.map(parseEventConsumer);
return { items };
}
function parseEventConsumer(response: any): EventConsumerDto {
return new EventConsumerDto(response._links,
response.name,
response.count,
response.isStopped,
response.isResetting,
response.error,
response.position);
}

100
frontend/src/app/features/administration/services/users.service.spec.ts

@ -8,8 +8,9 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing'; import { inject, TestBed } from '@angular/core/testing';
import { ApiUrlConfig, Resource, ResourceLinks } from '@app/shared'; import { ApiUrlConfig, UserDto, UsersDto } from '@app/shared';
import { UserDto, UsersDto, UsersService } from './users.service'; import { CreateUserDto, IResourceDto, ResourceLinkDto, UpdateUserDto } from '@app/shared/model';
import { UsersService } from './users.service';
describe('UsersService', () => { describe('UsersService', () => {
beforeEach(() => { beforeEach(() => {
@ -31,7 +32,6 @@ describe('UsersService', () => {
it('should make get request to get many users', it('should make get request to get many users',
inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => {
let users: UsersDto; let users: UsersDto;
userManagementService.getUsers(20, 30).subscribe(result => { userManagementService.getUsers(20, 30).subscribe(result => {
users = result; users = result;
}); });
@ -47,22 +47,23 @@ describe('UsersService', () => {
userResponse(12), userResponse(12),
userResponse(13), userResponse(13),
], ],
_links: {},
}); });
expect(users!).toEqual({ expect(users!).toEqual(
total: 100, new UsersDto({
items: [ total: 100,
createUser(12), items: [
createUser(13), createUser(12),
], createUser(13),
canCreate: false, ],
}); _links: {},
}));
})); }));
it('should make get request with query to get many users', it('should make get request with query to get many users',
inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => {
let users: UsersDto; let users: UsersDto;
userManagementService.getUsers(20, 30, 'my-query').subscribe(result => { userManagementService.getUsers(20, 30, 'my-query').subscribe(result => {
users = result; users = result;
}); });
@ -78,22 +79,23 @@ describe('UsersService', () => {
userResponse(12), userResponse(12),
userResponse(13), userResponse(13),
], ],
_links: {},
}); });
expect(users!).toEqual({ expect(users!).toEqual(
total: 100, new UsersDto({
items: [ total: 100,
createUser(12), items: [
createUser(13), createUser(12),
], createUser(13),
canCreate: false, ],
}); _links: {},
}));
})); }));
it('should make get request to get single user', it('should make get request to get single user',
inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => {
let user: UserDto; let user: UserDto;
userManagementService.getUser('123').subscribe(result => { userManagementService.getUser('123').subscribe(result => {
user = result; user = result;
}); });
@ -110,10 +112,14 @@ describe('UsersService', () => {
it('should make post request to create user', it('should make post request to create user',
inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => {
const dto = { email: 'mail@squidex.io', displayName: 'Squidex User', permissions: ['Permission1'], password: 'password' }; const dto = new CreateUserDto({
email: 'mail@squidex.io',
displayName: 'Squidex User',
permissions: ['Permission1'],
password: 'password',
});
let user: UserDto; let user: UserDto;
userManagementService.postUser(dto).subscribe(result => { userManagementService.postUser(dto).subscribe(result => {
user = result; user = result;
}); });
@ -130,16 +136,20 @@ describe('UsersService', () => {
it('should make put request to update user', it('should make put request to update user',
inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => {
const dto = { email: 'mail@squidex.io', displayName: 'Squidex User', permissions: ['Permission1'], password: 'password' }; const dto = new UpdateUserDto({
email: 'mail@squidex.io',
displayName: 'Squidex User',
permissions: ['Permission1'],
password: 'password',
});
const resource: Resource = { const resource: IResourceDto = {
_links: { _links: {
update: { method: 'PUT', href: 'api/user-management/123' }, update: new ResourceLinkDto({ method: 'PUT', href: 'api/user-management/123' }),
}, },
}; };
let user: UserDto; let user: UserDto;
userManagementService.putUser(resource, dto).subscribe(result => { userManagementService.putUser(resource, dto).subscribe(result => {
user = result; user = result;
}); });
@ -156,14 +166,13 @@ describe('UsersService', () => {
it('should make put request to lock user', it('should make put request to lock user',
inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => {
const resource: Resource = { const resource: IResourceDto = {
_links: { _links: {
lock: { method: 'PUT', href: 'api/user-management/123/lock' }, lock: new ResourceLinkDto({ method: 'PUT', href: 'api/user-management/123/lock' }),
}, },
}; };
let user: UserDto; let user: UserDto;
userManagementService.lockUser(resource).subscribe(result => { userManagementService.lockUser(resource).subscribe(result => {
user = result; user = result;
}); });
@ -180,14 +189,13 @@ describe('UsersService', () => {
it('should make put request to unlock user', it('should make put request to unlock user',
inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => {
const resource: Resource = { const resource: IResourceDto = {
_links: { _links: {
unlock: { method: 'PUT', href: 'api/user-management/123/unlock' }, unlock: new ResourceLinkDto({ method: 'PUT', href: 'api/user-management/123/unlock' }),
}, },
}; };
let user: UserDto; let user: UserDto;
userManagementService.unlockUser(resource).subscribe(result => { userManagementService.unlockUser(resource).subscribe(result => {
user = result; user = result;
}); });
@ -204,9 +212,9 @@ describe('UsersService', () => {
it('should make delete request to delete user', it('should make delete request to delete user',
inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => {
const resource: Resource = { const resource: IResourceDto = {
_links: { _links: {
delete: { method: 'DELETE', href: 'api/user-management/123' }, delete: new ResourceLinkDto({ method: 'DELETE', href: 'api/user-management/123' }),
}, },
}; };
@ -241,18 +249,18 @@ describe('UsersService', () => {
}); });
export function createUser(id: number, suffix = '') { export function createUser(id: number, suffix = '') {
const links: ResourceLinks = {
update: { method: 'PUT', href: `/users/${id}` },
};
const key = `${id}${suffix}`; const key = `${id}${suffix}`;
return new UserDto(links, return new UserDto({
`${id}`, id: `${id}`,
`user${key}@domain.com`, email: `user${key}@domain.com`,
`user${key}`, displayName: `user${key}`,
[ permissions: [
`Permission${key}`, `Permission${key}`,
], ],
true); isLocked: true,
} _links: {
update: new ResourceLinkDto({ method: 'PUT', href: `/users/${id}` }),
},
});
}

99
frontend/src/app/features/administration/services/users.service.ts

@ -9,56 +9,8 @@ import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { ApiUrlConfig, hasAnyLink, pretifyError, Resource, ResourceLinks, StringHelper } from '@app/shared'; import { ApiUrlConfig, CreateUserDto, IResourceDto, pretifyError, StringHelper, UpdateUserDto, UserDto, UsersDto } from '@app/shared';
export { UserDto, UsersDto };
export class UserDto implements Resource {
public readonly _links: ResourceLinks;
public readonly canDelete: boolean;
public readonly canLock: boolean;
public readonly canUnlock: boolean;
public readonly canUpdate: boolean;
constructor(links: ResourceLinks,
public readonly id: string,
public readonly email: string,
public readonly displayName: string,
public readonly permissions: ReadonlyArray<string> = [],
public readonly isLocked?: boolean,
) {
this._links = links;
this.canDelete = hasAnyLink(links, 'delete');
this.canLock = hasAnyLink(links, 'lock');
this.canUnlock = hasAnyLink(links, 'unlock');
this.canUpdate = hasAnyLink(links, 'update');
}
}
export type UsersDto = Readonly<{
// The list of users.
items: ReadonlyArray<UserDto>;
// The number of users.
total: number;
// True, if the user has permissions to create a user.
canCreate?: boolean;
}>;
export type UpsertUserDto = Readonly<{
// The email address of the user.
email: string;
// The display name.
displayName?: string;
// The permissions as in the dot-notation.
permissions?: ReadonlyArray<string>;
// The password (confirm is only used in the UI).
password?: string;
}>;
@Injectable() @Injectable()
export class UsersService { export class UsersService {
@ -73,7 +25,7 @@ export class UsersService {
return this.http.get<any>(url).pipe( return this.http.get<any>(url).pipe(
map(body => { map(body => {
return parseUsers(body); return UsersDto.fromJSON(body);
}), }),
pretifyError('i18n:users.loadFailed')); pretifyError('i18n:users.loadFailed'));
} }
@ -83,58 +35,58 @@ export class UsersService {
return this.http.get(url).pipe( return this.http.get(url).pipe(
map(body => { map(body => {
return parseUser(body); return UserDto.fromJSON(body);
}), }),
pretifyError('i18n:users.loadUserFailed')); pretifyError('i18n:users.loadUserFailed'));
} }
public postUser(dto: UpsertUserDto): Observable<UserDto> { public postUser(dto: CreateUserDto): Observable<UserDto> {
const url = this.apiUrl.buildUrl('api/user-management'); const url = this.apiUrl.buildUrl('api/user-management');
return this.http.post(url, dto).pipe( return this.http.post(url, dto.toJSON()).pipe(
map(body => { map(body => {
return parseUser(body); return UserDto.fromJSON(body);
}), }),
pretifyError('i18n:users.createFailed')); pretifyError('i18n:users.createFailed'));
} }
public putUser(user: Resource, dto: Partial<UpsertUserDto>): Observable<UserDto> { public putUser(user: IResourceDto, dto: UpdateUserDto): Observable<UserDto> {
const link = user._links['update']; const link = user._links['update'];
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url, { body: dto }).pipe( return this.http.request(link.method, url, { body: dto.toJSON() }).pipe(
map(body => { map(body => {
return parseUser(body); return UserDto.fromJSON(body);
}), }),
pretifyError('i18n:users.updateFailed')); pretifyError('i18n:users.updateFailed'));
} }
public lockUser(user: Resource): Observable<UserDto> { public lockUser(user: IResourceDto): Observable<UserDto> {
const link = user._links['lock']; const link = user._links['lock'];
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url).pipe( return this.http.request(link.method, url).pipe(
map(body => { map(body => {
return parseUser(body); return UserDto.fromJSON(body);
}), }),
pretifyError('i18n:users.lockFailed')); pretifyError('i18n:users.lockFailed'));
} }
public unlockUser(user: Resource): Observable<UserDto> { public unlockUser(user: IResourceDto): Observable<UserDto> {
const link = user._links['unlock']; const link = user._links['unlock'];
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url).pipe( return this.http.request(link.method, url).pipe(
map(body => { map(body => {
return parseUser(body); return UserDto.fromJSON(body);
}), }),
pretifyError('i18n:users.unlockFailed')); pretifyError('i18n:users.unlockFailed'));
} }
public deleteUser(user: Resource): Observable<any> { public deleteUser(user: IResourceDto): Observable<any> {
const link = user._links['delete']; const link = user._links['delete'];
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
@ -142,23 +94,4 @@ export class UsersService {
return this.http.request(link.method, url).pipe( return this.http.request(link.method, url).pipe(
pretifyError('i18n:users.deleteFailed')); pretifyError('i18n:users.deleteFailed'));
} }
} }
function parseUsers(response: { items: any[]; total: number } & Resource): UsersDto {
const { items: list, total, _links } = response;
const items = list.map(parseUser);
const canCreate = hasAnyLink(_links, 'create');
return { items, total, canCreate };
}
function parseUser(response: any) {
return new UserDto(
response._links,
response.id,
response.email,
response.displayName,
response.permissions,
response.isLocked);
}

8
frontend/src/app/features/administration/state/event-consumers.state.spec.ts

@ -7,7 +7,7 @@
import { of, onErrorResumeNextWith, throwError } from 'rxjs'; import { of, onErrorResumeNextWith, throwError } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { DialogService } from '@app/shared'; import { DialogService, EventConsumersDto } from '@app/shared';
import { EventConsumersService } from '../internal'; import { EventConsumersService } from '../internal';
import { createEventConsumer } from '../services/event-consumers.service.spec'; import { createEventConsumer } from '../services/event-consumers.service.spec';
import { EventConsumersState } from './event-consumers.state'; import { EventConsumersState } from './event-consumers.state';
@ -34,7 +34,7 @@ describe('EventConsumersState', () => {
describe('Loading', () => { describe('Loading', () => {
it('should load event consumers', () => { it('should load event consumers', () => {
eventConsumersService.setup(x => x.getEventConsumers()) eventConsumersService.setup(x => x.getEventConsumers())
.returns(() => of({ items: [eventConsumer1, eventConsumer2] })).verifiable(); .returns(() => of(new EventConsumersDto({ items: [eventConsumer1, eventConsumer2], _links: {} }))).verifiable();
eventConsumersState.load().subscribe(); eventConsumersState.load().subscribe();
@ -56,7 +56,7 @@ describe('EventConsumersState', () => {
it('should show notification on load if reload is true', () => { it('should show notification on load if reload is true', () => {
eventConsumersService.setup(x => x.getEventConsumers()) eventConsumersService.setup(x => x.getEventConsumers())
.returns(() => of({ items: [eventConsumer1, eventConsumer2] })).verifiable(); .returns(() => of(new EventConsumersDto({ items: [eventConsumer1, eventConsumer2], _links: {} }))).verifiable();
eventConsumersState.load(true).subscribe(); eventConsumersState.load(true).subscribe();
@ -80,7 +80,7 @@ describe('EventConsumersState', () => {
describe('Updates', () => { describe('Updates', () => {
beforeEach(() => { beforeEach(() => {
eventConsumersService.setup(x => x.getEventConsumers()) eventConsumersService.setup(x => x.getEventConsumers())
.returns(() => of({ items: [eventConsumer1, eventConsumer2] })).verifiable(); .returns(() => of(new EventConsumersDto({ items: [eventConsumer1, eventConsumer2], _links: {} }))).verifiable();
eventConsumersState.load().subscribe(); eventConsumersState.load().subscribe();
}); });

4
frontend/src/app/features/administration/state/event-consumers.state.ts

@ -8,8 +8,8 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { finalize, tap } from 'rxjs/operators'; import { finalize, tap } from 'rxjs/operators';
import { debug, DialogService, LoadingState, shareSubscribed, State } from '@app/shared'; import { debug, DialogService, EventConsumerDto, LoadingState, shareSubscribed, State } from '@app/shared';
import { EventConsumerDto, EventConsumersService } from '../services/event-consumers.service'; import { EventConsumersService } from '../services/event-consumers.service';
interface Snapshot extends LoadingState { interface Snapshot extends LoadingState {
// The list of event consumers. // The list of event consumers.

7
frontend/src/app/features/administration/state/users.forms.ts

@ -6,10 +6,9 @@
*/ */
import { UntypedFormControl, Validators } from '@angular/forms'; import { UntypedFormControl, Validators } from '@angular/forms';
import { ExtendedFormGroup, Form, ValidatorsEx } from '@app/shared'; import { CreateUserDto, ExtendedFormGroup, Form, UserDto, ValidatorsEx } from '@app/shared';
import { UpsertUserDto, UserDto } from '../services/users.service';
export class UserForm extends Form<ExtendedFormGroup, UpsertUserDto, UserDto> { export class UserForm extends Form<ExtendedFormGroup, CreateUserDto, UserDto> {
constructor() { constructor() {
super(new ExtendedFormGroup({ super(new ExtendedFormGroup({
email: new UntypedFormControl('', [ email: new UntypedFormControl('', [
@ -52,6 +51,6 @@ export class UserForm extends Form<ExtendedFormGroup, UpsertUserDto, UserDto> {
protected transformSubmit(value: any) { protected transformSubmit(value: any) {
const permissions = value['permissions'].split('\n').defined(); const permissions = value['permissions'].split('\n').defined();
return { ...value, permissions }; return new CreateUserDto({ ...value, permissions });
} }
} }

32
frontend/src/app/features/administration/state/users.state.spec.ts

@ -7,8 +7,8 @@
import { firstValueFrom, of, onErrorResumeNextWith, throwError } from 'rxjs'; import { firstValueFrom, of, onErrorResumeNextWith, throwError } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { DialogService } from '@app/shared'; import { CreateUserDto, DialogService, UpdateUserDto, UsersDto } from '@app/shared';
import { UpsertUserDto, UsersService } from '../internal'; import { UsersService } from '../internal';
import { createUser } from '../services/users.service.spec'; import { createUser } from '../services/users.service.spec';
import { UsersState } from './users.state'; import { UsersState } from './users.state';
@ -36,7 +36,7 @@ describe('UsersState', () => {
describe('Loading', () => { describe('Loading', () => {
it('should load users', () => { it('should load users', () => {
usersService.setup(x => x.getUsers(10, 0, undefined)) usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of({ items: [user1, user2], total: 200 })).verifiable(); .returns(() => of(new UsersDto({ items: [user1, user2], total: 200, _links: {} }))).verifiable();
usersState.load().subscribe(); usersState.load().subscribe();
@ -59,7 +59,7 @@ describe('UsersState', () => {
it('should show notification on load if reload is true', () => { it('should show notification on load if reload is true', () => {
usersService.setup(x => x.getUsers(10, 0, undefined)) usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of({ items: [user1, user2], total: 200 })).verifiable(); .returns(() => of(new UsersDto({ items: [user1, user2], total: 200, _links: {} }))).verifiable();
usersState.load(true).subscribe(); usersState.load(true).subscribe();
@ -70,7 +70,7 @@ describe('UsersState', () => {
it('should load with new pagination if paging', () => { it('should load with new pagination if paging', () => {
usersService.setup(x => x.getUsers(10, 10, undefined)) usersService.setup(x => x.getUsers(10, 10, undefined))
.returns(() => of({ items: [], total: 200 })).verifiable(); .returns(() => of(new UsersDto({ items: [], total: 200, _links: {} }))).verifiable();
usersState.page({ page: 1, pageSize: 10 }).subscribe(); usersState.page({ page: 1, pageSize: 10 }).subscribe();
@ -79,7 +79,7 @@ describe('UsersState', () => {
it('should load with query if searching', () => { it('should load with query if searching', () => {
usersService.setup(x => x.getUsers(10, 0, 'my-query')) usersService.setup(x => x.getUsers(10, 0, 'my-query'))
.returns(() => of({ items: [], total: 0 })).verifiable(); .returns(() => of(new UsersDto({ items: [], total: 0, _links: {} }))).verifiable();
usersState.search('my-query').subscribe(); usersState.search('my-query').subscribe();
@ -90,7 +90,7 @@ describe('UsersState', () => {
describe('Updates', () => { describe('Updates', () => {
beforeEach(() => { beforeEach(() => {
usersService.setup(x => x.getUsers(10, 0, undefined)) usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of({ items: [user1, user2], total: 200 })).verifiable(); .returns(() => of(new UsersDto({ items: [user1, user2], total: 200, _links: {} }))).verifiable();
usersState.load().subscribe(); usersState.load().subscribe();
}); });
@ -130,7 +130,7 @@ describe('UsersState', () => {
}); });
it('should add user to snapshot if created', () => { it('should add user to snapshot if created', () => {
const request: UpsertUserDto = { ...newUser, password: 'password' } as any; const request = new CreateUserDto({ ...newUser, password: 'password' });
usersService.setup(x => x.postUser(request)) usersService.setup(x => x.postUser(request))
.returns(() => of(newUser)).verifiable(); .returns(() => of(newUser)).verifiable();
@ -142,9 +142,8 @@ describe('UsersState', () => {
}); });
it('should update user if updated', () => { it('should update user if updated', () => {
const request: Partial<UpsertUserDto> = {};
const updated = createUser(2, '_new'); const updated = createUser(2, '_new');
const request = new UpdateUserDto({ ...updated });
usersService.setup(x => x.putUser(user2, request)) usersService.setup(x => x.putUser(user2, request))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -187,10 +186,10 @@ describe('UsersState', () => {
}); });
it('should truncate users if page size reached', () => { it('should truncate users if page size reached', () => {
const request: UpsertUserDto = { ...newUser, password: 'password' } as any; const request = new CreateUserDto({ ...newUser, password: 'password' });
usersService.setup(x => x.getUsers(2, 0, undefined)) usersService.setup(x => x.getUsers(2, 0, undefined))
.returns(() => of({ items: [user1, user2], total: 200 })).verifiable(); .returns(() => of(new UsersDto({ items: [user1, user2], total: 200, _links: {} }))).verifiable();
usersService.setup(x => x.postUser(request)) usersService.setup(x => x.postUser(request))
.returns(() => of(newUser)).verifiable(); .returns(() => of(newUser)).verifiable();
@ -205,8 +204,8 @@ describe('UsersState', () => {
describe('Selection', () => { describe('Selection', () => {
beforeEach(() => { beforeEach(() => {
usersService.setup(x => x.getUsers(10, 0, undefined)) usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of({ items: [user1, user2], total: 200 })).verifiable(Times.atLeastOnce()); .returns(() => of(new UsersDto({ items: [user1, user2], total: 200, _links: {} }))).verifiable(Times.atLeastOnce());
usersState.load().subscribe(); usersState.load().subscribe();
usersState.select(user2.id).subscribe(); usersState.select(user2.id).subscribe();
@ -219,7 +218,7 @@ describe('UsersState', () => {
]; ];
usersService.setup(x => x.getUsers(10, 0, undefined)) usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of({ items: newUsers, total: 200 })); .returns(() => of(new UsersDto({ items: newUsers, total: 200, _links: {} })));
usersState.load().subscribe(); usersState.load().subscribe();
@ -227,9 +226,8 @@ describe('UsersState', () => {
}); });
it('should update selected user if updated', () => { it('should update selected user if updated', () => {
const request = {};
const updated = createUser(2, '_new'); const updated = createUser(2, '_new');
const request = new UpdateUserDto({ ...updated });
usersService.setup(x => x.putUser(user2, request)) usersService.setup(x => x.putUser(user2, request))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();

10
frontend/src/app/features/administration/state/users.state.ts

@ -11,8 +11,8 @@ import { Injectable } from '@angular/core';
import '@app/framework/utils/rxjs-extensions'; import '@app/framework/utils/rxjs-extensions';
import { EMPTY, Observable, of } from 'rxjs'; import { EMPTY, Observable, of } from 'rxjs';
import { catchError, finalize, tap } from 'rxjs/operators'; import { catchError, finalize, tap } from 'rxjs/operators';
import { debug, DialogService, getPagingInfo, ListState, shareSubscribed, State } from '@app/shared'; import { CreateUserDto, debug, DialogService, getPagingInfo, ListState, shareSubscribed, State, UpdateUserDto, UserDto } from '@app/shared';
import { UpsertUserDto, UserDto, UsersService } from '../services/users.service'; import { UsersService } from '../services/users.service';
interface Snapshot extends ListState<string> { interface Snapshot extends ListState<string> {
// The current users. // The current users.
@ -104,7 +104,7 @@ export class UsersState extends State<Snapshot> {
pageSize, pageSize,
pageSize * page, pageSize * page,
query).pipe( query).pipe(
tap(({ total, items: users, canCreate }) => { tap(({ canCreate, items: users, total }) => {
if (isReload) { if (isReload) {
this.dialogs.notifyInfo('i18n:users.reloaded'); this.dialogs.notifyInfo('i18n:users.reloaded');
} }
@ -132,7 +132,7 @@ export class UsersState extends State<Snapshot> {
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
public create(request: UpsertUserDto): Observable<UserDto> { public create(request: CreateUserDto): Observable<UserDto> {
return this.usersService.postUser(request).pipe( return this.usersService.postUser(request).pipe(
tap(created => { tap(created => {
this.next(s => { this.next(s => {
@ -144,7 +144,7 @@ export class UsersState extends State<Snapshot> {
shareSubscribed(this.dialogs, { silent: true })); shareSubscribed(this.dialogs, { silent: true }));
} }
public update(user: UserDto, request: Partial<UpsertUserDto>): Observable<UserDto> { public update(user: UserDto, request: UpdateUserDto): Observable<UserDto> {
return this.usersService.putUser(user, request).pipe( return this.usersService.putUser(user, request).pipe(
tap(updated => { tap(updated => {
this.replaceUser(updated); this.replaceUser(updated);

1
frontend/src/app/features/assets/pages/asset-tag-dialog.component.ts

@ -52,7 +52,6 @@ export class AssetTagDialogComponent implements OnInit {
public renameAssetTag() { public renameAssetTag() {
const value = this.editForm.submit(); const value = this.editForm.submit();
if (!value) { if (!value) {
return; return;
} }

4
frontend/src/app/features/content/pages/calendar/calendar-page.component.ts

@ -9,7 +9,7 @@
import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { AppsState, ConfirmClickDirective, ContentDto, ContentsService, ContentStatusComponent, CopyDirective, DateTime, DialogModel, FullDateTimePipe, getContentValue, LanguageDto, LanguagesState, LayoutComponent, LocalizerService, ModalDialogComponent, ModalDirective, ResourceLoaderService, TitleComponent, TooltipDirective, TranslatePipe, UserNameRefPipe, UserPictureRefPipe } from '@app/shared'; import { AppLanguageDto, AppsState, ConfirmClickDirective, ContentDto, ContentsService, ContentStatusComponent, CopyDirective, DateTime, DialogModel, FullDateTimePipe, getContentValue, LanguagesState, LayoutComponent, LocalizerService, ModalDialogComponent, ModalDirective, ResourceLoaderService, TitleComponent, TooltipDirective, TranslatePipe, UserNameRefPipe, UserPictureRefPipe } from '@app/shared';
declare const tui: any; declare const tui: any;
@ -39,7 +39,7 @@ type ViewMode = 'day' | 'week' | 'month';
}) })
export class CalendarPageComponent implements AfterViewInit, OnDestroy, OnInit { export class CalendarPageComponent implements AfterViewInit, OnDestroy, OnInit {
private calendar: any; private calendar: any;
private language!: LanguageDto; private language!: AppLanguageDto;
@ViewChild('calendarContainer', { static: false }) @ViewChild('calendarContainer', { static: false })
public calendarContainer!: ElementRef; public calendarContainer!: ElementRef;

6
frontend/src/app/features/content/pages/content/content-event.component.ts

@ -42,9 +42,9 @@ export class ContentEventComponent {
public ngOnChanges(changes: TypedSimpleChanges<this>) { public ngOnChanges(changes: TypedSimpleChanges<this>) {
if (changes.event) { if (changes.event) {
this.canLoadOrCompare = this.canLoadOrCompare =
(this.event.eventType === 'ContentUpdatedEvent' || (this.event.eventType === 'ContentUpdatedEvent' ||
this.event.eventType === 'ContentCreatedEventV2') && this.event.eventType === 'ContentCreatedEventV2') &&
!this.event.version.eq(this.content.version); this.event.version !== this.content.version;
} }
} }
} }

5
frontend/src/app/features/content/pages/content/content-page.component.ts

@ -252,7 +252,6 @@ export class ContentPageComponent implements CanComponentDeactivate, OnInit {
private saveContent(publish: boolean, navigationMode: SaveNavigationMode) { private saveContent(publish: boolean, navigationMode: SaveNavigationMode) {
const value = this.contentForm.submit(); const value = this.contentForm.submit();
if (!value) { if (!value) {
this.contentForm.submitFailed('i18n:contents.contentNotValid', false); this.contentForm.submitFailed('i18n:contents.contentNotValid', false);
return; return;
@ -365,10 +364,10 @@ export class ContentPageComponent implements CanComponentDeactivate, OnInit {
this.loadVersion(null, false); this.loadVersion(null, false);
} }
public loadVersion(version: Version | null, compare: boolean) { public loadVersion(version: number | null, compare: boolean) {
const content = this.content; const content = this.content;
if (!content || version === null || version.eq(content.version)) { if (!content || version === null || version != content.version) {
this.contentFormCompare = null; this.contentFormCompare = null;
this.contentVersion = null; this.contentVersion = null;
this.loadContent(content?.data || {}, true); this.loadContent(content?.data || {}, true);

4
frontend/src/app/features/content/pages/contents/contents-page.component.ts

@ -13,7 +13,7 @@ import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { distinctUntilChanged, map, shareReplay, switchMap, take, tap } from 'rxjs/operators'; import { distinctUntilChanged, map, shareReplay, switchMap, take, tap } from 'rxjs/operators';
import { AppLanguageDto, AppsState, ConfirmClickDirective, ContentDto, ContentListCellDirective, ContentListCellResizeDirective, ContentListHeaderComponent, ContentListWidthDirective, ContentsService, ContentsState, ContentStatusComponent, contentsTranslationStatus, ContributorsState, defined, DropdownMenuComponent, getTableConfig, KeysPipe, LanguageSelectorComponent, LanguagesState, LayoutComponent, ListViewComponent, LocalStoreService, ModalDirective, ModalModel, ModalPlacementDirective, NotifoComponent, PagerComponent, Queries, Query, QuerySynchronizer, Router2State, SchemaDto, SchemasService, SchemasState, SearchFormComponent, Settings, ShortcutDirective, SidebarMenuDirective, Subscriptions, switchSafe, SyncWidthDirective, TableSettings, TempService, TitleComponent, TooltipDirective, TourStepDirective, TranslatePipe, TranslationStatus, UIState } from '@app/shared'; import { AppLanguageDto, AppsState, ConfirmClickDirective, ContentDto, ContentListCellDirective, ContentListCellResizeDirective, ContentListHeaderComponent, ContentListWidthDirective, ContentsService, ContentsState, ContentStatusComponent, contentsTranslationStatus, ContributorsState, defined, DropdownMenuComponent, getTableConfig, KeysPipe, LanguageSelectorComponent, LanguagesState, LayoutComponent, ListViewComponent, LocalStoreService, ModalDirective, ModalModel, ModalPlacementDirective, NotifoComponent, PagerComponent, Queries, Query, QuerySynchronizer, Router2State, SchemaDto, SchemasService, SchemasState, SearchFormComponent, Settings, ShortcutDirective, SidebarMenuDirective, Subscriptions, switchSafe, SyncWidthDirective, TableSettings, TempService, TitleComponent, TooltipDirective, TourStepDirective, TranslatePipe, TranslationStatuses, UIState } from '@app/shared';
import { DueTimeSelectorComponent } from '../../shared/due-time-selector.component'; import { DueTimeSelectorComponent } from '../../shared/due-time-selector.component';
import { ContentComponent } from '../../shared/list/content.component'; import { ContentComponent } from '../../shared/list/content.component';
import { CustomViewEditorComponent } from './custom-view-editor.component'; import { CustomViewEditorComponent } from './custom-view-editor.component';
@ -82,7 +82,7 @@ export class ContentsPageComponent implements OnInit {
public language!: AppLanguageDto; public language!: AppLanguageDto;
public languages!: ReadonlyArray<AppLanguageDto>; public languages!: ReadonlyArray<AppLanguageDto>;
public translationStatus?: TranslationStatus; public translationStatus?: TranslationStatuses;
public get disableScheduler() { public get disableScheduler() {
return this.appsState.snapshot.selectedSettings?.hideScheduler === true; return this.appsState.snapshot.selectedSettings?.hideScheduler === true;

12
frontend/src/app/features/content/shared/due-time-selector.component.ts

@ -8,7 +8,7 @@
import { booleanAttribute, Component, Input } from '@angular/core'; import { booleanAttribute, Component, Input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Observable, of, Subject } from 'rxjs'; import { Observable, of, Subject } from 'rxjs';
import { DateTimeEditorComponent, DialogModel, FocusOnInitDirective, ModalDialogComponent, ModalDirective, TooltipDirective, TranslatePipe } from '@app/shared'; import { DateTime, DateTimeEditorComponent, DialogModel, FocusOnInitDirective, ModalDialogComponent, ModalDirective, TooltipDirective, TranslatePipe } from '@app/shared';
const OPTION_IMMEDIATELY = 'Immediately'; const OPTION_IMMEDIATELY = 'Immediately';
@ -28,7 +28,7 @@ const OPTION_IMMEDIATELY = 'Immediately';
], ],
}) })
export class DueTimeSelectorComponent { export class DueTimeSelectorComponent {
private dueTimeResult?: Subject<string | null>; private dueTimeResult?: Subject<DateTime | undefined>;
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
public disabled?: boolean | null; public disabled?: boolean | null;
@ -38,13 +38,13 @@ export class DueTimeSelectorComponent {
public dueTimeAction: string | null = ''; public dueTimeAction: string | null = '';
public dueTimeMode = OPTION_IMMEDIATELY; public dueTimeMode = OPTION_IMMEDIATELY;
public selectDueTime(action: string): Observable<string | null> { public selectDueTime(action: string): Observable<DateTime | undefined> {
if (this.disabled) { if (this.disabled) {
return of(null); return of(undefined);
} }
this.dueTimeAction = action; this.dueTimeAction = action;
this.dueTimeResult = new Subject<string | null>(); this.dueTimeResult = new Subject<DateTime | undefined>();
this.dueTimeDialog.show(); this.dueTimeDialog.show();
return this.dueTimeResult; return this.dueTimeResult;
@ -53,7 +53,7 @@ export class DueTimeSelectorComponent {
public confirmStatusChange() { public confirmStatusChange() {
const result = this.dueTimeMode === OPTION_IMMEDIATELY ? null : this.dueTime; const result = this.dueTimeMode === OPTION_IMMEDIATELY ? null : this.dueTime;
this.dueTimeResult?.next(result); this.dueTimeResult?.next(result ? DateTime.parseISO(result) : undefined);
this.dueTimeResult?.complete(); this.dueTimeResult?.complete();
this.cancelStatusChange(); this.cancelStatusChange();

2
frontend/src/app/features/content/shared/forms/array-editor.component.ts

@ -89,7 +89,7 @@ export class ArrayEditorComponent {
public ngOnChanges(changes: TypedSimpleChanges<this>) { public ngOnChanges(changes: TypedSimpleChanges<this>) {
if (changes.formModel) { if (changes.formModel) {
const maxItems = (this.formModel.field.properties as any)['maxItems'] || Number.MAX_VALUE; const maxItems = this.formModel.field.rawProperties['maxItems'] || Number.MAX_VALUE;
if (Types.is(this.formModel.field.properties, ComponentsFieldPropertiesDto)) { if (Types.is(this.formModel.field.properties, ComponentsFieldPropertiesDto)) {
this.schemasList = this.formModel.field.properties.schemaIds?.map(x => this.formModel.globals.schemas[x]).defined().sortedByString(x => x.displayName) || []; this.schemasList = this.formModel.field.properties.schemaIds?.map(x => this.formModel.globals.schemas[x]).defined().sortedByString(x => x.displayName) || [];

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

@ -9,7 +9,7 @@ import { AsyncPipe } from '@angular/common';
import { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, Input, numberAttribute, Output, QueryList, ViewChildren } from '@angular/core'; import { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, Input, numberAttribute, Output, QueryList, ViewChildren } from '@angular/core';
import { Observable, of } from 'rxjs'; import { Observable, of } from 'rxjs';
import { map } from 'rxjs/operators'; import { map } from 'rxjs/operators';
import { AppLanguageDto, ComponentForm, EditContentForm, FieldDto, FieldFormatter, FormHintComponent, IfOnceDirective, invalid$, ObjectFormBase, RootFieldDto, TooltipDirective, TranslatePipe, TypedSimpleChanges, Types, valueProjection$ } from '@app/shared'; import { AppLanguageDto, ComponentForm, EditContentForm, FieldDto, FieldFormatter, FormHintComponent, IfOnceDirective, invalid$, NestedFieldDto, ObjectFormBase, TooltipDirective, TranslatePipe, TypedSimpleChanges, Types, valueProjection$ } from '@app/shared';
import { ComponentSectionComponent } from './component-section.component'; import { ComponentSectionComponent } from './component-section.component';
@Component({ @Component({
@ -152,7 +152,7 @@ function getTitle(formModel: ObjectFormBase) {
let valueLength = 0; let valueLength = 0;
function addFields(fields: ReadonlyArray<FieldDto>) { function addFields(fields: ReadonlyArray<FieldDto | NestedFieldDto>) {
for (const field of fields) { for (const field of fields) {
const fieldValue = value[field.name]; const fieldValue = value[field.name];
@ -178,7 +178,7 @@ function getTitle(formModel: ObjectFormBase) {
valueLength += formatted.length; valueLength += formatted.length;
addFields(formModel.schema.fields); addFields(formModel.schema.fields);
} else if (Types.is(formModel.field, RootFieldDto)) { } else if (Types.is(formModel.field, FieldDto) && formModel.field.nested) {
addFields(formModel.field.nested); addFields(formModel.field.nested);
} }

8
frontend/src/app/features/content/shared/forms/content-field.component.ts

@ -8,7 +8,7 @@
import { AsyncPipe } from '@angular/common'; import { AsyncPipe } from '@angular/common';
import { booleanAttribute, Component, EventEmitter, HostBinding, inject, Input, numberAttribute, Optional, Output } from '@angular/core'; import { booleanAttribute, Component, EventEmitter, HostBinding, inject, Input, numberAttribute, Optional, Output } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AppLanguageDto, AppsState, changed$, CommentsState, disabled$, EditContentForm, FieldForm, FocusMarkerComponent, invalid$, LocalStoreService, SchemaDto, Settings, TooltipDirective, TranslationsService, TypedSimpleChanges, UIOptions } from '@app/shared'; import { AppLanguageDto, AppsState, changed$, CommentsState, disabled$, EditContentForm, FieldForm, FocusMarkerComponent, invalid$, LocalStoreService, SchemaDto, Settings, TooltipDirective, TranslateDto, TranslationsService, TypedSimpleChanges, UIOptions } from '@app/shared';
import { FieldCopyButtonComponent } from './field-copy-button.component'; import { FieldCopyButtonComponent } from './field-copy-button.component';
import { FieldEditorComponent } from './field-editor.component'; import { FieldEditorComponent } from './field-editor.component';
import { FieldLanguagesComponent } from './field-languages.component'; import { FieldLanguagesComponent } from './field-languages.component';
@ -158,7 +158,11 @@ export class ContentFieldComponent {
return; return;
} }
const request = { text, sourceLanguage, targetLanguage }; const request = new TranslateDto({
sourceLanguage,
text,
targetLanguage,
});
this.translations.translate(this.appsState.appName, request) this.translations.translate(this.appsState.appName, request)
.subscribe(result => { .subscribe(result => {

4
frontend/src/app/features/content/shared/forms/content-section.component.ts

@ -7,7 +7,7 @@
import { AsyncPipe } from '@angular/common'; import { AsyncPipe } from '@angular/common';
import { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, forwardRef, Input, numberAttribute, Output } from '@angular/core'; import { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, forwardRef, Input, numberAttribute, Output } from '@angular/core';
import { AppLanguageDto, EditContentForm, FieldForm, FieldSection, FormHintComponent, LocalStoreService, MarkdownDirective, RootFieldDto, SchemaDto, Settings, StatefulComponent, TypedSimpleChanges } from '@app/shared'; import { AppLanguageDto, EditContentForm, FieldForm, FieldSection, FormHintComponent, LocalStoreService, MarkdownDirective, FieldDto, SchemaDto, Settings, StatefulComponent, TypedSimpleChanges } from '@app/shared';
import { ContentFieldComponent } from './content-field.component'; import { ContentFieldComponent } from './content-field.component';
interface State { interface State {
@ -48,7 +48,7 @@ export class ContentSectionComponent extends StatefulComponent<State> {
public formContext!: any; public formContext!: any;
@Input({ required: true }) @Input({ required: true })
public formSection!: FieldSection<RootFieldDto, FieldForm>; public formSection!: FieldSection<FieldDto, FieldForm>;
@Input({ required: true }) @Input({ required: true })
public schema!: SchemaDto; public schema!: SchemaDto;

21
frontend/src/app/features/content/shared/forms/field-editor.component.ts

@ -9,7 +9,7 @@ import { AsyncPipe } from '@angular/common';
import { booleanAttribute, Component, ElementRef, EventEmitter, Input, numberAttribute, Output, ViewChild } from '@angular/core'; import { booleanAttribute, Component, ElementRef, EventEmitter, Input, numberAttribute, Output, ViewChild } from '@angular/core';
import { AbstractControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { AbstractControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AbstractContentForm, AnnotationCreate, AnnotationsSelect, AppLanguageDto, ChatDialogComponent, CheckboxGroupComponent, CodeEditorComponent, ColorPickerComponent, CommentsState, ConfirmClickDirective, ControlErrorsComponent, DateTimeEditorComponent, DialogModel, disabled$, EditContentForm, FieldDto, FormHintComponent, GeolocationEditorComponent, hasNoValue$, HTTP, IndeterminateValueDirective, MarkdownDirective, MathHelper, MessageBus, ModalDirective, RadioGroupComponent, ReferenceInputComponent, RichEditorComponent, StarsComponent, TagEditorComponent, ToggleComponent, TooltipDirective, TransformInputDirective, TypedSimpleChanges, Types } from '@app/shared'; import { AbstractContentForm, AnnotationCreate, AnnotationsSelect, AnyFieldDto, AppLanguageDto, ChatDialogComponent, CheckboxGroupComponent, CodeEditorComponent, ColorPickerComponent, CommentsState, ConfirmClickDirective, ControlErrorsComponent, DateTimeEditorComponent, DialogModel, disabled$, EditContentForm, FormHintComponent, GeolocationEditorComponent, hasNoValue$, HTTP, IndeterminateValueDirective, MarkdownDirective, MathHelper, MessageBus, ModalDirective, RadioGroupComponent, ReferenceInputComponent, RichEditorComponent, StarsComponent, TagEditorComponent, ToggleComponent, TooltipDirective, TransformInputDirective, TypedSimpleChanges, Types } from '@app/shared';
import { ReferenceDropdownComponent } from '../references/reference-dropdown.component'; import { ReferenceDropdownComponent } from '../references/reference-dropdown.component';
import { ReferencesCheckboxesComponent } from '../references/references-checkboxes.component'; import { ReferencesCheckboxesComponent } from '../references/references-checkboxes.component';
import { ReferencesEditorComponent } from '../references/references-editor.component'; import { ReferencesEditorComponent } from '../references/references-editor.component';
@ -84,7 +84,7 @@ export class FieldEditorComponent {
public formLevel!: number; public formLevel!: number;
@Input({ required: true }) @Input({ required: true })
public formModel!: AbstractContentForm<FieldDto, AbstractControl>; public formModel!: AbstractContentForm<AnyFieldDto, AbstractControl>;
@Input({ required: true }) @Input({ required: true })
public language!: AppLanguageDto; public language!: AppLanguageDto;
@ -142,17 +142,18 @@ export class FieldEditorComponent {
public reset() { public reset() {
const editor = this.editor as any; const editor = this.editor as any;
if (!editor) {
return;
}
if (editor) { const nativeElement = this.editor.nativeElement;
const nativeElement = this.editor.nativeElement;
if (nativeElement && Types.isFunction(nativeElement['reset'])) { if (nativeElement && Types.isFunction(nativeElement['reset'])) {
nativeElement['reset'](); nativeElement['reset']();
} }
if (this.editor && Types.isFunction(editor['reset'])) { if (this.editor && Types.isFunction(editor['reset'])) {
editor['reset'](); editor['reset']();
}
} }
} }

27
frontend/src/app/features/content/shared/list/content.component.ts

@ -111,22 +111,23 @@ export class ContentComponent {
} }
const value = this.patchForm.submit(); const value = this.patchForm.submit();
if (!value) {
return;
}
if (value) { this.contentsState.patch(this.content, value)
this.contentsState.patch(this.content, value) .subscribe({
.subscribe({ next: () => {
next: () => { this.patchForm!.submitCompleted({ noReset: true });
this.patchForm!.submitCompleted({ noReset: true });
this.changeDetector.detectChanges(); this.changeDetector.detectChanges();
}, },
error: error => { error: error => {
this.patchForm!.submitFailed(error); this.patchForm!.submitFailed(error);
this.changeDetector.detectChanges(); this.changeDetector.detectChanges();
}, },
}); });
}
} }
public shouldStop(field: TableField) { public shouldStop(field: TableField) {

36
frontend/src/app/features/content/shared/references/content-creator.component.ts

@ -106,26 +106,26 @@ export class ContentCreatorComponent implements OnInit {
private saveContent(publish: boolean) { private saveContent(publish: boolean) {
const value = this.contentForm.submit(); const value = this.contentForm.submit();
if (!value) {
if (value) {
if (!this.canCreate(publish)) {
return;
}
this.contentsState.create(value, publish)
.subscribe({
next: content => {
this.contentForm.submitCompleted({ noReset: true });
this.emitSelect(content);
},
error: error => {
this.contentForm.submitFailed(error);
},
});
} else {
this.contentForm.submitFailed('i18n:contents.contentNotValid'); this.contentForm.submitFailed('i18n:contents.contentNotValid');
return;
} }
if (!this.canCreate(publish)) {
return;
}
this.contentsState.create(value, publish)
.subscribe({
next: content => {
this.contentForm.submitCompleted({ noReset: true });
this.emitSelect(content);
},
error: error => {
this.contentForm.submitFailed(error);
},
});
} }
private canCreate(publish: boolean) { private canCreate(publish: boolean) {

6
frontend/src/app/features/content/shared/references/reference-dropdown.component.ts

@ -8,7 +8,7 @@
import { booleanAttribute, ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'; import { booleanAttribute, ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core';
import { FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; import { FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { ContentDto, ContentsDto, DropdownComponent, getContentValue, HighlightPipe, LanguageDto, LocalizerService, ResolveContents, SafeHtmlPipe, StatefulControlComponent, Subscriptions, TypedSimpleChanges, Types, value$ } from '@app/shared'; import { AppLanguageDto, ContentDto, ContentsDto, DropdownComponent, getContentValue, HighlightPipe, LocalizerService, ResolveContents, SafeHtmlPipe, StatefulControlComponent, Subscriptions, TypedSimpleChanges, Types, value$ } from '@app/shared';
export const SQX_REFERENCE_DROPDOWN_CONTROL_VALUE_ACCESSOR: any = { export const SQX_REFERENCE_DROPDOWN_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ReferenceDropdownComponent), multi: true, provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ReferenceDropdownComponent), multi: true,
@ -53,10 +53,10 @@ export class ReferenceDropdownComponent extends StatefulControlComponent<State,
private isLoadingFailed = false; private isLoadingFailed = false;
@Input({ required: true }) @Input({ required: true })
public language!: LanguageDto; public language!: AppLanguageDto;
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ required: true }) @Input({ required: true })
public schemaId!: string; public schemaId!: string;

6
frontend/src/app/features/content/shared/references/reference-item.component.ts

@ -10,7 +10,7 @@
import { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, Input, numberAttribute, Output } from '@angular/core'; import { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, Input, numberAttribute, Output } from '@angular/core';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { ConfirmClickDirective, ContentDto, ContentListCellDirective, ContentListFieldComponent, ContentValueComponent, getContentValue, LanguageDto, META_FIELDS, TooltipDirective, TranslatePipe } from '@app/shared'; import { AppLanguageDto, ConfirmClickDirective, ContentDto, ContentListCellDirective, ContentListFieldComponent, ContentValueComponent, getContentValue, META_FIELDS, TooltipDirective, TranslatePipe } from '@app/shared';
@Component({ @Component({
standalone: true, standalone: true,
@ -38,10 +38,10 @@ export class ReferenceItemComponent {
public clone = new EventEmitter(); public clone = new EventEmitter();
@Input() @Input()
public language!: LanguageDto; public language!: AppLanguageDto;
@Input() @Input()
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
public canRemove?: boolean | null = true; public canRemove?: boolean | null = true;

4
frontend/src/app/features/content/shared/references/references-checkboxes.component.ts

@ -7,7 +7,7 @@
import { booleanAttribute, ChangeDetectionStrategy, Component, forwardRef, inject, Input } from '@angular/core'; import { booleanAttribute, ChangeDetectionStrategy, Component, forwardRef, inject, Input } from '@angular/core';
import { FormControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; import { FormControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { AppsState, CheckboxGroupComponent, ContentDto, ContentsService, LanguageDto, LocalizerService, StatefulControlComponent, Subscriptions, TypedSimpleChanges, UIOptions } from '@app/shared'; import { AppLanguageDto, AppsState, CheckboxGroupComponent, ContentDto, ContentsService, LocalizerService, StatefulControlComponent, Subscriptions, TypedSimpleChanges, UIOptions } from '@app/shared';
import { ReferencesTagsConverter } from './references-tag-converter'; import { ReferencesTagsConverter } from './references-tag-converter';
export const SQX_REFERENCES_CHECKBOXES_CONTROL_VALUE_ACCESSOR: any = { export const SQX_REFERENCES_CHECKBOXES_CONTROL_VALUE_ACCESSOR: any = {
@ -45,7 +45,7 @@ export class ReferencesCheckboxesComponent extends StatefulControlComponent<Stat
public schemaId: string | undefined | null; public schemaId: string | undefined | null;
@Input({ required: true }) @Input({ required: true })
public language!: LanguageDto; public language!: AppLanguageDto;
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
public set disabled(value: boolean | undefined | null) { public set disabled(value: boolean | undefined | null) {

4
frontend/src/app/features/content/shared/references/references-radio-buttons.component.ts

@ -7,7 +7,7 @@
import { booleanAttribute, ChangeDetectionStrategy, Component, forwardRef, inject, Input } from '@angular/core'; import { booleanAttribute, ChangeDetectionStrategy, Component, forwardRef, inject, Input } from '@angular/core';
import { FormControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; import { FormControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms';
import { AppsState, ContentDto, ContentsService, LanguageDto, LocalizerService, RadioGroupComponent, StatefulControlComponent, Subscriptions, TypedSimpleChanges, UIOptions } from '@app/shared'; import { AppLanguageDto, AppsState, ContentDto, ContentsService, LocalizerService, RadioGroupComponent, StatefulControlComponent, Subscriptions, TypedSimpleChanges, UIOptions } from '@app/shared';
import { ReferencesTagsConverter } from './references-tag-converter'; import { ReferencesTagsConverter } from './references-tag-converter';
export const SQX_REFERENCES_RADIO_BUTTONS_CONTROL_VALUE_ACCESSOR: any = { export const SQX_REFERENCES_RADIO_BUTTONS_CONTROL_VALUE_ACCESSOR: any = {
@ -45,7 +45,7 @@ export class ReferencesRadioButtonsComponent extends StatefulControlComponent<St
public schemaId: string | undefined | null; public schemaId: string | undefined | null;
@Input({ required: true }) @Input({ required: true })
public language!: LanguageDto; public language!: AppLanguageDto;
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
public set disabled(value: boolean | undefined | null) { public set disabled(value: boolean | undefined | null) {

6
frontend/src/app/features/content/shared/references/references-tag-converter.ts

@ -5,12 +5,12 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { ContentDto, getContentValue, LanguageDto, LocalizerService, TagConverter, TagValue } from '@app/shared/internal'; import { AppLanguageDto, ContentDto, getContentValue, LocalizerService, TagConverter, TagValue } from '@app/shared/internal';
export class ReferencesTagsConverter implements TagConverter { export class ReferencesTagsConverter implements TagConverter {
public tags: ReadonlyArray<TagValue> = []; public tags: ReadonlyArray<TagValue> = [];
constructor(language: LanguageDto, contents: ReadonlyArray<ContentDto>, constructor(language: AppLanguageDto, contents: ReadonlyArray<ContentDto>,
private readonly localizer: LocalizerService, private readonly localizer: LocalizerService,
) { ) {
this.tags = this.createTags(language, contents); this.tags = this.createTags(language, contents);
@ -28,7 +28,7 @@ export class ReferencesTagsConverter implements TagConverter {
return result || null; return result || null;
} }
private createTags(language: LanguageDto, contents: ReadonlyArray<ContentDto>): ReadonlyArray<TagValue> { private createTags(language: AppLanguageDto, contents: ReadonlyArray<ContentDto>): ReadonlyArray<TagValue> {
if (contents.length === 0) { if (contents.length === 0) {
return []; return [];
} }

6
frontend/src/app/features/content/shared/references/references-tags.component.ts

@ -8,7 +8,7 @@
import { booleanAttribute, ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core'; import { booleanAttribute, ChangeDetectionStrategy, Component, forwardRef, Input } from '@angular/core';
import { FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; import { FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule, UntypedFormControl } from '@angular/forms';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { ContentDto, ContentsDto, LanguageDto, LocalizerService, ResolveContents, StatefulControlComponent, Subscriptions, TagEditorComponent, TranslatePipe, TypedSimpleChanges, Types } from '@app/shared'; import { AppLanguageDto, ContentDto, ContentsDto, LocalizerService, ResolveContents, StatefulControlComponent, Subscriptions, TagEditorComponent, TranslatePipe, TypedSimpleChanges, Types } from '@app/shared';
import { ReferencesTagsConverter } from './references-tag-converter'; import { ReferencesTagsConverter } from './references-tag-converter';
export const SQX_REFERENCES_TAGS_CONTROL_VALUE_ACCESSOR: any = { export const SQX_REFERENCES_TAGS_CONTROL_VALUE_ACCESSOR: any = {
@ -51,10 +51,10 @@ export class ReferencesTagsComponent extends StatefulControlComponent<State, Rea
public schemaId!: string; public schemaId!: string;
@Input({ required: true }) @Input({ required: true })
public language!: LanguageDto; public language!: AppLanguageDto;
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
public set disabled(value: boolean | undefined | null) { public set disabled(value: boolean | undefined | null) {

4
frontend/src/app/features/rules/pages/rule/rule-page.component.html

@ -101,7 +101,7 @@
[triggerForm]="currentTrigger"></sqx-content-changed-trigger> [triggerForm]="currentTrigger"></sqx-content-changed-trigger>
} }
@case ("SchemaChanged") { @case ("SchemaChanged") {
<sqx-schema-changed-trigger [triggerForm]="currentTrigger.form"></sqx-schema-changed-trigger> <sqx-schema-changed-trigger [triggerForm]="currentTrigger"></sqx-schema-changed-trigger>
} }
@case ("Usage") { @case ("Usage") {
<sqx-usage-trigger [triggerForm]="currentTrigger"></sqx-usage-trigger> <sqx-usage-trigger [triggerForm]="currentTrigger"></sqx-usage-trigger>
@ -162,7 +162,7 @@
<sqx-generic-action <sqx-generic-action
[actionForm]="currentAction" [actionForm]="currentAction"
[appName]="rulesState.appName" [appName]="rulesState.appName"
[trigger]="currentTrigger?.form.value" [trigger]="currentTrigger?.form?.value || {}"
[triggerType]="currentTrigger?.triggerType"></sqx-generic-action> [triggerType]="currentTrigger?.triggerType"></sqx-generic-action>
} @else { } @else {
<div class="row g-0"> <div class="row g-0">

24
frontend/src/app/features/rules/pages/rule/rule-page.component.ts

@ -10,7 +10,7 @@ import { Component, OnInit } from '@angular/core';
import { AbstractControl, FormsModule } from '@angular/forms'; import { AbstractControl, FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { debounceTime, Subscription } from 'rxjs'; import { debounceTime, Subscription } from 'rxjs';
import { ActionForm, ALL_TRIGGERS, ConfirmClickDirective, FormAlertComponent, KeysPipe, LayoutComponent, ListViewComponent, MessageBus, RuleDto, RuleElementDto, RulesService, RulesState, SchemasState, SidebarMenuDirective, Subscriptions, TitleComponent, ToggleComponent, TooltipDirective, TourHintDirective, TourStepDirective, TranslatePipe, TriggerForm, TriggerType, value$ } from '@app/shared'; import { ActionForm, ALL_TRIGGERS, ConfirmClickDirective, DynamicCreateRuleDto, DynamicRuleDto, DynamicUpdateRuleDto, FormAlertComponent, KeysPipe, LayoutComponent, ListViewComponent, MessageBus, RuleElementDto, RulesService, RulesState, SchemasState, SidebarMenuDirective, Subscriptions, TitleComponent, ToggleComponent, TooltipDirective, TourHintDirective, TourStepDirective, TranslatePipe, TriggerForm, value$ } from '@app/shared';
import { GenericActionComponent } from '../../shared/actions/generic-action.component'; import { GenericActionComponent } from '../../shared/actions/generic-action.component';
import { RuleElementComponent } from '../../shared/rule-element.component'; import { RuleElementComponent } from '../../shared/rule-element.component';
import { AssetChangedTriggerComponent } from '../../shared/triggers/asset-changed-trigger.component'; import { AssetChangedTriggerComponent } from '../../shared/triggers/asset-changed-trigger.component';
@ -60,7 +60,7 @@ export class RulePageComponent implements OnInit {
public supportedTriggers = ALL_TRIGGERS; public supportedTriggers = ALL_TRIGGERS;
public supportedActions: { [name: string]: RuleElementDto } = {}; public supportedActions: { [name: string]: RuleElementDto } = {};
public rule?: RuleDto | null; public rule?: DynamicRuleDto | null;
public currentTrigger?: TriggerForm; public currentTrigger?: TriggerForm;
public currentAction?: ActionForm; public currentAction?: ActionForm;
@ -69,7 +69,7 @@ export class RulePageComponent implements OnInit {
public isEditable = false; public isEditable = false;
public get isManual() { public get isManual() {
return this.rule?.triggerType === 'Manual'; return this.rule?.trigger.triggerType === 'Manual';
} }
public get actionElement() { public get actionElement() {
@ -77,7 +77,7 @@ export class RulePageComponent implements OnInit {
} }
public get triggerElement() { public get triggerElement() {
return this.supportedTriggers[(this.currentTrigger?.triggerType || '') as TriggerType]; return this.supportedTriggers[this.currentTrigger!.triggerType];
} }
constructor( constructor(
@ -94,7 +94,6 @@ export class RulePageComponent implements OnInit {
this.rulesService.getActions() this.rulesService.getActions()
.subscribe(actions => { .subscribe(actions => {
this.supportedActions = actions; this.supportedActions = actions;
this.initFromRule(); this.initFromRule();
}); });
@ -102,7 +101,6 @@ export class RulePageComponent implements OnInit {
this.rulesState.selectedRule this.rulesState.selectedRule
.subscribe(rule => { .subscribe(rule => {
this.rule = rule; this.rule = rule;
this.initFromRule(); this.initFromRule();
})); }));
@ -114,8 +112,8 @@ export class RulePageComponent implements OnInit {
this.isEditable = this.rule.canUpdate; this.isEditable = this.rule.canUpdate;
this.isEnabled = this.rule.isEnabled; this.isEnabled = this.rule.isEnabled;
this.selectAction(this.rule.actionType, this.rule.action); this.selectAction(this.rule.action.actionType, this.rule.action);
this.selectTrigger(this.rule.triggerType, this.rule.trigger); this.selectTrigger(this.rule.trigger.triggerType, this.rule.trigger);
} else { } else {
this.isEditable = true; this.isEditable = true;
this.isEnabled = false; this.isEnabled = false;
@ -140,7 +138,7 @@ export class RulePageComponent implements OnInit {
} }
} }
public selectTrigger(type: TriggerType, values?: any) { public selectTrigger(type: string, values?: any) {
if (this.currentTrigger?.triggerType !== type) { if (this.currentTrigger?.triggerType !== type) {
this.currentTrigger = new TriggerForm(type); this.currentTrigger = new TriggerForm(type);
this.currentTrigger.setEnabled(this.isEditable); this.currentTrigger.setEnabled(this.isEditable);
@ -175,20 +173,18 @@ export class RulePageComponent implements OnInit {
} }
const action = this.currentAction.submit(); const action = this.currentAction.submit();
if (!action) { if (!action) {
return; return;
} }
const trigger = this.currentTrigger.submit(); const trigger = this.currentTrigger.submit();
if (!trigger || !action) { if (!trigger || !action) {
return; return;
} }
const request: any = { trigger, action, isEnabled: this.isEnabled };
if (this.rule) { if (this.rule) {
const request = new DynamicUpdateRuleDto({ trigger, action, isEnabled: this.isEnabled });
this.rulesState.update(this.rule, request) this.rulesState.update(this.rule, request)
.subscribe({ .subscribe({
next: () => { next: () => {
@ -199,6 +195,8 @@ export class RulePageComponent implements OnInit {
}, },
}); });
} else { } else {
const request = new DynamicCreateRuleDto({ trigger, action });
this.rulesState.create(request) this.rulesState.create(request)
.subscribe({ .subscribe({
next: rule => { next: rule => {

6
frontend/src/app/features/rules/pages/rules/rule.component.html

@ -89,7 +89,7 @@
<h3>{{ "rules.ruleSyntax.if" | sqxTranslate }}</h3> <h3>{{ "rules.ruleSyntax.if" | sqxTranslate }}</h3>
</div> </div>
<div class="col"> <div class="col">
<sqx-rule-element disabled="true" [element]="ruleTriggers[rule.triggerType]" [type]="rule.triggerType"></sqx-rule-element> <sqx-rule-element disabled="true" [element]="ruleTriggers[rule.trigger.triggerType]" [type]="rule.trigger.triggerType"></sqx-rule-element>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<h3>{{ "rules.ruleSyntax.then" | sqxTranslate }}</h3> <h3>{{ "rules.ruleSyntax.then" | sqxTranslate }}</h3>
@ -97,8 +97,8 @@
<div class="col"> <div class="col">
<sqx-rule-element <sqx-rule-element
disabled="true" disabled="true"
[element]="$any(ruleActions[rule.actionType])" [element]="$any(ruleActions[rule.action.actionType])"
[type]="rule.actionType"></sqx-rule-element> [type]="rule.action.actionType"></sqx-rule-element>
</div> </div>
<div class="col col-last text-end"> <div class="col col-last text-end">
@if (isManual) { @if (isManual) {

14
frontend/src/app/features/rules/pages/rules/rule.component.ts

@ -9,7 +9,7 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { ActionsDto, ConfirmClickDirective, DropdownMenuComponent, EditableTitleComponent, ModalDirective, ModalModel, ModalPlacementDirective, RuleDto, RulesState, ToggleComponent, TranslatePipe, TriggersDto } from '@app/shared'; import { ActionsDto, ConfirmClickDirective, DropdownMenuComponent, DynamicRuleDto, DynamicUpdateRuleDto, EditableTitleComponent, ModalDirective, ModalModel, ModalPlacementDirective, RulesState, ToggleComponent, TranslatePipe, TriggersDto } from '@app/shared';
import { RuleElementComponent } from '../../shared/rule-element.component'; import { RuleElementComponent } from '../../shared/rule-element.component';
@Component({ @Component({
@ -39,12 +39,12 @@ export class RuleComponent {
public ruleActions!: ActionsDto; public ruleActions!: ActionsDto;
@Input({ required: true }) @Input({ required: true })
public rule!: RuleDto; public rule!: DynamicRuleDto;
public dropdown = new ModalModel(); public dropdown = new ModalModel();
public get isManual() { public get isManual() {
return this.rule.triggerType === 'Manual'; return this.rule.trigger.triggerType === 'Manual';
} }
constructor( constructor(
@ -65,19 +65,19 @@ export class RuleComponent {
} }
public rename(name: string) { public rename(name: string) {
this.rulesState.update(this.rule, { name }); this.rulesState.update(this.rule, new DynamicUpdateRuleDto({ name }));
} }
public disable() { public disable() {
this.rulesState.update(this.rule, { isEnabled: false }); this.rulesState.update(this.rule, new DynamicUpdateRuleDto({ isEnabled: false }));
} }
public enable() { public enable() {
this.rulesState.update(this.rule, { isEnabled: true }); this.rulesState.update(this.rule, new DynamicUpdateRuleDto({ isEnabled: true }));
} }
public toggle() { public toggle() {
this.rulesState.update(this.rule, { isEnabled: !this.rule.isEnabled }); this.rulesState.update(this.rule, new DynamicUpdateRuleDto({ isEnabled: !this.rule.isEnabled }));
} }
public trigger() { public trigger() {

8
frontend/src/app/features/rules/pages/rules/rules-page.component.ts

@ -8,7 +8,7 @@
import { AsyncPipe } from '@angular/common'; import { AsyncPipe } from '@angular/common';
import { Component, OnInit } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { ALL_TRIGGERS, LayoutComponent, ListViewComponent, RuleDto, RuleElementDto, RulesService, RulesState, SchemasState, ShortcutDirective, SidebarMenuDirective, TitleComponent, TooltipDirective, TourHintDirective, TourStepDirective, TranslatePipe } from '@app/shared'; import { ALL_TRIGGERS, DynamicRuleDto, DynamicUpdateRuleDto, LayoutComponent, ListViewComponent, RuleElementDto, RulesService, RulesState, SchemasState, ShortcutDirective, SidebarMenuDirective, TitleComponent, TooltipDirective, TourHintDirective, TourStepDirective, TranslatePipe } from '@app/shared';
import { RuleComponent } from './rule.component'; import { RuleComponent } from './rule.component';
@Component({ @Component({
@ -63,11 +63,11 @@ export class RulesPageComponent implements OnInit {
this.rulesState.runCancel(); this.rulesState.runCancel();
} }
public delete(rule: RuleDto) { public delete(rule: DynamicRuleDto) {
this.rulesState.delete(rule); this.rulesState.delete(rule);
} }
public toggle(rule: RuleDto) { public toggle(rule: DynamicRuleDto) {
this.rulesState.update(rule, { isEnabled: !rule.isEnabled }); this.rulesState.update(rule, new DynamicUpdateRuleDto({ isEnabled: !rule.isEnabled }));
} }
} }

2
frontend/src/app/features/rules/shared/actions/generic-action.component.html

@ -31,7 +31,7 @@
<textarea class="form-control" id="{{ property.name }}" [formControlName]="property.name"></textarea> <textarea class="form-control" id="{{ property.name }}" [formControlName]="property.name"></textarea>
} }
} }
@case ("JavaScript") { @case ("Javascript") {
<sqx-code-editor <sqx-code-editor
[completion]="ruleCompletions | async" [completion]="ruleCompletions | async"
[formControlName]="property.name" [formControlName]="property.name"

23
frontend/src/app/features/schemas/pages/schema/common/schema-edit-form.component.ts

@ -51,17 +51,18 @@ export class SchemaEditFormComponent {
} }
const value = this.fieldForm.submit(); const value = this.fieldForm.submit();
if (!value) {
if (value) { return;
this.schemasState.update(this.schema, value)
.subscribe({
next: () => {
this.fieldForm.submitCompleted({ noReset: true });
},
error: error => {
this.fieldForm.submitFailed(error);
},
});
} }
this.schemasState.update(this.schema, value)
.subscribe({
next: () => {
this.fieldForm.submitCompleted({ noReset: true });
},
error: error => {
this.fieldForm.submitFailed(error);
},
});
} }
} }

23
frontend/src/app/features/schemas/pages/schema/export/schema-export-form.component.ts

@ -47,17 +47,18 @@ export class SchemaExportFormComponent {
} }
const value = this.synchronizeForm.submit(); const value = this.synchronizeForm.submit();
if (!value) {
if (value) { return;
this.schemasState.synchronize(this.schema, value)
.subscribe({
next: () => {
this.synchronizeForm.submitCompleted({ noReset: true });
},
error: error => {
this.synchronizeForm.submitFailed(error);
},
});
} }
this.schemasState.synchronize(this.schema, value)
.subscribe({
next: () => {
this.synchronizeForm.submitCompleted({ noReset: true });
},
error: error => {
this.synchronizeForm.submitFailed(error);
},
});
} }
} }

8
frontend/src/app/features/schemas/pages/schema/fields/field-group.component.ts

@ -8,7 +8,7 @@
import { CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList } from '@angular/cdk/drag-drop'; import { CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList } from '@angular/cdk/drag-drop';
import { booleanAttribute, Component, EventEmitter, Input, Output } from '@angular/core'; import { booleanAttribute, Component, EventEmitter, Input, Output } from '@angular/core';
import { AppSettingsDto, FieldDto, FieldGroup, LanguageDto, LocalStoreService, RootFieldDto, SchemaDto, Settings, StatefulComponent } from '@app/shared'; import { AppLanguageDto, AppSettingsDto, FieldDto, FieldGroup, LocalStoreService, SchemaDto, Settings, StatefulComponent } from '@app/shared';
import { FieldComponent } from './field.component'; import { FieldComponent } from './field.component';
interface State { interface State {
@ -33,10 +33,10 @@ export class FieldGroupComponent extends StatefulComponent<State> {
public sorted = new EventEmitter<CdkDragDrop<FieldDto[]>>(); public sorted = new EventEmitter<CdkDragDrop<FieldDto[]>>();
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<AppLanguageDto>;
@Input() @Input()
public parent?: RootFieldDto; public parent?: FieldDto;
@Input({ required: true }) @Input({ required: true })
public settings!: AppSettingsDto; public settings!: AppSettingsDto;
@ -56,7 +56,7 @@ export class FieldGroupComponent extends StatefulComponent<State> {
public trackByFieldFn: (_index: number, field: FieldDto) => any; public trackByFieldFn: (_index: number, field: FieldDto) => any;
public get hasAnyFields() { public get hasAnyFields() {
return this.parent ? this.parent.nested.length > 0 : this.schema.fields.length > 0; return this.parent ? this.parent.nested && this.parent.nested.length > 0 : this.schema.fields.length > 0;
} }
constructor( constructor(

2
frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html

@ -11,7 +11,7 @@
@if (editForm) { @if (editForm) {
<form class="edit-form" [formGroup]="editForm.form" (ngSubmit)="save('Close')"> <form class="edit-form" [formGroup]="editForm.form" (ngSubmit)="save('Close')">
<sqx-field-form <sqx-field-form
[field]="field" [field]="editField"
[fieldForm]="editForm.form" [fieldForm]="editForm.form"
isEditable="true" isEditable="true"
[isLocalizable]="isLocalizable" [isLocalizable]="isLocalizable"

21
frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.ts

@ -8,7 +8,7 @@
import { AsyncPipe } from '@angular/common'; import { AsyncPipe } from '@angular/common';
import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'; import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AddFieldForm, AppSettingsDto, ControlErrorsComponent, createProperties, DropdownMenuComponent, EditFieldForm, FieldDto, fieldTypes, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, LanguagesState, ModalDialogComponent, ModalDirective, ModalModel, ModalPlacementDirective, RootFieldDto, SchemaDto, SchemasState, TooltipDirective, TranslatePipe, Types } from '@app/shared'; import { AddFieldForm, AppSettingsDto, ControlErrorsComponent, createProperties, DropdownMenuComponent, EditFieldForm, FieldDto, fieldTypes, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, LanguagesState, ModalDialogComponent, ModalDirective, ModalModel, ModalPlacementDirective, SchemaDto, SchemasState, TooltipDirective, TranslatePipe, Types, UpdateFieldDto } from '@app/shared';
import { FieldFormComponent } from './forms/field-form.component'; import { FieldFormComponent } from './forms/field-form.component';
@ -48,21 +48,21 @@ export class FieldWizardComponent implements OnInit {
public settings!: AppSettingsDto; public settings!: AppSettingsDto;
@Input() @Input()
public parent: RootFieldDto | null | undefined; public parent: FieldDto | null | undefined;
@Output() @Output()
public dialogClose = new EventEmitter(); public dialogClose = new EventEmitter();
public fieldTypes = fieldTypes; public fieldTypes = fieldTypes;
public field!: FieldDto;
public addFieldForm = new AddFieldForm(); public addFieldForm = new AddFieldForm();
public addFieldModal = new ModalModel(); public addFieldModal = new ModalModel();
public editField!: FieldDto;
public editForm?: EditFieldForm; public editForm?: EditFieldForm;
public get isLocalizable() { public get isLocalizable() {
return (this.parent && this.parent.isLocalizable) || (this.field as any)['isLocalizable']; return (this.parent && this.parent.isLocalizable) || (this.editField as any)['isLocalizable'];
} }
constructor( constructor(
@ -83,7 +83,6 @@ export class FieldWizardComponent implements OnInit {
public addField(navigationMode: SaveNavigationMode) { public addField(navigationMode: SaveNavigationMode) {
const value = this.addFieldForm.submit(); const value = this.addFieldForm.submit();
if (!value) { if (!value) {
return; return;
} }
@ -102,10 +101,9 @@ export class FieldWizardComponent implements OnInit {
break; break;
case 'Edit': case 'Edit':
this.field = dto; this.editField = dto as any;
this.editForm = new EditFieldForm(this.editField.properties);
this.editForm = new EditFieldForm(this.field.properties); this.editForm.load(this.editField.properties);
this.editForm.load(this.field.properties);
break; break;
case 'Close': case 'Close':
@ -124,14 +122,13 @@ export class FieldWizardComponent implements OnInit {
} }
const value = this.editForm.submit(); const value = this.editForm.submit();
if (!value) { if (!value) {
return; return;
} }
const properties = createProperties(this.field.properties.fieldType, value); const properties = createProperties(this.editField.properties.fieldType as any, value);
this.schemasState.updateField(this.schema, this.field as RootFieldDto, { properties }) this.schemasState.updateField(this.schema, this.editField, new UpdateFieldDto({ properties }))
.subscribe({ .subscribe({
next: () => { next: () => {
switch (navigationMode) { switch (navigationMode) {

35
frontend/src/app/features/schemas/pages/schema/fields/field.component.ts

@ -8,7 +8,7 @@
import { booleanAttribute, Component, forwardRef, Input } from '@angular/core'; import { booleanAttribute, Component, forwardRef, Input } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AppSettingsDto, ConfirmClickDirective, createProperties, DialogModel, DropdownMenuComponent, EditFieldForm, FieldDto, LanguageDto, ModalDirective, ModalModel, ModalPlacementDirective, NestedFieldDto, RootFieldDto, SchemaDto, SchemasState, TooltipDirective, TourStepDirective, TranslatePipe, TypedSimpleChanges } from '@app/shared'; import { AppLanguageDto, AppSettingsDto, ConfirmClickDirective, createProperties, DialogModel, DropdownMenuComponent, EditFieldForm, FieldDto, ModalDirective, ModalModel, ModalPlacementDirective, SchemaDto, SchemasState, TooltipDirective, TourStepDirective, TranslatePipe, TypedSimpleChanges, UpdateFieldDto } from '@app/shared';
import { FieldWizardComponent } from './field-wizard.component'; import { FieldWizardComponent } from './field-wizard.component';
import { FieldFormComponent } from './forms/field-form.component'; import { FieldFormComponent } from './forms/field-form.component';
import { SortableFieldListComponent } from './sortable-field-list.component'; import { SortableFieldListComponent } from './sortable-field-list.component';
@ -35,7 +35,7 @@ import { SortableFieldListComponent } from './sortable-field-list.component';
}) })
export class FieldComponent { export class FieldComponent {
@Input({ required: true }) @Input({ required: true })
public field!: NestedFieldDto | RootFieldDto; public field!: FieldDto | FieldDto;
@Input({ required: true }) @Input({ required: true })
public schema!: SchemaDto; public schema!: SchemaDto;
@ -44,10 +44,10 @@ export class FieldComponent {
public plain = false; public plain = false;
@Input() @Input()
public parent?: RootFieldDto; public parent?: FieldDto;
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ required: true }) @Input({ required: true })
public settings!: AppSettingsDto; public settings!: AppSettingsDto;
@ -118,19 +118,20 @@ export class FieldComponent {
} }
const value = this.editForm.submit(); const value = this.editForm.submit();
if (!value) {
if (value) { return;
const properties = createProperties(this.field.properties.fieldType, value);
this.schemasState.updateField(this.schema, this.field, { properties })
.subscribe({
next: () => {
this.editForm.submitCompleted({ noReset: true });
},
error: error => {
this.editForm.submitFailed(error);
},
});
} }
const properties = createProperties(this.field.properties.fieldType as any, value);
this.schemasState.updateField(this.schema, this.field, new UpdateFieldDto({ properties }))
.subscribe({
next: () => {
this.editForm.submitCompleted({ noReset: true });
},
error: error => {
this.editForm.submitFailed(error);
},
});
} }
} }

4
frontend/src/app/features/schemas/pages/schema/fields/forms/field-form-validation.component.ts

@ -8,7 +8,7 @@
import { booleanAttribute, Component, Input } from '@angular/core'; import { booleanAttribute, Component, Input } from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { AppSettingsDto, FieldDto, LanguageDto, SchemaDto, TranslatePipe } from '@app/shared'; import { AppLanguageDto, AppSettingsDto, FieldDto, SchemaDto, TranslatePipe } from '@app/shared';
import { ArrayValidationComponent } from '../types/array-validation.component'; import { ArrayValidationComponent } from '../types/array-validation.component';
import { AssetsValidationComponent } from '../types/assets-validation.component'; import { AssetsValidationComponent } from '../types/assets-validation.component';
import { BooleanValidationComponent } from '../types/boolean-validation.component'; import { BooleanValidationComponent } from '../types/boolean-validation.component';
@ -61,7 +61,7 @@ export class FieldFormValidationComponent {
public settings!: AppSettingsDto; public settings!: AppSettingsDto;
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
public isLocalizable?: boolean | null; public isLocalizable?: boolean | null;

4
frontend/src/app/features/schemas/pages/schema/fields/forms/field-form.component.ts

@ -8,7 +8,7 @@
import { AfterViewInit, booleanAttribute, Component, EventEmitter, Input, Output } from '@angular/core'; import { AfterViewInit, booleanAttribute, Component, EventEmitter, Input, Output } from '@angular/core';
import { UntypedFormGroup } from '@angular/forms'; import { UntypedFormGroup } from '@angular/forms';
import { AppSettingsDto, FieldDto, LanguageDto, SchemaDto, TranslatePipe } from '@app/shared'; import { AppLanguageDto, AppSettingsDto, FieldDto, SchemaDto, TranslatePipe } from '@app/shared';
import { JsonMoreComponent } from '../types/json-more.component'; import { JsonMoreComponent } from '../types/json-more.component';
import { FieldFormCommonComponent } from './field-form-common.component'; import { FieldFormCommonComponent } from './field-form-common.component';
import { FieldFormUIComponent } from './field-form-ui.component'; import { FieldFormUIComponent } from './field-form-ui.component';
@ -47,7 +47,7 @@ export class FieldFormComponent implements AfterViewInit {
public settings!: AppSettingsDto; public settings!: AppSettingsDto;
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
public isLocalizable?: boolean | null; public isLocalizable?: boolean | null;

6
frontend/src/app/features/schemas/pages/schema/fields/sortable-field-list.component.ts

@ -8,7 +8,7 @@
import { CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList, CdkDropListGroup, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop'; import { CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList, CdkDropListGroup, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
import { booleanAttribute, Component, EventEmitter, forwardRef, Input, Output } from '@angular/core'; import { booleanAttribute, Component, EventEmitter, forwardRef, Input, Output } from '@angular/core';
import { AppSettingsDto, FieldDto, FieldGroup, groupFields, LanguageDto, RootFieldDto, SchemaDto } from '@app/shared'; import { AppLanguageDto, AppSettingsDto, FieldDto, FieldGroup, groupFields, SchemaDto } from '@app/shared';
import { FieldGroupComponent } from './field-group.component'; import { FieldGroupComponent } from './field-group.component';
@Component({ @Component({
@ -29,10 +29,10 @@ export class SortableFieldListComponent {
public sorted = new EventEmitter<ReadonlyArray<FieldDto>>(); public sorted = new EventEmitter<ReadonlyArray<FieldDto>>();
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<AppLanguageDto>;
@Input() @Input()
public parent?: RootFieldDto; public parent?: FieldDto;
@Input({ required: true }) @Input({ required: true })
public settings!: AppSettingsDto; public settings!: AppSettingsDto;

4
frontend/src/app/features/schemas/pages/schema/fields/types/assets-validation.component.ts

@ -8,7 +8,7 @@
import { booleanAttribute, Component, Input } from '@angular/core'; import { booleanAttribute, Component, Input } from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { AssetsFieldPropertiesDto, FieldDto, FormHintComponent, LanguageDto, LocalizedInputComponent, TagEditorComponent, TranslatePipe } from '@app/shared'; import { AppLanguageDto, AssetsFieldPropertiesDto, FieldDto, FormHintComponent, LocalizedInputComponent, TagEditorComponent, TranslatePipe } from '@app/shared';
@Component({ @Component({
standalone: true, standalone: true,
@ -35,7 +35,7 @@ export class AssetsValidationComponent {
public properties!: AssetsFieldPropertiesDto; public properties!: AssetsFieldPropertiesDto;
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
public isLocalizable?: boolean | null; public isLocalizable?: boolean | null;

4
frontend/src/app/features/schemas/pages/schema/fields/types/boolean-ui.component.ts

@ -8,7 +8,7 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { BOOLEAN_FIELD_EDITORS, BooleanFieldPropertiesDto, FieldDto, FormHintComponent, TranslatePipe } from '@app/shared'; import { BooleanFieldEditorValues, BooleanFieldPropertiesDto, FieldDto, FormHintComponent, TranslatePipe } from '@app/shared';
@Component({ @Component({
standalone: true, standalone: true,
@ -23,7 +23,7 @@ import { BOOLEAN_FIELD_EDITORS, BooleanFieldPropertiesDto, FieldDto, FormHintCom
], ],
}) })
export class BooleanUIComponent { export class BooleanUIComponent {
public readonly editors = BOOLEAN_FIELD_EDITORS; public readonly editors = BooleanFieldEditorValues;
@Input({ required: true }) @Input({ required: true })
public fieldForm!: UntypedFormGroup; public fieldForm!: UntypedFormGroup;

4
frontend/src/app/features/schemas/pages/schema/fields/types/boolean-validation.component.ts

@ -9,7 +9,7 @@
import { booleanAttribute, Component, Input } from '@angular/core'; import { booleanAttribute, Component, Input } from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { BooleanFieldPropertiesDto, FieldDto, FormHintComponent, hasNoValue$, IndeterminateValueDirective, LanguageDto, LocalizedInputComponent, TranslatePipe, TypedSimpleChanges } from '@app/shared'; import { AppLanguageDto, BooleanFieldPropertiesDto, FieldDto, FormHintComponent, hasNoValue$, IndeterminateValueDirective, LocalizedInputComponent, TranslatePipe, TypedSimpleChanges } from '@app/shared';
@Component({ @Component({
standalone: true, standalone: true,
@ -36,7 +36,7 @@ export class BooleanValidationComponent {
public properties!: BooleanFieldPropertiesDto; public properties!: BooleanFieldPropertiesDto;
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
public isLocalizable?: boolean | null; public isLocalizable?: boolean | null;

4
frontend/src/app/features/schemas/pages/schema/fields/types/date-time-ui.component.ts

@ -8,7 +8,7 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { DATETIME_FIELD_EDITORS, DateTimeFieldPropertiesDto, FieldDto, FloatConverter, FormHintComponent, MarkdownDirective, TranslatePipe } from '@app/shared'; import { DateTimeFieldEditorValues, DateTimeFieldPropertiesDto, FieldDto, FloatConverter, FormHintComponent, MarkdownDirective, TranslatePipe } from '@app/shared';
@Component({ @Component({
standalone: true, standalone: true,
@ -25,7 +25,7 @@ import { DATETIME_FIELD_EDITORS, DateTimeFieldPropertiesDto, FieldDto, FloatConv
}) })
export class DateTimeUIComponent { export class DateTimeUIComponent {
public readonly converter = FloatConverter.INSTANCE; public readonly converter = FloatConverter.INSTANCE;
public readonly editors = DATETIME_FIELD_EDITORS; public readonly editors = DateTimeFieldEditorValues;
@Input({ required: true }) @Input({ required: true })
public fieldForm!: UntypedFormGroup; public fieldForm!: UntypedFormGroup;

4
frontend/src/app/features/schemas/pages/schema/fields/types/date-time-validation.component.ts

@ -9,7 +9,7 @@ import { AsyncPipe } from '@angular/common';
import { booleanAttribute, Component, Input } from '@angular/core'; import { booleanAttribute, Component, Input } from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { DateTimeEditorComponent, DateTimeFieldPropertiesDto, FieldDto, FormHintComponent, hasNoValue$, LanguageDto, LocalizedInputComponent, TranslatePipe, TypedSimpleChanges } from '@app/shared'; import { AppLanguageDto, DateTimeEditorComponent, DateTimeFieldPropertiesDto, FieldDto, FormHintComponent, hasNoValue$, LocalizedInputComponent, TranslatePipe, TypedSimpleChanges } from '@app/shared';
const CALCULATED_DEFAULT_VALUES: ReadonlyArray<string> = ['Now', 'Today']; const CALCULATED_DEFAULT_VALUES: ReadonlyArray<string> = ['Now', 'Today'];
@ -39,7 +39,7 @@ export class DateTimeValidationComponent {
public properties!: DateTimeFieldPropertiesDto; public properties!: DateTimeFieldPropertiesDto;
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
public isLocalizable?: boolean | null; public isLocalizable?: boolean | null;

4
frontend/src/app/features/schemas/pages/schema/fields/types/number-ui.component.ts

@ -9,7 +9,7 @@ import { AsyncPipe } from '@angular/common';
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { FieldDto, FloatConverter, FormHintComponent, NUMBER_FIELD_EDITORS, NumberFieldPropertiesDto, Subscriptions, TagEditorComponent, TranslatePipe, TypedSimpleChanges, valueProjection$ } from '@app/shared'; import { FieldDto, FloatConverter, FormHintComponent, NumberFieldEditorValues, NumberFieldPropertiesDto, Subscriptions, TagEditorComponent, TranslatePipe, TypedSimpleChanges, valueProjection$ } from '@app/shared';
@Component({ @Component({
standalone: true, standalone: true,
@ -29,7 +29,7 @@ export class NumberUIComponent {
private readonly subscriptions = new Subscriptions(); private readonly subscriptions = new Subscriptions();
public readonly converter = FloatConverter.INSTANCE; public readonly converter = FloatConverter.INSTANCE;
public readonly editors = NUMBER_FIELD_EDITORS; public readonly editors = NumberFieldEditorValues;
@Input({ required: true }) @Input({ required: true })
public fieldForm!: UntypedFormGroup; public fieldForm!: UntypedFormGroup;

6
frontend/src/app/features/schemas/pages/schema/fields/types/number-validation.component.ts

@ -8,7 +8,7 @@
import { booleanAttribute, Component, Input } from '@angular/core'; import { booleanAttribute, Component, Input } from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { FieldDto, FormHintComponent, LanguageDto, LocalizedInputComponent, NumberFieldPropertiesDto, RootFieldDto, SchemaDto, TranslatePipe, Types } from '@app/shared'; import { AppLanguageDto, FieldDto, FormHintComponent, LocalizedInputComponent, NumberFieldPropertiesDto, SchemaDto, TranslatePipe, Types } from '@app/shared';
@Component({ @Component({
standalone: true, standalone: true,
@ -37,12 +37,12 @@ export class NumberValidationComponent {
public properties!: NumberFieldPropertiesDto; public properties!: NumberFieldPropertiesDto;
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
public isLocalizable?: boolean | null; public isLocalizable?: boolean | null;
public get showUnique() { public get showUnique() {
return Types.is(this.field, RootFieldDto) && !this.field.isLocalizable && this.schema.type !== 'Component'; return Types.is(this.field, FieldDto) && !this.field.isLocalizable && this.schema.type !== 'Component';
} }
} }

4
frontend/src/app/features/schemas/pages/schema/fields/types/references-ui.component.ts

@ -8,7 +8,7 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { FieldDto, FormHintComponent, REFERENCES_FIELD_EDITORS, ReferencesFieldPropertiesDto, TranslatePipe } from '@app/shared'; import { FieldDto, FormHintComponent, ReferencesFieldEditorValues, ReferencesFieldPropertiesDto, TranslatePipe } from '@app/shared';
@Component({ @Component({
standalone: true, standalone: true,
@ -23,7 +23,7 @@ import { FieldDto, FormHintComponent, REFERENCES_FIELD_EDITORS, ReferencesFieldP
], ],
}) })
export class ReferencesUIComponent { export class ReferencesUIComponent {
public readonly editors = REFERENCES_FIELD_EDITORS; public readonly editors = ReferencesFieldEditorValues;
@Input({ required: true }) @Input({ required: true })
public fieldForm!: UntypedFormGroup; public fieldForm!: UntypedFormGroup;

4
frontend/src/app/features/schemas/pages/schema/fields/types/references-validation.component.ts

@ -8,7 +8,7 @@
import { AsyncPipe } from '@angular/common'; import { AsyncPipe } from '@angular/common';
import { booleanAttribute, Component, Input } from '@angular/core'; import { booleanAttribute, Component, Input } from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { FieldDto, FormHintComponent, LanguageDto, LocalizedInputComponent, ReferencesFieldPropertiesDto, SchemaTagSource, TagEditorComponent, TranslatePipe } from '@app/shared'; import { AppLanguageDto, FieldDto, FormHintComponent, LocalizedInputComponent, ReferencesFieldPropertiesDto, SchemaTagSource, TagEditorComponent, TranslatePipe } from '@app/shared';
@Component({ @Component({
standalone: true, standalone: true,
@ -36,7 +36,7 @@ export class ReferencesValidationComponent {
public properties!: ReferencesFieldPropertiesDto; public properties!: ReferencesFieldPropertiesDto;
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
public isLocalizable?: boolean | null; public isLocalizable?: boolean | null;

4
frontend/src/app/features/schemas/pages/schema/fields/types/string-ui.component.ts

@ -9,7 +9,7 @@ import { AsyncPipe } from '@angular/common';
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AssetFolderDropdownComponent, FieldDto, FormHintComponent, SchemaTagSource, STRING_FIELD_EDITORS, StringFieldPropertiesDto, Subscriptions, TagEditorComponent, TranslatePipe, TypedSimpleChanges, valueProjection$ } from '@app/shared'; import { AssetFolderDropdownComponent, FieldDto, FormHintComponent, SchemaTagSource, StringFieldEditorValues, StringFieldPropertiesDto, Subscriptions, TagEditorComponent, TranslatePipe, TypedSimpleChanges, valueProjection$ } from '@app/shared';
@Component({ @Component({
standalone: true, standalone: true,
@ -29,7 +29,7 @@ import { AssetFolderDropdownComponent, FieldDto, FormHintComponent, SchemaTagSou
export class StringUIComponent { export class StringUIComponent {
private readonly subscriptions = new Subscriptions(); private readonly subscriptions = new Subscriptions();
public readonly editors = STRING_FIELD_EDITORS; public readonly editors = StringFieldEditorValues;
@Input({ required: true }) @Input({ required: true })
public fieldForm!: UntypedFormGroup; public fieldForm!: UntypedFormGroup;

8
frontend/src/app/features/schemas/pages/schema/fields/types/string-validation.component.ts

@ -9,7 +9,7 @@ import { AsyncPipe } from '@angular/common';
import { booleanAttribute, Component, Input } from '@angular/core'; import { booleanAttribute, Component, Input } from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { AppSettingsDto, DropdownMenuComponent, FieldDto, FormHintComponent, hasNoValue$, hasValue$, LanguageDto, LocalizedInputComponent, ModalDirective, ModalModel, ModalPlacementDirective, PatternDto, RootFieldDto, SchemaDto, STRING_CONTENT_TYPES, StringFieldPropertiesDto, Subscriptions, TranslatePipe, TypedSimpleChanges, Types, value$ } from '@app/shared'; import { AppLanguageDto, AppSettingsDto, DropdownMenuComponent, FieldDto, FormHintComponent, hasNoValue$, hasValue$, LocalizedInputComponent, ModalDirective, ModalModel, ModalPlacementDirective, PatternDto, SchemaDto, StringContentTypeValues, StringFieldPropertiesDto, Subscriptions, TranslatePipe, TypedSimpleChanges, Types, value$ } from '@app/shared';
@Component({ @Component({
standalone: true, standalone: true,
@ -31,7 +31,7 @@ import { AppSettingsDto, DropdownMenuComponent, FieldDto, FormHintComponent, has
export class StringValidationComponent { export class StringValidationComponent {
private readonly subscriptions = new Subscriptions(); private readonly subscriptions = new Subscriptions();
public readonly contentTypes = STRING_CONTENT_TYPES; public readonly contentTypes = StringContentTypeValues;
@Input({ required: true }) @Input({ required: true })
public fieldForm!: UntypedFormGroup; public fieldForm!: UntypedFormGroup;
@ -49,7 +49,7 @@ export class StringValidationComponent {
public settings!: AppSettingsDto; public settings!: AppSettingsDto;
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
public isLocalizable?: boolean | null; public isLocalizable?: boolean | null;
@ -61,7 +61,7 @@ export class StringValidationComponent {
public patternsModal = new ModalModel(); public patternsModal = new ModalModel();
public get showUnique() { public get showUnique() {
return Types.is(this.field, RootFieldDto) && !this.field.isLocalizable && this.schema.type !== 'Component'; return Types.is(this.field, FieldDto) && !this.field.isLocalizable && this.schema.type !== 'Component';
} }
public ngOnChanges(changes: TypedSimpleChanges<this>) { public ngOnChanges(changes: TypedSimpleChanges<this>) {

4
frontend/src/app/features/schemas/pages/schema/fields/types/tags-ui.component.ts

@ -8,7 +8,7 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { FieldDto, FormHintComponent, TagEditorComponent, TAGS_FIELD_EDITORS, TagsFieldPropertiesDto, TranslatePipe } from '@app/shared'; import { FieldDto, FormHintComponent, TagEditorComponent, TagsFieldEditorValues, TagsFieldPropertiesDto, TranslatePipe } from '@app/shared';
@Component({ @Component({
standalone: true, standalone: true,
@ -24,7 +24,7 @@ import { FieldDto, FormHintComponent, TagEditorComponent, TAGS_FIELD_EDITORS, Ta
], ],
}) })
export class TagsUIComponent { export class TagsUIComponent {
public readonly editors = TAGS_FIELD_EDITORS; public readonly editors = TagsFieldEditorValues;
@Input({ required: true }) @Input({ required: true })
public fieldForm!: UntypedFormGroup; public fieldForm!: UntypedFormGroup;

4
frontend/src/app/features/schemas/pages/schema/fields/types/tags-validation.component.ts

@ -8,7 +8,7 @@
import { booleanAttribute, Component, Input } from '@angular/core'; import { booleanAttribute, Component, Input } from '@angular/core';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { FormsModule, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
import { FieldDto, FormHintComponent, LanguageDto, LocalizedInputComponent, TagEditorComponent, TagsFieldPropertiesDto, TranslatePipe } from '@app/shared'; import { AppLanguageDto, FieldDto, FormHintComponent, LocalizedInputComponent, TagEditorComponent, TagsFieldPropertiesDto, TranslatePipe } from '@app/shared';
@Component({ @Component({
standalone: true, standalone: true,
@ -35,7 +35,7 @@ export class TagsValidationComponent {
public properties!: TagsFieldPropertiesDto; public properties!: TagsFieldPropertiesDto;
@Input({ required: true }) @Input({ required: true })
public languages!: ReadonlyArray<LanguageDto>; public languages!: ReadonlyArray<AppLanguageDto>;
@Input({ transform: booleanAttribute }) @Input({ transform: booleanAttribute })
public isLocalizable?: boolean | null; public isLocalizable?: boolean | null;

25
frontend/src/app/features/schemas/pages/schema/indexes/index-form.component.ts

@ -83,18 +83,19 @@ export class IndexFormComponent {
} }
public createSchema() { public createSchema() {
const fields = this.createForm.submit(); const value = this.createForm.submit();
if (!value) {
if (fields) { return;
this.indexesState.create({ fields })
.subscribe({
next: () => {
this.emitCreate();
},
error: error => {
this.createForm.submitFailed(error);
},
});
} }
this.indexesState.create(value)
.subscribe({
next: () => {
this.emitCreate();
},
error: error => {
this.createForm.submitFailed(error);
},
});
} }
} }

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

@ -60,17 +60,18 @@ export class SchemaPreviewUrlsFormComponent implements OnInit {
} }
const value = this.editForm.submit(); const value = this.editForm.submit();
if (!value) {
if (value) { return;
this.schemasState.configurePreviewUrls(this.schema, value)
.subscribe({
next: () => {
this.editForm.submitCompleted({ noReset: true });
},
error: error => {
this.editForm.submitFailed(error);
},
});
} }
this.schemasState.configurePreviewUrls(this.schema, value)
.subscribe({
next: () => {
this.editForm.submitCompleted({ noReset: true });
},
error: error => {
this.editForm.submitFailed(error);
},
});
} }
} }

25
frontend/src/app/features/schemas/pages/schema/rules/schema-field-rules-form.component.ts

@ -56,7 +56,7 @@ export class SchemaFieldRulesFormComponent implements OnInit {
if (field.properties.isContentField) { if (field.properties.isContentField) {
fieldNames.push(field.name); fieldNames.push(field.name);
for (const nestedField of field.nested) { for (const nestedField of field.nested || []) {
if (nestedField.properties.isContentField) { if (nestedField.properties.isContentField) {
fieldNames.push(`${field.name}.${nestedField.name}`); fieldNames.push(`${field.name}.${nestedField.name}`);
} }
@ -82,17 +82,18 @@ export class SchemaFieldRulesFormComponent implements OnInit {
} }
const value = this.editForm.submit(); const value = this.editForm.submit();
if (!value) {
if (value) { return;
this.schemasState.configureFieldRules(this.schema, value)
.subscribe({
next: () => {
this.editForm.submitCompleted({ noReset: true });
},
error: error => {
this.editForm.submitFailed(error);
},
});
} }
this.schemasState.configureFieldRules(this.schema, value)
.subscribe({
next: () => {
this.editForm.submitCompleted({ noReset: true });
},
error: error => {
this.editForm.submitFailed(error);
},
});
} }
} }

23
frontend/src/app/features/schemas/pages/schema/scripts/schema-scripts-form.component.ts

@ -63,17 +63,18 @@ export class SchemaScriptsFormComponent {
} }
const value = this.editForm.submit(); const value = this.editForm.submit();
if (!value) {
if (value) { return;
this.schemasState.configureScripts(this.schema, value)
.subscribe({
next: () => {
this.editForm.submitCompleted({ noReset: true });
},
error: error => {
this.editForm.submitFailed(error);
},
});
} }
this.schemasState.configureScripts(this.schema, value)
.subscribe({
next: () => {
this.editForm.submitCompleted({ noReset: true });
},
error: error => {
this.editForm.submitFailed(error);
},
});
} }
} }

2
frontend/src/app/features/schemas/pages/schema/ui/field-list.component.ts

@ -39,7 +39,7 @@ export class FieldListComponent {
public withMetaFields = false; public withMetaFields = false;
@Output() @Output()
public fieldNamesChange = new EventEmitter<ReadonlyArray<string>>(); public fieldNamesChange = new EventEmitter<string[]>();
public fieldsAdded!: TableField[]; public fieldsAdded!: TableField[];
public fieldsNotAdded!: TableField[]; public fieldsNotAdded!: TableField[];

14
frontend/src/app/features/schemas/pages/schema/ui/schema-ui-form.component.ts

@ -7,7 +7,7 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { SchemaDto, SchemasState, TranslatePipe } from '@app/shared'; import { ConfigureUIFieldsDto, SchemaDto, SchemasState, TranslatePipe } from '@app/shared';
import { FieldListComponent } from './field-list.component'; import { FieldListComponent } from './field-list.component';
@Component({ @Component({
@ -29,8 +29,8 @@ export class SchemaUIFormComponent {
public isEditable = false; public isEditable = false;
public fieldsInLists: ReadonlyArray<string> = []; public fieldsInLists: string[] = [];
public fieldsInReferences: ReadonlyArray<string> = []; public fieldsInReferences: string[] = [];
constructor( constructor(
private readonly schemasState: SchemasState, private readonly schemasState: SchemasState,
@ -44,11 +44,11 @@ export class SchemaUIFormComponent {
this.fieldsInReferences = this.schema.fieldsInReferences; this.fieldsInReferences = this.schema.fieldsInReferences;
} }
public setFieldsInLists(names: ReadonlyArray<string>) { public setFieldsInLists(names: string[]) {
this.fieldsInLists = names; this.fieldsInLists = names;
} }
public setFieldsInReferences(names: ReadonlyArray<string>) { public setFieldsInReferences(names: string[]) {
this.fieldsInReferences = names; this.fieldsInReferences = names;
} }
@ -61,9 +61,11 @@ export class SchemaUIFormComponent {
return; return;
} }
this.schemasState.configureUIFields(this.schema, { const request = new ConfigureUIFieldsDto({
fieldsInLists: this.fieldsInLists, fieldsInLists: this.fieldsInLists,
fieldsInReferences: this.fieldsInReferences, fieldsInReferences: this.fieldsInReferences,
}); });
this.schemasState.configureUIFields(this.schema, request);
} }
} }

23
frontend/src/app/features/schemas/pages/schemas/schema-form.component.ts

@ -74,17 +74,18 @@ export class SchemaFormComponent implements OnInit {
public createSchema() { public createSchema() {
const value = this.createForm.submit(); const value = this.createForm.submit();
if (!value) {
if (value) { return;
this.schemasState.create(value)
.subscribe({
next: dto => {
this.emitCreate(dto);
},
error: error => {
this.createForm.submitFailed(error);
},
});
} }
this.schemasState.create(value)
.subscribe({
next: dto => {
this.emitCreate(dto);
},
error: error => {
this.createForm.submitFailed(error);
},
});
} }
} }

13
frontend/src/app/features/schemas/pages/schemas/schemas-page.component.ts

@ -89,13 +89,14 @@ export class SchemasPageComponent implements OnInit {
public addCategory() { public addCategory() {
const value = this.addCategoryForm.submit(); const value = this.addCategoryForm.submit();
if (!value) {
return;
}
if (value) { try {
try { this.schemasState.addCategory(value.name);
this.schemasState.addCategory(value.name); } finally {
} finally { this.addCategoryForm.submitCompleted();
this.addCategoryForm.submitCompleted();
}
} }
} }

23
frontend/src/app/features/settings/pages/asset-scripts/asset-scripts-page.component.ts

@ -83,17 +83,18 @@ export class AssetScriptsPageComponent implements OnInit {
} }
const value = this.editForm.submit(); const value = this.editForm.submit();
if (!value) {
if (value) { return;
this.assetScriptsState.update(value)
.subscribe({
next: () => {
this.editForm.submitCompleted({ noReset: true });
},
error: error => {
this.editForm.submitFailed(error);
},
});
} }
this.assetScriptsState.update(value)
.subscribe({
next: () => {
this.editForm.submitCompleted({ noReset: true });
},
error: error => {
this.editForm.submitFailed(error);
},
});
} }
} }

23
frontend/src/app/features/settings/pages/clients/client-add-form.component.ts

@ -36,18 +36,19 @@ export class ClientAddFormComponent {
public addClient() { public addClient() {
const value = this.addClientForm.submit(); const value = this.addClientForm.submit();
if (!value) {
if (value) { return;
this.clientsState.attach(value)
.subscribe({
next: () => {
this.addClientForm.submitCompleted();
},
error: error => {
this.addClientForm.submitFailed(error);
},
});
} }
this.clientsState.attach(value)
.subscribe({
next: () => {
this.addClientForm.submitCompleted();
},
error: error => {
this.addClientForm.submitFailed(error);
},
});
} }
public cancel() { public cancel() {

2
frontend/src/app/features/settings/pages/clients/client.component.html

@ -124,7 +124,7 @@
</div> </div>
@if (client.canUpdate) { @if (client.canUpdate) {
<div class="col-auto"> <div class="col-auto">
<button class="btn btn-outline-secondary" (click)="updateApiCallsLimit()"> <button class="btn btn-outline-secondary" (click)="updateApiCallsLimit(apiCallsLimit)">
{{ "common.save" | sqxTranslate }} {{ "common.save" | sqxTranslate }}
</button> </button>
</div> </div>

20
frontend/src/app/features/settings/pages/clients/client.component.ts

@ -8,7 +8,7 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { AppsState, ClientDto, ClientsState, ConfirmClickDirective, CopyDirective, DialogModel, EditableTitleComponent, FormHintComponent, ModalDirective, RoleDto, TooltipDirective, TourStepDirective, TranslatePipe, TypedSimpleChanges } from '@app/shared'; import { AppsState, ClientDto, ClientsState, ConfirmClickDirective, CopyDirective, DialogModel, EditableTitleComponent, FormHintComponent, ModalDirective, RoleDto, TooltipDirective, TourStepDirective, TranslatePipe, TypedSimpleChanges, UpdateClientDto } from '@app/shared';
import { ClientConnectFormComponent } from './client-connect-form.component'; import { ClientConnectFormComponent } from './client-connect-form.component';
@Component({ @Component({
@ -58,18 +58,26 @@ export class ClientComponent {
} }
public updateRole(role: string) { public updateRole(role: string) {
this.clientsState.update(this.client, { role }); const request = new UpdateClientDto({ role });
this.clientsState.update(this.client, request);
} }
public updateAllowAnonymous(allowAnonymous: boolean) { public updateAllowAnonymous(allowAnonymous: boolean) {
this.clientsState.update(this.client, { allowAnonymous }); const request = new UpdateClientDto({ allowAnonymous });
this.clientsState.update(this.client, request);
} }
public updateApiCallsLimit() { public updateApiCallsLimit(apiCallsLimit: number) {
this.clientsState.update(this.client, { apiCallsLimit: this.apiCallsLimit }); const request = new UpdateClientDto({ apiCallsLimit });
this.clientsState.update(this.client, request);
} }
public rename(name: string) { public rename(name: string) {
this.clientsState.update(this.client, { name }); const request = new UpdateClientDto({ name });
this.clientsState.update(this.client, request);
} }
} }

33
frontend/src/app/features/settings/pages/contributors/contributor-add-form.component.ts

@ -85,23 +85,24 @@ export class ContributorAddFormComponent {
public assignContributor() { public assignContributor() {
const value = this.assignContributorForm.submit(); const value = this.assignContributorForm.submit();
if (!value) {
return;
}
if (value) { this.contributorsState.assign(value)
this.contributorsState.assign(value) .subscribe({
.subscribe({ next: isCreated => {
next: isCreated => { this.assignContributorForm.submitCompleted({ newValue: this.defaultValue });
this.assignContributorForm.submitCompleted({ newValue: this.defaultValue });
if (isCreated) { if (isCreated) {
this.dialogs.notifyInfo('i18n:contributors.contributorAssigned'); this.dialogs.notifyInfo('i18n:contributors.contributorAssigned');
} else { } else {
this.dialogs.notifyInfo('i18n:contributors.contributorAssignedOld'); this.dialogs.notifyInfo('i18n:contributors.contributorAssignedOld');
} }
}, },
error: error => { error: error => {
this.assignContributorForm.submitFailed(error); this.assignContributorForm.submitFailed(error);
}, },
}); });
}
} }
} }

6
frontend/src/app/features/settings/pages/contributors/contributor.component.ts

@ -10,7 +10,7 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ConfirmClickDirective, ContributorDto, ContributorsState, HighlightPipe, RoleDto, TooltipDirective, UserPicturePipe } from '@app/shared'; import { AssignContributorDto, ConfirmClickDirective, ContributorDto, ContributorsState, HighlightPipe, RoleDto, TooltipDirective, UserPicturePipe } from '@app/shared';
@Component({ @Component({
standalone: true, standalone: true,
@ -46,6 +46,8 @@ export class ContributorComponent {
} }
public changeRole(role: string) { public changeRole(role: string) {
this.contributorsState.assign({ contributorId: this.contributor.contributorId, role }); const request = new AssignContributorDto({ contributorId: this.contributor.contributorId, role });
this.contributorsState.assign(request);
} }
} }

4
frontend/src/app/features/settings/pages/contributors/import-contributors-dialog.component.ts

@ -10,7 +10,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { EMPTY, of } from 'rxjs'; import { EMPTY, of } from 'rxjs';
import { catchError, mergeMap, tap } from 'rxjs/operators'; import { catchError, mergeMap, tap } from 'rxjs/operators';
import { ContributorsState, ErrorDto, FormHintComponent, ImportContributorsForm, ModalDialogComponent, RoleDto, StatusIconComponent, TooltipDirective, TranslatePipe } from '@app/shared'; import { AssignContributorDto, ContributorsState, ErrorDto, FormHintComponent, ImportContributorsForm, ModalDialogComponent, RoleDto, StatusIconComponent, TooltipDirective, TranslatePipe } from '@app/shared';
type ImportStatus = { type ImportStatus = {
email: string; email: string;
@ -96,7 +96,7 @@ export class ImportContributorsDialogComponent {
} }
function createRequest(status: ImportStatus) { function createRequest(status: ImportStatus) {
return { contributorId: status.email, role: status.role, invite: true }; return new AssignContributorDto({ contributorId: status.email, role: status.role, invite: true });
} }
function getError(error: ErrorDto): string { function getError(error: ErrorDto): string {

23
frontend/src/app/features/settings/pages/languages/language-add-form.component.ts

@ -69,17 +69,18 @@ export class LanguageAddFormComponent {
public addLanguage() { public addLanguage() {
const value = this.addLanguageForm.submit(); const value = this.addLanguageForm.submit();
if (!value) {
if (value) { return;
this.languagesState.add(value.language)
.subscribe({
next: () => {
this.addLanguageForm.submitCompleted();
},
error: error => {
this.addLanguageForm.submitFailed(error);
},
});
} }
this.languagesState.add(value.language)
.subscribe({
next: () => {
this.addLanguageForm.submitCompleted();
},
error: error => {
this.addLanguageForm.submitFailed(error);
},
});
} }
} }

31
frontend/src/app/features/settings/pages/languages/language.component.ts

@ -9,7 +9,7 @@ import { CdkDrag, CdkDragDrop, CdkDragHandle, CdkDropList } from '@angular/cdk/d
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AppLanguageDto, ConfirmClickDirective, EditLanguageForm, FormHintComponent, LanguageDto, LanguagesState, sorted, TranslatePipe } from '@app/shared'; import { AppLanguageDto, ConfirmClickDirective, EditLanguageForm, FormHintComponent, LanguageDto, LanguagesState, sorted, TranslatePipe, UpdateLanguageDto } from '@app/shared';
@Component({ @Component({
standalone: true, standalone: true,
@ -67,7 +67,7 @@ export class LanguageComponent {
} }
public sort(event: CdkDragDrop<ReadonlyArray<AppLanguageDto>>) { public sort(event: CdkDragDrop<ReadonlyArray<AppLanguageDto>>) {
this.fallbackLanguages = sorted(event); this.fallbackLanguages = sorted(event) as any;
} }
public save() { public save() {
@ -76,20 +76,21 @@ export class LanguageComponent {
} }
const value = this.editForm.submit(); const value = this.editForm.submit();
if (!value) {
if (value) { return;
const request = { ...value, fallback: this.fallbackLanguages.map(x => x.iso2Code) };
this.languagesState.update(this.language, request)
.subscribe({
next: () => {
this.editForm.submitCompleted({ noReset: true });
},
error: error => {
this.editForm.submitFailed(error);
},
});
} }
const request = new UpdateLanguageDto({ ...value, fallback: this.fallbackLanguages.map(x => x.iso2Code) });
this.languagesState.update(this.language, request)
.subscribe({
next: () => {
this.editForm.submitCompleted({ noReset: true });
},
error: error => {
this.editForm.submitFailed(error);
},
});
} }
public removeFallbackLanguage(language: LanguageDto) { public removeFallbackLanguage(language: LanguageDto) {

54
frontend/src/app/features/settings/pages/more/more-page.component.ts

@ -92,20 +92,21 @@ export class MorePageComponent implements OnInit {
} }
const value = this.updateForm.submit(); const value = this.updateForm.submit();
if (!value) {
if (value) { return;
this.appsState.update(this.app, value)
.subscribe({
next: app => {
this.updateForm.submitCompleted({ newValue: app });
},
error: error => {
this.dialogs.notifyError(error);
this.updateForm.submitFailed(error);
},
});
} }
this.appsState.update(this.app, value)
.subscribe({
next: app => {
this.updateForm.submitCompleted({ newValue: app });
},
error: error => {
this.dialogs.notifyError(error);
this.updateForm.submitFailed(error);
},
});
} }
public transfer() { public transfer() {
@ -114,20 +115,21 @@ export class MorePageComponent implements OnInit {
} }
const value = this.transferForm.submit(); const value = this.transferForm.submit();
if (!value) {
if (value) { return;
this.appsState.transfer(this.app, value.teamId)
.subscribe({
next: app => {
this.transferForm.submitCompleted({ newValue: app });
},
error: error => {
this.dialogs.notifyError(error);
this.transferForm.submitFailed(error);
},
});
} }
this.appsState.transfer(this.app, value)
.subscribe({
next: app => {
this.transferForm.submitCompleted({ newValue: app });
},
error: error => {
this.dialogs.notifyError(error);
this.transferForm.submitFailed(error);
},
});
} }
public uploadImage(file: ReadonlyArray<File>) { public uploadImage(file: ReadonlyArray<File>) {

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save