From 03c23a8ec71effb364bdf761b6ba62df14a416f8 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 25 May 2019 22:31:10 +0200 Subject: [PATCH 001/107] Patterns simplified. --- .../Apps/AppPatterns.cs | 11 + .../Apps/AppExtensions.cs | 20 ++ .../Apps/AppGrain.cs | 77 +++---- .../Apps/Commands/AddPattern.cs | 27 --- ...{DeletePattern.cs => ConfigurePatterns.cs} | 8 +- .../{UpdatePattern.cs => UpsertAppPattern.cs} | 8 +- .../Apps/Guards/GuardAppPatterns.cs | 94 +++----- .../Apps/InitialPatterns.cs | 5 +- .../Apps/Services/IAppPlanBillingManager.cs | 3 +- .../NoopAppPlanBillingManager.cs | 3 +- .../Apps/State/AppState.cs | 5 + .../EntityExtensions.cs | 21 ++ .../Schemas/Guards/GuardSchema.cs | 84 ++++--- .../Schemas/SchemaGrain.cs | 8 +- .../Apps/AppPatternsConfigured.cs | 18 ++ src/Squidex.Shared/Permissions.cs | 2 - .../EnrichWithAppIdCommandMiddleware.cs | 2 +- .../EnrichWithSchemaIdCommandMiddleware.cs | 2 +- .../Controllers/Apps/AppPatternsController.cs | 66 +----- .../Controllers/Apps/Models/AppPatternDto.cs | 13 +- .../Apps/Models/ConfigurePatternsDto.cs | 31 +++ .../Apps/Models/UpdatePatternDto.cs | 44 ---- src/Squidex/Config/Domain/EntitiesServices.cs | 2 +- .../Model/Apps/AppPatternsTests.cs | 26 +++ .../Apps/AppGrainTests.cs | 115 ++++------ .../Billing/NoopAppPlanBillingManagerTests.cs | 3 +- .../Apps/Guards/GuardAppPatternsTests.cs | 209 +++++++----------- .../NotificationEmailEventConsumerTests.cs | 2 +- .../EnrichWithAppIdCommandMiddlewareTests.cs | 4 +- tools/Migrate_01/Migrations/AddPatterns.cs | 27 +-- 30 files changed, 416 insertions(+), 524 deletions(-) create mode 100644 src/Squidex.Domain.Apps.Entities/Apps/AppExtensions.cs delete mode 100644 src/Squidex.Domain.Apps.Entities/Apps/Commands/AddPattern.cs rename src/Squidex.Domain.Apps.Entities/Apps/Commands/{DeletePattern.cs => ConfigurePatterns.cs} (70%) rename src/Squidex.Domain.Apps.Entities/Apps/Commands/{UpdatePattern.cs => UpsertAppPattern.cs} (76%) create mode 100644 src/Squidex.Domain.Apps.Entities/EntityExtensions.cs create mode 100644 src/Squidex.Domain.Apps.Events/Apps/AppPatternsConfigured.cs create mode 100644 src/Squidex/Areas/Api/Controllers/Apps/Models/ConfigurePatternsDto.cs delete mode 100644 src/Squidex/Areas/Api/Controllers/Apps/Models/UpdatePatternDto.cs diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/AppPatterns.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/AppPatterns.cs index cb9e13d3d..b86f572af 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Apps/AppPatterns.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Apps/AppPatterns.cs @@ -8,6 +8,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.Contracts; +using System.Linq; using Squidex.Infrastructure; using Squidex.Infrastructure.Collections; @@ -26,6 +27,16 @@ namespace Squidex.Domain.Apps.Core.Apps { } + public static AppPatterns Create(IEnumerable patterns) + { + if (patterns == null || !patterns.Any()) + { + return Empty; + } + + return new AppPatterns(patterns.Select(x => new KeyValuePair(Guid.NewGuid(), x)).ToArray()); + } + [Pure] public AppPatterns Remove(Guid id) { diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppExtensions.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppExtensions.cs new file mode 100644 index 000000000..8f94a80cf --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppExtensions.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Apps +{ + public static class AppExtensions + { + public static NamedId NamedId(this IAppEntity app) + { + return new NamedId(app.Id, app.Name); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs index 3645192a0..55c36460c 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; @@ -157,28 +158,12 @@ namespace Squidex.Domain.Apps.Entities.Apps UpdateRole(c); }); - case AddPattern addPattern: - return UpdateAsync(addPattern, c => + case ConfigurePatterns configurePatterns: + return UpdateAsync(configurePatterns, c => { - GuardAppPatterns.CanAdd(Snapshot.Patterns, c); + GuardAppPatterns.CanConfigure(c); - AddPattern(c); - }); - - case DeletePattern deletePattern: - return UpdateAsync(deletePattern, c => - { - GuardAppPatterns.CanDelete(Snapshot.Patterns, c); - - DeletePattern(c); - }); - - case UpdatePattern updatePattern: - return UpdateAsync(updatePattern, c => - { - GuardAppPatterns.CanUpdate(Snapshot.Patterns, c); - - UpdatePattern(c); + ConfigurePatterns(c); }); case ChangePlan changePlan: @@ -194,7 +179,7 @@ namespace Squidex.Domain.Apps.Entities.Apps } else { - var result = await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.Id, Snapshot.Name, c.PlanId); + var result = await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.NamedId(), c.PlanId); switch (result) { @@ -213,7 +198,7 @@ namespace Squidex.Domain.Apps.Entities.Apps case ArchiveApp archiveApp: return UpdateAsync(archiveApp, async c => { - await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.Id, Snapshot.Name, null); + await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.NamedId(), null); ArchiveApp(c); }); @@ -234,16 +219,12 @@ namespace Squidex.Domain.Apps.Entities.Apps var events = new List { - CreateInitalEvent(command.Name), + CreateInitialEvent(command.Name), + CreateInitialLanguage(), CreateInitialOwner(command.Actor), - CreateInitialLanguage() + CreateInitialPatterns(initialPatterns) }; - foreach (var pattern in initialPatterns) - { - events.Add(CreateInitialPattern(pattern.Key, pattern.Value)); - } - foreach (var @event in events) { @event.Actor = command.Actor; @@ -311,19 +292,9 @@ namespace Squidex.Domain.Apps.Entities.Apps RaiseEvent(SimpleMapper.Map(command, new AppPlanReset())); } - public void AddPattern(AddPattern command) + public void ConfigurePatterns(ConfigurePatterns command) { - RaiseEvent(SimpleMapper.Map(command, new AppPatternAdded())); - } - - public void DeletePattern(DeletePattern command) - { - RaiseEvent(SimpleMapper.Map(command, new AppPatternDeleted())); - } - - public void UpdatePattern(UpdatePattern command) - { - RaiseEvent(SimpleMapper.Map(command, new AppPatternUpdated())); + RaiseEvent(SimpleMapper.Map(command, CreatePatterns(command))); } public void AddRole(AddRole command) @@ -358,22 +329,17 @@ namespace Squidex.Domain.Apps.Entities.Apps { if (@event.AppId == null) { - @event.AppId = NamedId.Of(Snapshot.Id, Snapshot.Name); + @event.AppId = Snapshot.NamedId(); } RaiseEvent(Envelope.Create(@event)); } - private static AppCreated CreateInitalEvent(string name) + private static AppCreated CreateInitialEvent(string name) { return new AppCreated { Name = name }; } - private static AppPatternAdded CreateInitialPattern(Guid id, AppPattern pattern) - { - return new AppPatternAdded { PatternId = id, Name = pattern.Name, Pattern = pattern.Pattern, Message = pattern.Message }; - } - private static AppLanguageAdded CreateInitialLanguage() { return new AppLanguageAdded { Language = Language.EN }; @@ -384,6 +350,21 @@ namespace Squidex.Domain.Apps.Entities.Apps return new AppContributorAssigned { ContributorId = actor.Identifier, Role = Role.Owner }; } + private static AppPatternsConfigured CreateInitialPatterns(InitialPatterns patterns) + { + return new AppPatternsConfigured { Patterns = patterns.ToArray() }; + } + + private static AppPatternsConfigured CreatePatterns(ConfigurePatterns command) + { + return new AppPatternsConfigured { Patterns = command.Patterns?.Select(Convert).ToArray() }; + } + + private static AppPattern Convert(UpsertAppPattern source) + { + return new AppPattern(source.Name, source.Pattern, source.Message); + } + public Task> GetStateAsync() { return J.AsTask(Snapshot); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddPattern.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddPattern.cs deleted file mode 100644 index 30873adbb..000000000 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/AddPattern.cs +++ /dev/null @@ -1,27 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; - -namespace Squidex.Domain.Apps.Entities.Apps.Commands -{ - public sealed class AddPattern : AppCommand - { - public Guid PatternId { get; set; } - - public string Name { get; set; } - - public string Pattern { get; set; } - - public string Message { get; set; } - - public AddPattern() - { - PatternId = Guid.NewGuid(); - } - } -} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeletePattern.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigurePatterns.cs similarity index 70% rename from src/Squidex.Domain.Apps.Entities/Apps/Commands/DeletePattern.cs rename to src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigurePatterns.cs index 199bff83c..b0746007b 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/DeletePattern.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigurePatterns.cs @@ -1,16 +1,14 @@ // ========================================================================== // Squidex Headless CMS // ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) +// Copyright (c) Squidex UG (haftungsbeschraenkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; - namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class DeletePattern : AppCommand + public sealed class ConfigurePatterns : AppCommand { - public Guid PatternId { get; set; } + public UpsertAppPattern[] Patterns { get; set; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdatePattern.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpsertAppPattern.cs similarity index 76% rename from src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdatePattern.cs rename to src/Squidex.Domain.Apps.Entities/Apps/Commands/UpsertAppPattern.cs index 415856189..5b1acdde5 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdatePattern.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpsertAppPattern.cs @@ -1,18 +1,14 @@ // ========================================================================== // Squidex Headless CMS // ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) +// Copyright (c) Squidex UG (haftungsbeschraenkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; - namespace Squidex.Domain.Apps.Entities.Apps.Commands { - public sealed class UpdatePattern : AppCommand + public sealed class UpsertAppPattern { - public Guid PatternId { get; set; } - public string Name { get; set; } public string Pattern { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppPatterns.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppPatterns.cs index 685441e53..0a8011a40 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppPatterns.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppPatterns.cs @@ -4,9 +4,9 @@ // Copyright (c) Squidex UG (haftungsbeschränkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== + using System; using System.Linq; -using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; @@ -14,88 +14,64 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards { public static class GuardAppPatterns { - public static void CanAdd(AppPatterns patterns, AddPattern command) + public static void CanConfigure(ConfigurePatterns command) { Guard.NotNull(command, nameof(command)); - Validate.It(() => "Cannot add pattern.", e => + Validate.It(() => "Cannot configure patterns.", e => { - if (command.PatternId == Guid.Empty) + if (command.Patterns?.Length > 0) { - e(Not.Defined("Id"), nameof(command.PatternId)); - } + var patternIndex = 0; + var patternPrefix = string.Empty; - if (string.IsNullOrWhiteSpace(command.Name)) - { - e(Not.Defined("Name"), nameof(command.Name)); - } + foreach (var pattern in command.Patterns) + { + patternIndex++; + patternPrefix = $"{nameof(command.Patterns)}[{patternIndex}]"; - if (patterns.Values.Any(x => x.Name.Equals(command.Name, StringComparison.OrdinalIgnoreCase))) - { - e("A pattern with the same name already exists."); - } + ValidatePattern(pattern, patternPrefix, e); + } - if (string.IsNullOrWhiteSpace(command.Pattern)) - { - e(Not.Defined("Pattern"), nameof(command.Pattern)); - } - else if (!command.Pattern.IsValidRegex()) - { - e(Not.Valid("Pattern"), nameof(command.Pattern)); - } + var validNames = command.Patterns.Select(p => p?.Name).Where(p => !string.IsNullOrWhiteSpace(p)); - if (patterns.Values.Any(x => x.Pattern == command.Pattern)) - { - e("This pattern already exists but with another name."); - } - }); - } + if (validNames.Count() != validNames.Distinct(StringComparer.OrdinalIgnoreCase).Count()) + { + e("Two patterns with the same name exist.", nameof(command.Patterns)); + } - public static void CanDelete(AppPatterns patterns, DeletePattern command) - { - Guard.NotNull(command, nameof(command)); + var validPatterns = command.Patterns.Select(p => p?.Pattern).Where(p => !string.IsNullOrWhiteSpace(p)); - if (!patterns.ContainsKey(command.PatternId)) - { - throw new DomainObjectNotFoundException(command.PatternId.ToString(), typeof(AppPattern)); - } + if (validPatterns.Count() != validPatterns.Distinct().Count()) + { + e("Two patterns with the same expression exist.", nameof(command.Patterns)); + } + } + }); } - public static void CanUpdate(AppPatterns patterns, UpdatePattern command) + private static void ValidatePattern(UpsertAppPattern pattern, string prefix, AddValidation e) { - Guard.NotNull(command, nameof(command)); - - if (!patterns.ContainsKey(command.PatternId)) + if (pattern == null) { - throw new DomainObjectNotFoundException(command.PatternId.ToString(), typeof(AppPattern)); + e(Not.Defined("Pattern"), prefix); } - - Validate.It(() => "Cannot update pattern.", e => + else { - if (string.IsNullOrWhiteSpace(command.Name)) + if (string.IsNullOrWhiteSpace(pattern.Name)) { - e(Not.Defined("Name"), nameof(command.Name)); + e(Not.Defined("Name"), $"{prefix}.{nameof(pattern.Name)}"); } - if (patterns.Any(x => x.Key != command.PatternId && x.Value.Name.Equals(command.Name, StringComparison.OrdinalIgnoreCase))) + if (string.IsNullOrWhiteSpace(pattern.Pattern)) { - e("A pattern with the same name already exists."); + e(Not.Defined("Expression"), $"{prefix}.{nameof(pattern.Pattern)}"); } - - if (string.IsNullOrWhiteSpace(command.Pattern)) + else if (!pattern.Pattern.IsValidRegex()) { - e(Not.Defined("Pattern"), nameof(command.Pattern)); + e(Not.Valid("Expression"), $"{prefix}.{nameof(pattern.Pattern)}"); } - else if (!command.Pattern.IsValidRegex()) - { - e(Not.Valid("Pattern"), nameof(command.Pattern)); - } - - if (patterns.Any(x => x.Key != command.PatternId && x.Value.Pattern == command.Pattern)) - { - e("This pattern already exists but with another name."); - } - }); + } } } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/InitialPatterns.cs b/src/Squidex.Domain.Apps.Entities/Apps/InitialPatterns.cs index d8da459ed..ec55fac44 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/InitialPatterns.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/InitialPatterns.cs @@ -5,19 +5,18 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Collections.Generic; using Squidex.Domain.Apps.Core.Apps; namespace Squidex.Domain.Apps.Entities.Apps { - public sealed class InitialPatterns : Dictionary + public sealed class InitialPatterns : List { public InitialPatterns() { } - public InitialPatterns(Dictionary patterns) + public InitialPatterns(IEnumerable patterns) : base(patterns) { } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlanBillingManager.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlanBillingManager.cs index 89c6342cd..933a11ddf 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlanBillingManager.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlanBillingManager.cs @@ -7,6 +7,7 @@ using System; using System.Threading.Tasks; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Apps.Services { @@ -14,7 +15,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services { bool HasPortal { get; } - Task ChangePlanAsync(string userId, Guid appId, string appName, string planId); + Task ChangePlanAsync(string userId, NamedId appId, string planId); Task GetPortalLinkAsync(string userId); } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/NoopAppPlanBillingManager.cs b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/NoopAppPlanBillingManager.cs index 8e968ccc7..b8c1f46ef 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/NoopAppPlanBillingManager.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/NoopAppPlanBillingManager.cs @@ -7,6 +7,7 @@ using System; using System.Threading.Tasks; +using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations { @@ -17,7 +18,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations get { return false; } } - public Task ChangePlanAsync(string userId, Guid appId, string appName, string planId) + public Task ChangePlanAsync(string userId, NamedId appId, string planId) { return Task.FromResult(new PlanResetResult()); } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs index 4f31dbdc0..162ecc75c 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs @@ -92,6 +92,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.State Clients = Clients.Revoke(@event.Id); } + protected void On(AppPatternsConfigured @event) + { + Patterns = AppPatterns.Create(@event.Patterns); + } + protected void On(AppPatternAdded @event) { Patterns = Patterns.Add(@event.PatternId, @event.Name, @event.Pattern, @event.Message); diff --git a/src/Squidex.Domain.Apps.Entities/EntityExtensions.cs b/src/Squidex.Domain.Apps.Entities/EntityExtensions.cs new file mode 100644 index 000000000..b5c78cd43 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/EntityExtensions.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities +{ + public static class EntityExtensions + { + public static NamedId NamedId(this IAppEntity entity) + { + return new NamedId(entity.Id, entity.Name); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs index ab4851731..4e937c96e 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs @@ -142,49 +142,73 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards fieldIndex++; fieldPrefix = $"Fields[{fieldIndex}]"; - if (!field.Partitioning.IsValidPartitioning()) - { - e(Not.Valid("Partitioning"), $"{fieldPrefix}.{nameof(field.Partitioning)}"); - } + ValidateRootField(field, fieldPrefix, e); + } - ValidateField(field, fieldPrefix, e); + if (command.Fields.Select(x => x?.Name).Distinct().Count() != command.Fields.Count) + { + e("Fields cannot have duplicate names.", nameof(command.Fields)); + } + } + } - if (field.Nested?.Count > 0) - { - if (field.Properties is ArrayFieldProperties) - { - var nestedIndex = 0; - var nestedPrefix = string.Empty; + private static void ValidateRootField(UpsertSchemaField field, string prefix, AddValidation e) + { + if (field == null) + { + e(Not.Defined("Field"), prefix); + } + else + { + if (!field.Partitioning.IsValidPartitioning()) + { + e(Not.Valid("Partitioning"), $"{prefix}.{nameof(field.Partitioning)}"); + } - foreach (var nestedField in field.Nested) - { - nestedIndex++; - nestedPrefix = $"{fieldPrefix}.Nested[{nestedIndex}]"; + ValidateField(field, prefix, e); - if (nestedField.Properties is ArrayFieldProperties) - { - e("Nested field cannot be array fields.", $"{nestedPrefix}.{nameof(nestedField.Properties)}"); - } + if (field.Nested?.Count > 0) + { + if (field.Properties is ArrayFieldProperties) + { + var nestedIndex = 0; + var nestedPrefix = string.Empty; - ValidateField(nestedField, nestedPrefix, e); - } - } - else if (field.Nested.Count > 0) + foreach (var nestedField in field.Nested) { - e("Only array fields can have nested fields.", $"{fieldPrefix}.{nameof(field.Partitioning)}"); - } + nestedIndex++; + nestedPrefix = $"{prefix}.Nested[{nestedIndex}]"; - if (field.Nested.Select(x => x.Name).Distinct().Count() != field.Nested.Count) - { - e("Fields cannot have duplicate names.", $"{fieldPrefix}.Nested"); + ValidateNestedField(nestedField, nestedPrefix, e); } } + else if (field.Nested.Count > 0) + { + e("Only array fields can have nested fields.", $"{prefix}.{nameof(field.Partitioning)}"); + } + + if (field.Nested.Select(x => x.Name).Distinct().Count() != field.Nested.Count) + { + e("Fields cannot have duplicate names.", $"{prefix}.Nested"); + } } + } + } - if (command.Fields.Select(x => x.Name).Distinct().Count() != command.Fields.Count) + private static void ValidateNestedField(UpsertSchemaNestedField nestedField, string prefix, AddValidation e) + { + if (nestedField == null) + { + e(Not.Defined("Field"), prefix); + } + else + { + if (nestedField.Properties is ArrayFieldProperties) { - e("Fields cannot have duplicate names.", nameof(command.Fields)); + e("Nested field cannot be array fields.", $"{prefix}.{nameof(nestedField.Properties)}"); } + + ValidateField(nestedField, prefix, e); } } diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs index 5905b66fd..44e96fa7f 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs @@ -321,7 +321,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas { if (id.HasValue && Snapshot.SchemaDef.FieldsById.TryGetValue(id.Value, out var field)) { - return NamedId.Of(field.Id, field.Name); + return field.NamedId(); } return null; @@ -333,13 +333,13 @@ namespace Squidex.Domain.Apps.Entities.Schemas { if (Snapshot.SchemaDef.FieldsById.TryGetValue(pc.ParentFieldId.Value, out var field)) { - pe.ParentFieldId = NamedId.Of(field.Id, field.Name); + pe.ParentFieldId = field.NamedId(); if (command is FieldCommand fc && @event is FieldEvent fe) { if (field is IArrayField arrayField && arrayField.FieldsById.TryGetValue(fc.FieldId, out var nestedField)) { - fe.FieldId = NamedId.Of(nestedField.Id, nestedField.Name); + fe.FieldId = nestedField.NamedId(); } } } @@ -357,7 +357,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas { if (@event.SchemaId == null) { - @event.SchemaId = NamedId.Of(Snapshot.Id, Snapshot.SchemaDef.Name); + @event.SchemaId = Snapshot.NamedId(); } if (@event.AppId == null) diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppPatternsConfigured.cs b/src/Squidex.Domain.Apps.Events/Apps/AppPatternsConfigured.cs new file mode 100644 index 000000000..4ad20a734 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Apps/AppPatternsConfigured.cs @@ -0,0 +1,18 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Apps +{ + [EventType(nameof(AppPatternsConfigured))] + public sealed class AppPatternsConfigured : AppEvent + { + public AppPattern[] Patterns { get; set; } + } +} diff --git a/src/Squidex.Shared/Permissions.cs b/src/Squidex.Shared/Permissions.cs index 964925efb..4ab584b23 100644 --- a/src/Squidex.Shared/Permissions.cs +++ b/src/Squidex.Shared/Permissions.cs @@ -80,9 +80,7 @@ namespace Squidex.Shared public const string AppPatterns = "squidex.apps.{app}.patterns"; public const string AppPatternsRead = "squidex.apps.{app}.patterns.read"; - public const string AppPatternsCreate = "squidex.apps.{app}.patterns.create"; public const string AppPatternsUpdate = "squidex.apps.{app}.patterns.update"; - public const string AppPatternsDelete = "squidex.apps.{app}.patterns.delete"; public const string AppBackups = "squidex.apps.{app}.backups"; public const string AppBackupsRead = "squidex.apps.{app}.backups.read"; diff --git a/src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs b/src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs index 06e050784..0b7d872e5 100644 --- a/src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs +++ b/src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs @@ -57,7 +57,7 @@ namespace Squidex.Web.CommandMiddlewares throw new InvalidOperationException("Cannot resolve app."); } - return NamedId.Of(appFeature.App.Id, appFeature.App.Name); + return appFeature.App.NamedId(); } } } \ No newline at end of file diff --git a/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs b/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs index 672a16b74..a64798783 100644 --- a/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs +++ b/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs @@ -69,7 +69,7 @@ namespace Squidex.Web.CommandMiddlewares if (appFeature?.App != null) { - appId = NamedId.Of(appFeature.App.Id, appFeature.App.Name); + appId = appFeature.App.NamedId(); } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs index fd4fac1c5..099940efc 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs @@ -11,7 +11,6 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; using Squidex.Areas.Api.Controllers.Apps.Models; -using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure.Commands; using Squidex.Shared; using Squidex.Web; @@ -55,73 +54,24 @@ namespace Squidex.Areas.Api.Controllers.Apps } /// - /// Create a new app pattern. - /// - /// The name of the app. - /// Pattern to be added to the app. - /// - /// 201 => Pattern generated. - /// 400 => Pattern request not valid. - /// 404 => App not found. - /// - [HttpPost] - [Route("apps/{app}/patterns/")] - [ProducesResponseType(typeof(AppPatternDto), 201)] - [ApiPermission(Permissions.AppPatternsCreate)] - [ApiCosts(1)] - public async Task PostPattern(string app, [FromBody] UpdatePatternDto request) - { - var command = request.ToAddCommand(); - - await CommandBus.PublishAsync(command); - - var response = AppPatternDto.FromCommand(command); - - return CreatedAtAction(nameof(GetPatterns), new { app }, response); - } - - /// - /// Update an existing app pattern. + /// Updates all app pattern. /// /// The name of the app. /// The id of the pattern to be updated. - /// Pattern to be updated for the app. + /// Patterns to be updated for the app. /// - /// 204 => Pattern updated. - /// 400 => Pattern request not valid. - /// 404 => Pattern or app not found. + /// 204 => Patterns updated. + /// 400 => Patterns request not valid. + /// 404 => App not found. /// [HttpPut] - [Route("apps/{app}/patterns/{id}/")] + [Route("apps/{app}/patterns/")] [ProducesResponseType(typeof(AppPatternDto), 201)] [ApiPermission(Permissions.AppPatternsUpdate)] [ApiCosts(1)] - public async Task UpdatePattern(string app, Guid id, [FromBody] UpdatePatternDto request) - { - await CommandBus.PublishAsync(request.ToUpdateCommand(id)); - - return NoContent(); - } - - /// - /// Delete an existing app pattern. - /// - /// The name of the app. - /// The id of the pattern to be deleted. - /// - /// 204 => Pattern removed. - /// 404 => Pattern or app not found. - /// - /// - /// Schemas using this pattern will still function using the same Regular Expression. - /// - [HttpDelete] - [Route("apps/{app}/patterns/{id}/")] - [ApiPermission(Permissions.AppPatternsDelete)] - [ApiCosts(1)] - public async Task DeletePattern(string app, Guid id) + public async Task UpdatePatterns(string app, Guid id, [FromBody] ConfigurePatternsDto request) { - await CommandBus.PublishAsync(new DeletePattern { PatternId = id }); + await CommandBus.PublishAsync(request.ToConfigureCommand()); return NoContent(); } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppPatternDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppPatternDto.cs index ad8efa50f..a3132e39f 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppPatternDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppPatternDto.cs @@ -9,18 +9,12 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Squidex.Domain.Apps.Core.Apps; -using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure.Reflection; namespace Squidex.Areas.Api.Controllers.Apps.Models { public sealed class AppPatternDto { - /// - /// Unique id of the pattern. - /// - public Guid PatternId { get; set; } - /// /// The name of the suggestion. /// @@ -40,12 +34,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models public static AppPatternDto FromKvp(KeyValuePair kvp) { - return SimpleMapper.Map(kvp.Value, new AppPatternDto { PatternId = kvp.Key }); - } - - public static AppPatternDto FromCommand(AddPattern command) - { - return SimpleMapper.Map(command, new AppPatternDto()); + return SimpleMapper.Map(kvp.Value, new AppPatternDto()); } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/ConfigurePatternsDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/ConfigurePatternsDto.cs new file mode 100644 index 000000000..f99f7c38e --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/ConfigurePatternsDto.cs @@ -0,0 +1,31 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class ConfigurePatternsDto + { + /// + /// The list of patterns. + /// + [Required] + public AppPatternDto[] Patterns { get; set; } + + public ConfigurePatterns ToConfigureCommand() + { + return new ConfigurePatterns + { + Patterns = Patterns?.Select(p => SimpleMapper.Map(p, new UpsertAppPattern())).ToArray() + }; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdatePatternDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdatePatternDto.cs deleted file mode 100644 index 4c225880f..000000000 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdatePatternDto.cs +++ /dev/null @@ -1,44 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System; -using System.ComponentModel.DataAnnotations; -using Squidex.Domain.Apps.Entities.Apps.Commands; -using Squidex.Infrastructure.Reflection; - -namespace Squidex.Areas.Api.Controllers.Apps.Models -{ - public class UpdatePatternDto - { - /// - /// The name of the suggestion. - /// - [Required] - public string Name { get; set; } - - /// - /// The regex pattern. - /// - [Required] - public string Pattern { get; set; } - - /// - /// The regex message. - /// - public string Message { get; set; } - - public AddPattern ToAddCommand() - { - return SimpleMapper.Map(this, new AddPattern()); - } - - public UpdatePattern ToUpdateCommand(Guid id) - { - return SimpleMapper.Map(this, new UpdatePattern { PatternId = id }); - } - } -} diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index 1b378c173..923f6a830 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -144,7 +144,7 @@ namespace Squidex.Config.Domain if (!string.IsNullOrWhiteSpace(pattern.Key) && !string.IsNullOrWhiteSpace(pattern.Value)) { - result[Guid.NewGuid()] = new AppPattern(pattern.Key, pattern.Value); + result.Add(new AppPattern(pattern.Key, pattern.Value)); } } diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppPatternsTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppPatternsTests.cs index 56d615159..550c3320b 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppPatternsTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppPatternsTests.cs @@ -6,6 +6,7 @@ // ========================================================================== using System; +using System.Linq; using FluentAssertions; using Squidex.Domain.Apps.Core.Apps; using Xunit; @@ -25,6 +26,31 @@ namespace Squidex.Domain.Apps.Core.Model.Apps patterns_0 = AppPatterns.Empty.Add(firstId, "Default", "Default Pattern", "Message"); } + [Fact] + public void Should_create_patterns() + { + var pattern = new AppPattern("NewPattern", "New Pattern", "Message"); + var patterns = AppPatterns.Create(Enumerable.Repeat(pattern, 1)); + + Assert.Same(pattern, patterns.Values.First()); + } + + [Fact] + public void Should_create_empty_from_null_enumerable() + { + var patterns = AppPatterns.Create(null); + + Assert.Same(AppPatterns.Empty, patterns); + } + + [Fact] + public void Should_create_empty_from_empty_enumerable() + { + var patterns = AppPatterns.Create(Enumerable.Empty()); + + Assert.Same(AppPatterns.Empty, patterns); + } + [Fact] public void Should_add_pattern() { diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs index 1ac33f4b1..7e7c2933e 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs @@ -7,8 +7,10 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using FakeItEasy; +using FluentAssertions; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Services; @@ -37,9 +39,6 @@ namespace Squidex.Domain.Apps.Entities.Apps private readonly string planIdPaid = "premium"; private readonly string planIdFree = "free"; private readonly AppGrain sut; - private readonly Guid patternId1 = Guid.NewGuid(); - private readonly Guid patternId2 = Guid.NewGuid(); - private readonly Guid patternId3 = Guid.NewGuid(); private readonly InitialPatterns initialPatterns; protected override Guid Id @@ -60,8 +59,8 @@ namespace Squidex.Domain.Apps.Entities.Apps initialPatterns = new InitialPatterns { - { patternId1, new AppPattern("Number", "[0-9]") }, - { patternId2, new AppPattern("Numbers", "[0-9]*") } + new AppPattern("Number", "[0-9]"), + new AppPattern("Numbers", "[0-9]*") }; sut = new AppGrain(initialPatterns, Store, A.Dummy(), appPlansProvider, appPlansBillingManager, userResolver); @@ -84,17 +83,16 @@ namespace Squidex.Domain.Apps.Entities.Apps var result = await sut.ExecuteAsync(CreateCommand(command)); - result.ShouldBeEquivalent(EntityCreatedResult.Create(Id, 4)); + result.ShouldBeEquivalent(EntityCreatedResult.Create(Id, 3)); Assert.Equal(AppName, sut.Snapshot.Name); LastEvents .ShouldHaveSameEvents( CreateEvent(new AppCreated { Name = AppName }), - CreateEvent(new AppContributorAssigned { ContributorId = User.Identifier, Role = Role.Owner }), CreateEvent(new AppLanguageAdded { Language = Language.EN }), - CreateEvent(new AppPatternAdded { PatternId = patternId1, Name = "Number", Pattern = "[0-9]" }), - CreateEvent(new AppPatternAdded { PatternId = patternId2, Name = "Numbers", Pattern = "[0-9]*" }) + CreateEvent(new AppContributorAssigned { ContributorId = User.Identifier, Role = Role.Owner }), + CreateEvent(new AppPatternsConfigured { Patterns = initialPatterns.ToArray() }) ); } @@ -103,7 +101,7 @@ namespace Squidex.Domain.Apps.Entities.Apps { var command = new ChangePlan { PlanId = planIdPaid }; - A.CallTo(() => appPlansBillingManager.ChangePlanAsync(User.Identifier, AppId, AppName, planIdPaid)) + A.CallTo(() => appPlansBillingManager.ChangePlanAsync(User.Identifier, AppNamedId, planIdPaid)) .Returns(new PlanChangedResult()); await ExecuteCreateAsync(); @@ -125,10 +123,10 @@ namespace Squidex.Domain.Apps.Entities.Apps { var command = new ChangePlan { PlanId = planIdFree }; - A.CallTo(() => appPlansBillingManager.ChangePlanAsync(User.Identifier, AppId, AppName, planIdPaid)) + A.CallTo(() => appPlansBillingManager.ChangePlanAsync(User.Identifier, AppNamedId, planIdPaid)) .Returns(new PlanChangedResult()); - A.CallTo(() => appPlansBillingManager.ChangePlanAsync(User.Identifier, AppId, AppName, planIdFree)) + A.CallTo(() => appPlansBillingManager.ChangePlanAsync(User.Identifier, AppNamedId, planIdFree)) .Returns(new PlanResetResult()); await ExecuteCreateAsync(); @@ -151,7 +149,7 @@ namespace Squidex.Domain.Apps.Entities.Apps { var command = new ChangePlan { PlanId = planIdPaid }; - A.CallTo(() => appPlansBillingManager.ChangePlanAsync(User.Identifier, AppId, AppName, planIdPaid)) + A.CallTo(() => appPlansBillingManager.ChangePlanAsync(User.Identifier, AppNamedId, planIdPaid)) .Returns(new RedirectToCheckoutResult(new Uri("http://squidex.io"))); await ExecuteCreateAsync(); @@ -172,9 +170,9 @@ namespace Squidex.Domain.Apps.Entities.Apps var result = await sut.ExecuteAsync(CreateCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(5)); + result.ShouldBeEquivalent(new EntitySavedResult(4)); - A.CallTo(() => appPlansBillingManager.ChangePlanAsync(User.Identifier, AppId, AppName, planIdPaid)) + A.CallTo(() => appPlansBillingManager.ChangePlanAsync(User.Identifier, AppNamedId, planIdPaid)) .MustNotHaveHappened(); } @@ -187,7 +185,7 @@ namespace Squidex.Domain.Apps.Entities.Apps var result = await sut.ExecuteAsync(CreateCommand(command)); - result.ShouldBeEquivalent(EntityCreatedResult.Create(contributorId, 5)); + result.ShouldBeEquivalent(EntityCreatedResult.Create(contributorId, 4)); Assert.Equal(Role.Editor, sut.Snapshot.Contributors[contributorId]); @@ -207,7 +205,7 @@ namespace Squidex.Domain.Apps.Entities.Apps var result = await sut.ExecuteAsync(CreateCommand(command)); - result.ShouldBeEquivalent(EntityCreatedResult.Create(contributorId, 6)); + result.ShouldBeEquivalent(EntityCreatedResult.Create(contributorId, 5)); Assert.Equal(Role.Owner, sut.Snapshot.Contributors[contributorId]); @@ -227,7 +225,7 @@ namespace Squidex.Domain.Apps.Entities.Apps var result = await sut.ExecuteAsync(CreateCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(6)); + result.ShouldBeEquivalent(new EntitySavedResult(5)); Assert.False(sut.Snapshot.Contributors.ContainsKey(contributorId)); @@ -246,7 +244,7 @@ namespace Squidex.Domain.Apps.Entities.Apps var result = await sut.ExecuteAsync(CreateCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(5)); + result.ShouldBeEquivalent(new EntitySavedResult(4)); Assert.True(sut.Snapshot.Clients.ContainsKey(clientId)); @@ -266,7 +264,7 @@ namespace Squidex.Domain.Apps.Entities.Apps var result = await sut.ExecuteAsync(CreateCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(6)); + result.ShouldBeEquivalent(new EntitySavedResult(5)); Assert.False(sut.Snapshot.Clients.ContainsKey(clientId)); @@ -286,7 +284,7 @@ namespace Squidex.Domain.Apps.Entities.Apps var result = await sut.ExecuteAsync(CreateCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(7)); + result.ShouldBeEquivalent(new EntitySavedResult(6)); Assert.Equal(clientNewName, sut.Snapshot.Clients[clientId].Name); @@ -306,7 +304,7 @@ namespace Squidex.Domain.Apps.Entities.Apps var result = await sut.ExecuteAsync(CreateCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(5)); + result.ShouldBeEquivalent(new EntitySavedResult(4)); Assert.True(sut.Snapshot.LanguagesConfig.Contains(Language.DE)); @@ -326,7 +324,7 @@ namespace Squidex.Domain.Apps.Entities.Apps var result = await sut.ExecuteAsync(CreateCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(6)); + result.ShouldBeEquivalent(new EntitySavedResult(5)); Assert.False(sut.Snapshot.LanguagesConfig.Contains(Language.DE)); @@ -346,7 +344,7 @@ namespace Squidex.Domain.Apps.Entities.Apps var result = await sut.ExecuteAsync(CreateCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(6)); + result.ShouldBeEquivalent(new EntitySavedResult(5)); Assert.True(sut.Snapshot.LanguagesConfig.Contains(Language.DE)); @@ -365,7 +363,7 @@ namespace Squidex.Domain.Apps.Entities.Apps var result = await sut.ExecuteAsync(CreateCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(5)); + result.ShouldBeEquivalent(new EntitySavedResult(4)); Assert.Equal(5, sut.Snapshot.Roles.Count); @@ -385,7 +383,7 @@ namespace Squidex.Domain.Apps.Entities.Apps var result = await sut.ExecuteAsync(CreateCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(6)); + result.ShouldBeEquivalent(new EntitySavedResult(5)); Assert.Equal(4, sut.Snapshot.Roles.Count); @@ -405,7 +403,7 @@ namespace Squidex.Domain.Apps.Entities.Apps var result = await sut.ExecuteAsync(CreateCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(6)); + result.ShouldBeEquivalent(new EntitySavedResult(5)); LastEvents .ShouldHaveSameEvents( @@ -414,59 +412,31 @@ namespace Squidex.Domain.Apps.Entities.Apps } [Fact] - public async Task AddPattern_should_create_events_and_update_state() + public async Task ConfigurePatterns_should_create_events_and_update_state() { - var command = new AddPattern { PatternId = patternId3, Name = "Any", Pattern = ".*", Message = "Msg" }; - - await ExecuteCreateAsync(); - - var result = await sut.ExecuteAsync(CreateCommand(command)); - - result.ShouldBeEquivalent(new EntitySavedResult(5)); - - Assert.Equal(initialPatterns.Count + 1, sut.Snapshot.Patterns.Count); + var newPatterns = new[] + { + new UpsertAppPattern { Name = "chars", Pattern = "[a-z]*", Message = "Must be a character." } + }; - LastEvents - .ShouldHaveSameEvents( - CreateEvent(new AppPatternAdded { PatternId = patternId3, Name = "Any", Pattern = ".*", Message = "Msg" }) - ); - } + var resultPatterns = new[] + { + new AppPattern("chars", "[a-z]*", "Must be a character.") + }; - [Fact] - public async Task DeletePattern_should_create_events_and_update_state() - { - var command = new DeletePattern { PatternId = patternId3 }; + var command = new ConfigurePatterns { Patterns = newPatterns }; await ExecuteCreateAsync(); - await ExecuteAddPatternAsync(); var result = await sut.ExecuteAsync(CreateCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(6)); - - Assert.Equal(initialPatterns.Count, sut.Snapshot.Patterns.Count); - - LastEvents - .ShouldHaveSameEvents( - CreateEvent(new AppPatternDeleted { PatternId = patternId3 }) - ); - } - - [Fact] - public async Task UpdatePattern_should_create_events_and_update_state() - { - var command = new UpdatePattern { PatternId = patternId3, Name = "Any", Pattern = ".*", Message = "Msg" }; - - await ExecuteCreateAsync(); - await ExecuteAddPatternAsync(); - - var result = await sut.ExecuteAsync(CreateCommand(command)); + result.ShouldBeEquivalent(new EntitySavedResult(4)); - result.ShouldBeEquivalent(new EntitySavedResult(6)); + resultPatterns.Should().BeEquivalentTo(sut.Snapshot.Patterns.Values.ToArray()); LastEvents .ShouldHaveSameEvents( - CreateEvent(new AppPatternUpdated { PatternId = patternId3, Name = "Any", Pattern = ".*", Message = "Msg" }) + CreateEvent(new AppPatternsConfigured { Patterns = resultPatterns }) ); } @@ -479,22 +449,17 @@ namespace Squidex.Domain.Apps.Entities.Apps var result = await sut.ExecuteAsync(CreateCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(5)); + result.ShouldBeEquivalent(new EntitySavedResult(4)); LastEvents .ShouldHaveSameEvents( CreateEvent(new AppArchived()) ); - A.CallTo(() => appPlansBillingManager.ChangePlanAsync(command.Actor.Identifier, AppId, AppName, null)) + A.CallTo(() => appPlansBillingManager.ChangePlanAsync(command.Actor.Identifier, AppNamedId, null)) .MustHaveHappened(); } - private Task ExecuteAddPatternAsync() - { - return sut.ExecuteAsync(CreateCommand(new AddPattern { PatternId = patternId3, Name = "Name", Pattern = ".*" })); - } - private Task ExecuteCreateAsync() { return sut.ExecuteAsync(CreateCommand(new CreateApp { Name = AppName })); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Billing/NoopAppPlanBillingManagerTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Billing/NoopAppPlanBillingManagerTests.cs index 39172ada7..547db7299 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Billing/NoopAppPlanBillingManagerTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Billing/NoopAppPlanBillingManagerTests.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Threading.Tasks; using Squidex.Domain.Apps.Entities.Apps.Services.Implementations; using Xunit; @@ -25,7 +24,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Billing [Fact] public async Task Should_do_nothing_when_changing_plan() { - await sut.ChangePlanAsync(null, Guid.Empty, null, null); + await sut.ChangePlanAsync(null, null, null); } [Fact] diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppPatternsTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppPatternsTests.cs index 3bb1902c1..155432071 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppPatternsTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Guards/GuardAppPatternsTests.cs @@ -5,176 +5,135 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; -using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; using Xunit; -#pragma warning disable SA1310 // Field names must not contain underscore - namespace Squidex.Domain.Apps.Entities.Apps.Guards { public class GuardAppPatternsTests { - private readonly Guid patternId = Guid.NewGuid(); - private readonly AppPatterns patterns_0 = AppPatterns.Empty; - - [Fact] - public void CanAdd_should_throw_exception_if_name_empty() - { - var command = new AddPattern { PatternId = patternId, Name = string.Empty, Pattern = ".*" }; - - ValidationAssert.Throws(() => GuardAppPatterns.CanAdd(patterns_0, command), - new ValidationError("Name is required.", "Name")); - } - - [Fact] - public void CanAdd_should_throw_exception_if_pattern_empty() - { - var command = new AddPattern { PatternId = patternId, Name = "any", Pattern = string.Empty }; - - ValidationAssert.Throws(() => GuardAppPatterns.CanAdd(patterns_0, command), - new ValidationError("Pattern is required.", "Pattern")); - } - - [Fact] - public void CanAdd_should_throw_exception_if_pattern_not_valid() - { - var command = new AddPattern { PatternId = patternId, Name = "any", Pattern = "[0-9{1}" }; - - ValidationAssert.Throws(() => GuardAppPatterns.CanAdd(patterns_0, command), - new ValidationError("Pattern is not a valid value.", "Pattern")); - } - - [Fact] - public void CanAdd_should_throw_exception_if_name_exists() - { - var patterns_1 = patterns_0.Add(Guid.NewGuid(), "any", "[a-z]", "Message"); - - var command = new AddPattern { PatternId = patternId, Name = "any", Pattern = ".*" }; - - ValidationAssert.Throws(() => GuardAppPatterns.CanAdd(patterns_1, command), - new ValidationError("A pattern with the same name already exists.")); - } - - [Fact] - public void CanAdd_should_throw_exception_if_pattern_exists() - { - var patterns_1 = patterns_0.Add(Guid.NewGuid(), "any", "[a-z]", "Message"); - - var command = new AddPattern { PatternId = patternId, Name = "other", Pattern = "[a-z]" }; - - ValidationAssert.Throws(() => GuardAppPatterns.CanAdd(patterns_1, command), - new ValidationError("This pattern already exists but with another name.")); - } - [Fact] - public void CanAdd_should_not_throw_exception_if_success() + public void CanConfigure_should_throw_exception_if_two_patterns_with_same_pattern_exist() { - var command = new AddPattern { PatternId = patternId, Name = "any", Pattern = ".*" }; - - GuardAppPatterns.CanAdd(patterns_0, command); + var command = new ConfigurePatterns + { + Patterns = new[] + { + new UpsertAppPattern { Name = "name1", Pattern = "[a-z]" }, + new UpsertAppPattern { Name = "name2", Pattern = "[a-z]" } + } + }; + + ValidationAssert.Throws(() => GuardAppPatterns.CanConfigure(command), + new ValidationError("Two patterns with the same expression exist.", "Patterns")); } [Fact] - public void CanDelete_should_throw_exception_if_pattern_not_found() + public void CanConfigure_should_throw_exception_if_two_patterns_with_same_name_exist() { - var command = new DeletePattern { PatternId = patternId }; - - Assert.Throws(() => GuardAppPatterns.CanDelete(patterns_0, command)); + var command = new ConfigurePatterns + { + Patterns = new[] + { + new UpsertAppPattern { Name = "name", Pattern = "[a-z]" }, + new UpsertAppPattern { Name = "name", Pattern = "[0-9]" } + } + }; + + ValidationAssert.Throws(() => GuardAppPatterns.CanConfigure(command), + new ValidationError("Two patterns with the same name exist.", "Patterns")); } [Fact] - public void CanDelete_should_not_throw_exception_if_success() + public void CanConfigure_should_throw_exception_if_expression_not_valid() { - var patterns_1 = patterns_0.Add(patternId, "any", ".*", "Message"); - - var command = new DeletePattern { PatternId = patternId }; - - GuardAppPatterns.CanDelete(patterns_1, command); + var command = new ConfigurePatterns + { + Patterns = new[] + { + new UpsertAppPattern { Name = "name", Pattern = "((" } + } + }; + + ValidationAssert.Throws(() => GuardAppPatterns.CanConfigure(command), + new ValidationError("Expression is not a valid value.", "Patterns[1].Pattern")); } [Fact] - public void CanUpdate_should_throw_exception_if_name_empty() + public void CanConfigure_should_throw_exception_if_expression_is_empty() { - var patterns_1 = patterns_0.Add(patternId, "any", ".*", "Message"); - - var command = new UpdatePattern { PatternId = patternId, Name = string.Empty, Pattern = ".*" }; - - ValidationAssert.Throws(() => GuardAppPatterns.CanUpdate(patterns_1, command), - new ValidationError("Name is required.", "Name")); + var command = new ConfigurePatterns + { + Patterns = new[] + { + new UpsertAppPattern { Name = "name" } + } + }; + + ValidationAssert.Throws(() => GuardAppPatterns.CanConfigure(command), + new ValidationError("Expression is required.", "Patterns[1].Pattern")); } [Fact] - public void CanUpdate_should_throw_exception_if_pattern_empty() + public void CanConfigure_should_throw_exception_if_name_is_empty() { - var patterns_1 = patterns_0.Add(patternId, "any", ".*", "Message"); - - var command = new UpdatePattern { PatternId = patternId, Name = "any", Pattern = string.Empty }; - - ValidationAssert.Throws(() => GuardAppPatterns.CanUpdate(patterns_1, command), - new ValidationError("Pattern is required.", "Pattern")); + var command = new ConfigurePatterns + { + Patterns = new[] + { + new UpsertAppPattern { Pattern = "[0-9]" } + } + }; + + ValidationAssert.Throws(() => GuardAppPatterns.CanConfigure(command), + new ValidationError("Name is required.", "Patterns[1].Name")); } [Fact] - public void CanUpdate_should_throw_exception_if_pattern_not_valid() + public void CanConfigure_should_throw_exception_if_pattern_is_null() { - var patterns_1 = patterns_0.Add(patternId, "any", ".*", "Message"); - - var command = new UpdatePattern { PatternId = patternId, Name = "any", Pattern = "[0-9{1}" }; - - ValidationAssert.Throws(() => GuardAppPatterns.CanUpdate(patterns_1, command), - new ValidationError("Pattern is not a valid value.", "Pattern")); - } - - [Fact] - public void CanUpdate_should_throw_exception_if_name_exists() - { - var id1 = Guid.NewGuid(); - var id2 = Guid.NewGuid(); - - var patterns_1 = patterns_0.Add(id1, "Pattern1", "[0-5]", "Message"); - var patterns_2 = patterns_1.Add(id2, "Pattern2", "[0-4]", "Message"); - - var command = new UpdatePattern { PatternId = id2, Name = "Pattern1", Pattern = "[0-4]" }; - - ValidationAssert.Throws(() => GuardAppPatterns.CanUpdate(patterns_2, command), - new ValidationError("A pattern with the same name already exists.")); + var command = new ConfigurePatterns + { + Patterns = new UpsertAppPattern[] + { + null + } + }; + + ValidationAssert.Throws(() => GuardAppPatterns.CanConfigure(command), + new ValidationError("Pattern is required.", "Patterns[1]")); } [Fact] - public void CanUpdate_should_throw_exception_if_pattern_exists() + public void CanConfigure_should_not_throw_exception_if_patterns_is_valid() { - var id1 = Guid.NewGuid(); - var id2 = Guid.NewGuid(); - - var patterns_1 = patterns_0.Add(id1, "Pattern1", "[0-5]", "Message"); - var patterns_2 = patterns_1.Add(id2, "Pattern2", "[0-4]", "Message"); - - var command = new UpdatePattern { PatternId = id2, Name = "Pattern2", Pattern = "[0-5]" }; - - ValidationAssert.Throws(() => GuardAppPatterns.CanUpdate(patterns_2, command), - new ValidationError("This pattern already exists but with another name.")); + var command = new ConfigurePatterns + { + Patterns = new[] + { + new UpsertAppPattern { Name = "number", Pattern = "[0-9]" } + } + }; + + GuardAppPatterns.CanConfigure(command); } [Fact] - public void CanUpdate_should_throw_exception_if_pattern_does_not_exists() + public void CanConfigure_should_not_throw_exception_if_patterns_is_null() { - var command = new UpdatePattern { PatternId = patternId, Name = "Pattern1", Pattern = ".*" }; + var command = new ConfigurePatterns(); - Assert.Throws(() => GuardAppPatterns.CanUpdate(patterns_0, command)); + GuardAppPatterns.CanConfigure(command); } [Fact] - public void CanUpdate_should_not_throw_exception_if_pattern_exist_with_valid_command() + public void CanConfigure_should_not_throw_exception_if_patterns_is_empty() { - var patterns_1 = patterns_0.Add(patternId, "any", ".*", "Message"); - - var command = new UpdatePattern { PatternId = patternId, Name = "Pattern1", Pattern = ".*" }; + var command = new ConfigurePatterns { Patterns = new UpsertAppPattern[0] }; - GuardAppPatterns.CanUpdate(patterns_1, command); + GuardAppPatterns.CanConfigure(command); } } } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/History/Notifications/NotificationEmailEventConsumerTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/History/Notifications/NotificationEmailEventConsumerTests.cs index 815c45dab..15e5b1cd6 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/History/Notifications/NotificationEmailEventConsumerTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/History/Notifications/NotificationEmailEventConsumerTests.cs @@ -174,7 +174,7 @@ namespace Squidex.Domain.Apps.Entities.History.Notifications var @event = new AppContributorAssigned { Actor = new RefToken(assignerType, assignerId), - AppId = new NamedId(Guid.NewGuid(), appName), + AppId = NamedId.Of(Guid.NewGuid(), appName), ContributorId = assigneeId, IsCreated = isNewUser, IsAdded = isNewContributor, diff --git a/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithAppIdCommandMiddlewareTests.cs b/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithAppIdCommandMiddlewareTests.cs index 16398cc53..191d604fb 100644 --- a/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithAppIdCommandMiddlewareTests.cs +++ b/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithAppIdCommandMiddlewareTests.cs @@ -81,7 +81,7 @@ namespace Squidex.Web.CommandMiddlewares [Fact] public async Task Should_assign_app_id_to_app_self_command() { - var command = new AddPattern(); + var command = new ConfigurePatterns(); var context = new CommandContext(command, commandBus); await sut.HandleAsync(context); @@ -92,7 +92,7 @@ namespace Squidex.Web.CommandMiddlewares [Fact] public async Task Should_not_override_app_id() { - var command = new AddPattern { AppId = Guid.NewGuid() }; + var command = new ConfigurePatterns { AppId = Guid.NewGuid() }; var context = new CommandContext(command, commandBus); await sut.HandleAsync(context); diff --git a/tools/Migrate_01/Migrations/AddPatterns.cs b/tools/Migrate_01/Migrations/AddPatterns.cs index 14dd415ee..7d699fc37 100644 --- a/tools/Migrate_01/Migrations/AddPatterns.cs +++ b/tools/Migrate_01/Migrations/AddPatterns.cs @@ -5,7 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; +using System.Linq; using System.Threading.Tasks; using Orleans; using Squidex.Domain.Apps.Entities.Apps; @@ -13,6 +13,7 @@ using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Indexes; using Squidex.Infrastructure.Migrations; using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.Reflection; namespace Migrate_01.Migrations { @@ -32,6 +33,11 @@ namespace Migrate_01.Migrations { var ids = await grainFactory.GetGrain(SingleGrain.Id).GetAppIdsAsync(); + var command = new ConfigurePatterns + { + Patterns = initialPatterns.Select(p => SimpleMapper.Map(p, new UpsertAppPattern())).ToArray() + }; + foreach (var id in ids) { var app = grainFactory.GetGrain(id); @@ -40,21 +46,10 @@ namespace Migrate_01.Migrations if (state.Value.Patterns.Count == 0) { - foreach (var pattern in initialPatterns.Values) - { - var command = - new AddPattern - { - Actor = state.Value.CreatedBy, - AppId = state.Value.Id, - Name = pattern.Name, - PatternId = Guid.NewGuid(), - Pattern = pattern.Pattern, - Message = pattern.Message - }; - - await app.ExecuteAsync(command); - } + command.AppId = state.Value.Id; + command.Actor = state.Value.CreatedBy; + + await app.ExecuteAsync(command); } } } From 6f10534af3d616e38be452eb1a7c9032106cbd35 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 2 Jun 2019 12:41:43 +0200 Subject: [PATCH 002/107] DL parameter to prevent download. --- .../Assets/AssetContentController.cs | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs index 65dd776eb..8d947d64c 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs @@ -52,6 +52,7 @@ namespace Squidex.Areas.Api.Controllers.Assets /// The id or slug of the asset. /// Optional suffix that can be used to seo-optimize the link to the image Has not effect. /// The optional version of the asset. + /// Set it to 0 to prevent download. /// The target width of the asset, if it is an image. /// The target height of the asset, if it is an image. /// Optional image quality, it is is an jpeg image. @@ -67,6 +68,7 @@ namespace Squidex.Areas.Api.Controllers.Assets [AllowAnonymous] public async Task GetAssetContentBySlug(string app, string idOrSlug, string more, [FromQuery] long version = EtagVersion.Any, + [FromQuery] int dl = 1, [FromQuery] int? width = null, [FromQuery] int? height = null, [FromQuery] int? quality = null, @@ -83,7 +85,7 @@ namespace Squidex.Areas.Api.Controllers.Assets entity = await assetRepository.FindAssetBySlugAsync(App.Id, idOrSlug); } - return DeliverAsset(entity, version, width, height, quality, mode); + return DeliverAsset(entity, version, width, height, quality, mode, dl); } /// @@ -92,6 +94,7 @@ namespace Squidex.Areas.Api.Controllers.Assets /// The id of the asset. /// Optional suffix that can be used to seo-optimize the link to the image Has not effect. /// The optional version of the asset. + /// Set it to 0 to prevent download. /// The target width of the asset, if it is an image. /// The target height of the asset, if it is an image. /// Optional image quality, it is is an jpeg image. @@ -106,6 +109,7 @@ namespace Squidex.Areas.Api.Controllers.Assets [ApiCosts(0.5)] public async Task GetAssetContent(Guid id, string more, [FromQuery] long version = EtagVersion.Any, + [FromQuery] int dl = 1, [FromQuery] int? width = null, [FromQuery] int? height = null, [FromQuery] int? quality = null, @@ -113,10 +117,10 @@ namespace Squidex.Areas.Api.Controllers.Assets { var entity = await assetRepository.FindAssetAsync(id); - return DeliverAsset(entity, version, width, height, quality, mode); + return DeliverAsset(entity, version, width, height, quality, mode, dl); } - private IActionResult DeliverAsset(IAssetEntity entity, long version, int? width, int? height, int? quality, string mode) + private IActionResult DeliverAsset(IAssetEntity entity, long version, int? width, int? height, int? quality, string mode, int download = 1) { if (entity == null || entity.FileVersion < version || width == 0 || height == 0 || quality == 0) { @@ -125,7 +129,7 @@ namespace Squidex.Areas.Api.Controllers.Assets Response.Headers[HeaderNames.ETag] = entity.FileVersion.ToString(); - return new FileCallbackResult(entity.MimeType, entity.FileName, true, async bodyStream => + var handler = new Func(async bodyStream => { var assetId = entity.Id.ToString(); @@ -179,6 +183,15 @@ namespace Squidex.Areas.Api.Controllers.Assets await assetStore.DownloadAsync(assetId, entity.FileVersion, null, bodyStream); } }); + + if (download == 1) + { + return new FileCallbackResult(entity.MimeType, entity.FileName, true, handler); + } + else + { + return new FileCallbackResult(entity.MimeType, null, true, handler); + } } private static FileStream GetTempStream() From cd8331bc186729498d988b1f477bd5f8dd71253d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 3 Jun 2019 08:36:49 +0200 Subject: [PATCH 003/107] Support for duplicate names in GraphQL. --- .../Schemas/FieldCollection.cs | 9 +- .../GraphQL/Types/ContentDataGraphType.cs | 57 ++-- .../Contents/GraphQL/Types/Extensions.cs | 41 +++ .../Contents/GraphQL/Types/NestedGraphType.cs | 31 ++- .../Contents/GraphQL/GraphQLQueriesTests.cs | 253 +++++++++++------- .../Contents/GraphQL/GraphQLTestBase.cs | 22 +- 6 files changed, 274 insertions(+), 139 deletions(-) create mode 100644 src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs index d09841b55..f07114303 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs @@ -122,9 +122,14 @@ namespace Squidex.Domain.Apps.Core.Schemas { Guard.NotNull(field, nameof(field)); - if (ByName.ContainsKey(field.Name) || ById.ContainsKey(field.Id)) + if (ByName.ContainsKey(field.Name)) { - throw new ArgumentException($"A field with name '{field.Name}' and id {field.Id} already exists.", nameof(field)); + throw new ArgumentException($"A field with name '{field.Name}' already exists.", nameof(field)); + } + + if (ById.ContainsKey(field.Id)) + { + throw new ArgumentException($"A field with id {field.Id} already exists.", nameof(field)); } return Clone(clone => diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs index c25cd48bf..7dfd1480b 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs @@ -25,18 +25,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Name = $"{schemaType}DataDto"; - foreach (var field in schema.SchemaDef.Fields.ForApi()) + foreach (var (field, fieldName, typeName) in schema.SchemaDef.Fields.SafeFields()) { var (resolvedType, valueResolver) = model.GetGraphType(schema, field); if (valueResolver != null) { - var fieldType = field.TypeName(); - var fieldName = field.DisplayName(); + var displayName = field.DisplayName(); var fieldGraphType = new ObjectGraphType { - Name = $"{schemaType}Data{fieldType}Dto" + Name = $"{schemaType}Data{typeName}Dto" }; var partition = model.ResolvePartition(field.Partitioning); @@ -45,45 +44,51 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { var key = partitionItem.Key; - var partitionResolver = new FuncFieldResolver(c => - { - if (((ContentFieldData)c.Source).TryGetValue(key, out var value)) - { - return valueResolver(value, c); - } - else - { - return null; - } - }); - fieldGraphType.AddField(new FieldType { Name = key.EscapePartition(), - Resolver = partitionResolver, + Resolver = PartitionResolver(valueResolver, key), ResolvedType = resolvedType, Description = field.RawProperties.Hints }); } - fieldGraphType.Description = $"The structure of the {fieldName} field of the {schemaName} content type."; - - var fieldResolver = new FuncFieldResolver>(c => - { - return c.Source.GetOrDefault(field.Name); - }); + fieldGraphType.Description = $"The structure of the {displayName} field of the {schemaName} content type."; AddField(new FieldType { - Name = field.Name.ToCamelCase(), - Resolver = fieldResolver, + Name = fieldName, + Resolver = FieldResolver(field), ResolvedType = fieldGraphType, - Description = $"The {fieldName} field." + Description = $"The {displayName} field." }); } } Description = $"The structure of the {schemaName} content type."; } + + private static FuncFieldResolver PartitionResolver(ValueResolver valueResolver, string key) + { + return new FuncFieldResolver(c => + { + if (((ContentFieldData)c.Source).TryGetValue(key, out var value)) + { + return valueResolver(value, c); + } + else + { + return null; + } + }); + } + + private static FuncFieldResolver> FieldResolver(RootField field) + { + return new FuncFieldResolver>(c => + { + return c.Source.GetOrDefault(field.Name); + }); + } } } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs new file mode 100644 index 000000000..9531680b0 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs @@ -0,0 +1,41 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using Squidex.Domain.Apps.Core.Schemas; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + public static class Extensions + { + public static IEnumerable<(T Field, string Name, string Type)> SafeFields(this IEnumerable fields) where T : IField + { + var allFields = + fields.ForApi() + .Select(f => (Field: f, Name: f.Name.ToCamelCase(), Type: f.TypeName())).GroupBy(x => x.Name) + .Select(g => + { + return g.Select((f, i) => (f.Field, f.Name.SafeString(i), f.Type.SafeString(i))); + }) + .SelectMany(x => x); + + return allFields; + } + + private static string SafeString(this string value, int index) + { + if (index > 0) + { + return value + (index + 1); + } + + return value; + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs index e7b954698..7f64227bb 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs @@ -25,27 +25,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Name = $"{schemaType}{fieldName}ChildDto"; - foreach (var nestedField in field.Fields.ForApi()) + foreach (var (nestedField, nestedName, _) in field.Fields.SafeFields()) { var fieldInfo = model.GetGraphType(schema, nestedField); if (fieldInfo.ResolveType != null) { - var resolver = new FuncFieldResolver(c => - { - if (((JsonObject)c.Source).TryGetValue(nestedField.Name, out var value)) - { - return fieldInfo.Resolver(value, c); - } - else - { - return fieldInfo; - } - }); + var resolver = ValueResolver(nestedField, fieldInfo); AddField(new FieldType { - Name = nestedField.Name.ToCamelCase(), + Name = nestedName, Resolver = resolver, ResolvedType = fieldInfo.ResolveType, Description = $"The {fieldName}/{nestedField.DisplayName()} nested field." @@ -55,5 +45,20 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Description = $"The structure of the {schemaName}.{fieldName} nested schema."; } + + private static FuncFieldResolver ValueResolver(NestedField nestedField, (IGraphType ResolveType, ValueResolver Resolver) fieldInfo) + { + return new FuncFieldResolver(c => + { + if (((JsonObject)c.Source).TryGetValue(nestedField.Name, out var value)) + { + return fieldInfo.Resolver(value, c); + } + else + { + return fieldInfo; + } + }); + } } } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs index 06f6143eb..09b5afd92 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs @@ -187,9 +187,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var assetId = Guid.NewGuid(); var asset = CreateAsset(assetId); - var query = $@" - query {{ - findAsset(id: ""{assetId}"") {{ + var query = @" + query { + findAsset(id: """") { id version created @@ -209,8 +209,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL pixelHeight tags slug - }} - }}"; + } + }".Replace("", assetId.ToString()); A.CallTo(() => assetQuery.FindAssetAsync(MatchsAssetContext(), assetId)) .Returns(asset); @@ -372,12 +372,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { new { - nestedNumber = 1, + nestedNumber = 10, nestedBoolean = true }, new { - nestedNumber = 2, + nestedNumber = 20, nestedBoolean = false } } @@ -518,15 +518,86 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL AssertResult(expected, result); } + [Fact] + public async Task Should_return_single_content_with_duplicate_names() + { + var contentId = Guid.NewGuid(); + var content = CreateContent(contentId, Guid.Empty, Guid.Empty); + + var query = @" + query { + findMySchemaContent(id: """") { + data { + myNumber { + iv + } + myNumber2 { + iv + } + myArray { + iv { + nestedNumber + nestedNumber2 + } + } + } + } + }".Replace("", contentId.ToString()); + + A.CallTo(() => contentQuery.FindContentAsync(MatchsContentContext(), schemaId.ToString(), contentId, EtagVersion.Any)) + .Returns(content); + + var result = await sut.QueryAsync(context, new GraphQLQuery { Query = query }); + + var expected = new + { + data = new + { + findMySchemaContent = new + { + data = new + { + myNumber = new + { + iv = 1 + }, + myNumber2 = new + { + iv = 2 + }, + myArray = new + { + iv = new[] + { + new + { + nestedNumber = 10, + nestedNumber2 = 11, + }, + new + { + nestedNumber = 20, + nestedNumber2 = 21, + } + } + } + } + } + } + }; + + AssertResult(expected, result); + } + [Fact] public async Task Should_return_single_content_when_finding_content() { var contentId = Guid.NewGuid(); var content = CreateContent(contentId, Guid.Empty, Guid.Empty); - var query = $@" - query {{ - findMySchemaContent(id: ""{contentId}"") {{ + var query = @" + query { + findMySchemaContent(id: """") { id version created @@ -535,34 +606,34 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL lastModifiedBy status url - data {{ - myString {{ + data { + myString { de - }} - myNumber {{ + } + myNumber { iv - }} - myBoolean {{ + } + myBoolean { iv - }} - myDatetime {{ + } + myDatetime { iv - }} - myJson {{ + } + myJson { iv - }} - myGeolocation {{ + } + myGeolocation { iv - }} - myTags {{ + } + myTags { iv - }} - myLocalized {{ + } + myLocalized { de_DE - }} - }} - }} - }}"; + } + } + } + }".Replace("", contentId.ToString()); A.CallTo(() => contentQuery.FindContentAsync(MatchsContentContext(), schemaId.ToString(), contentId, EtagVersion.Any)) .Returns(content); @@ -645,19 +716,19 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var contentId = Guid.NewGuid(); var content = CreateContent(contentId, contentRefId, Guid.Empty); - var query = $@" - query {{ - findMySchemaContent(id: ""{contentId}"") {{ + var query = @" + query { + findMySchemaContent(id: """") { id - data {{ - myReferences {{ - iv {{ + data { + myReferences { + iv { id - }} - }} - }} - }} - }}"; + } + } + } + } + }".Replace("", contentId.ToString()); A.CallTo(() => contentQuery.FindContentAsync(MatchsContentContext(), schemaId.ToString(), contentId, EtagVersion.Any)) .Returns(content); @@ -703,19 +774,19 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var contentId = Guid.NewGuid(); var content = CreateContent(contentId, Guid.Empty, assetRefId); - var query = $@" - query {{ - findMySchemaContent(id: ""{contentId}"") {{ + var query = @" + query { + findMySchemaContent(id: """") { id - data {{ - myAssets {{ - iv {{ + data { + myAssets { + iv { id - }} - }} - }} - }} - }}"; + } + } + } + } + }".Replace("", contentId.ToString()); A.CallTo(() => contentQuery.FindContentAsync(MatchsContentContext(), schemaId.ToString(), contentId, EtagVersion.Any)) .Returns(content); @@ -760,18 +831,18 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var asset1 = CreateAsset(assetId1); var asset2 = CreateAsset(assetId2); - var query1 = $@" - query {{ - findAsset(id: ""{assetId1}"") {{ + var query1 = @" + query { + findAsset(id: """") { id - }} - }}"; - var query2 = $@" - query {{ - findAsset(id: ""{assetId2}"") {{ + } + }".Replace("", assetId1.ToString()); + var query2 = @" + query { + findAsset(id: """") { id - }} - }}"; + } + }".Replace("", assetId2.ToString()); A.CallTo(() => assetQuery.FindAssetAsync(MatchsAssetContext(), assetId1)) .Returns(asset1); @@ -813,9 +884,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var contentId = Guid.NewGuid(); var content = CreateContent(contentId, Guid.Empty, Guid.Empty, new NamedContentData()); - var query = $@" - query {{ - findMySchemaContent(id: ""{contentId}"") {{ + var query = @" + query { + findMySchemaContent(id: """") { id version created @@ -823,13 +894,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL lastModified lastModifiedBy url - data {{ - myInvalid {{ + data { + myInvalid { iv - }} - }} - }} - }}"; + } + } + } + }".Replace("", contentId.ToString()); A.CallTo(() => contentQuery.FindContentAsync(MatchsContentContext(), schemaId.ToString(), contentId, EtagVersion.Any)) .Returns(content); @@ -855,19 +926,19 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var contentId = Guid.NewGuid(); var content = CreateContent(contentId, Guid.Empty, Guid.Empty, null, dataDraft); - var query = $@" - query {{ - findMySchemaContent(id: ""{contentId}"") {{ - dataDraft {{ - myString {{ + var query = @" + query { + findMySchemaContent(id: """") { + dataDraft { + myString { de - }} - myNumber {{ + } + myNumber { iv - }} - }} - }} - }}"; + } + } + } + }".Replace("", contentId.ToString()); A.CallTo(() => contentQuery.FindContentAsync(MatchsContentContext(), schemaId.ToString(), contentId, EtagVersion.Any)) .Returns(content); @@ -904,16 +975,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var contentId = Guid.NewGuid(); var content = CreateContent(contentId, Guid.Empty, Guid.Empty, null); - var query = $@" - query {{ - findMySchemaContent(id: ""{contentId}"") {{ - dataDraft {{ - myString {{ + var query = @" + query { + findMySchemaContent(id: """") { + dataDraft { + myString { de - }} - }} - }} - }}"; + } + } + } + }".Replace("", contentId.ToString()); A.CallTo(() => contentQuery.FindContentAsync(MatchsContentContext(), schemaId.ToString(), contentId, EtagVersion.Any)) .Returns(content); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs index 9edd65708..d3f6b5ea1 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs @@ -58,13 +58,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL new StringFieldProperties()) .AddNumber(3, "my-number", Partitioning.Invariant, new NumberFieldProperties()) - .AddAssets(4, "my-assets", Partitioning.Invariant, + .AddNumber(4, "my_number", Partitioning.Invariant, + new NumberFieldProperties()) + .AddAssets(5, "my-assets", Partitioning.Invariant, new AssetsFieldProperties()) - .AddBoolean(5, "my-boolean", Partitioning.Invariant, + .AddBoolean(6, "my-boolean", Partitioning.Invariant, new BooleanFieldProperties()) - .AddDateTime(6, "my-datetime", Partitioning.Invariant, + .AddDateTime(7, "my-datetime", Partitioning.Invariant, new DateTimeFieldProperties()) - .AddReferences(7, "my-references", Partitioning.Invariant, + .AddReferences(8, "my-references", Partitioning.Invariant, new ReferencesFieldProperties { SchemaId = schemaId }) .AddReferences(9, "my-invalid", Partitioning.Invariant, new ReferencesFieldProperties { SchemaId = Guid.NewGuid() }) @@ -76,7 +78,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL new StringFieldProperties()) .AddArray(13, "my-array", Partitioning.Invariant, f => f .AddBoolean(121, "nested-boolean") - .AddNumber(122, "nested-number")) + .AddNumber(122, "nested-number") + .AddNumber(123, "nested_number")) .ConfigureScripts(new SchemaScripts { Query = "" }) .Publish(); @@ -111,6 +114,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL .AddField("my-number", new ContentFieldData() .AddValue("iv", 1.0)) + .AddField("my_number", + new ContentFieldData() + .AddValue("iv", 2.0)) .AddField("my-boolean", new ContentFieldData() .AddValue("iv", true)) @@ -137,10 +143,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL .AddValue("iv", JsonValue.Array( JsonValue.Object() .Add("nested-boolean", true) - .Add("nested-number", 1), + .Add("nested-number", 10) + .Add("nested_number", 11), JsonValue.Object() .Add("nested-boolean", false) - .Add("nested-number", 2)))); + .Add("nested-number", 20) + .Add("nested_number", 21)))); var content = new ContentEntity { From 0f856d57c34149c0e9a2076f976f0e2dcdd264ed Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 5 Jun 2019 18:12:14 +0200 Subject: [PATCH 004/107] Manually merged FTP. Closes #360 --- README.md | 5 +- .../Assets/MongoGridFsAssetStore.cs | 6 + .../Assets/FTPAssetStore.cs | 158 ++++++++++++++++++ .../Assets/FolderAssetStore.cs | 6 +- .../Assets/MemoryAssetStore.cs | 2 + .../Squidex.Infrastructure.csproj | 1 + src/Squidex/Config/Domain/AssetServices.cs | 20 +++ src/Squidex/appsettings.json | 21 ++- .../Assets/AssetStoreTests.cs | 49 ++++++ .../Assets/FTPAssetStoreFixture.cs | 29 ++++ .../Assets/FTPAssetStoreTests.cs | 35 ++++ 11 files changed, 325 insertions(+), 7 deletions(-) create mode 100644 src/Squidex.Infrastructure/Assets/FTPAssetStore.cs create mode 100644 tests/Squidex.Infrastructure.Tests/Assets/FTPAssetStoreFixture.cs create mode 100644 tests/Squidex.Infrastructure.Tests/Assets/FTPAssetStoreTests.cs diff --git a/README.md b/README.md index 2484db7f7..bbf5dfe3b 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,10 @@ Current Version v2.0.4. Roadmap: https://trello.com/b/KakM4F3S/squidex-roadmap ### Contributors -* [pushrbx](https://pushrbx.net/): Azure Store support. -* [cpmstars](https://www.cpmstars.com): Asset support for rich editor. * [civicplus](https://www.civicplus.com/) ([Avd6977](https://github.com/Avd6977), [dsbegnoce](https://github.com/dsbegnoche)): Google Maps support, custom regex patterns and a lot of small improvements. +* [cpmstars](https://www.cpmstars.com): Asset support for rich editor. +* [guohai](https://github.com/seamys): FTP asset store support, Email rule support, custom editors and bug fixes. +* [pushrbx](https://pushrbx.net/): Azure Store support. * [razims](https://github.com/razims): GridFS support. ## Contributing diff --git a/src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs b/src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs index 3a86d4fd9..cde15c0da 100644 --- a/src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs +++ b/src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs @@ -46,6 +46,8 @@ namespace Squidex.Infrastructure.Assets public async Task CopyAsync(string sourceFileName, string targetFileName, CancellationToken ct = default) { + Guard.NotNullOrEmpty(targetFileName, nameof(targetFileName)); + try { var sourceName = GetFileName(sourceFileName, nameof(sourceFileName)); @@ -63,6 +65,8 @@ namespace Squidex.Infrastructure.Assets public async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default) { + Guard.NotNull(stream, nameof(stream)); + try { var name = GetFileName(fileName, nameof(fileName)); @@ -80,6 +84,8 @@ namespace Squidex.Infrastructure.Assets public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) { + Guard.NotNull(stream, nameof(stream)); + try { var name = GetFileName(fileName, nameof(fileName)); diff --git a/src/Squidex.Infrastructure/Assets/FTPAssetStore.cs b/src/Squidex.Infrastructure/Assets/FTPAssetStore.cs new file mode 100644 index 000000000..8e361de32 --- /dev/null +++ b/src/Squidex.Infrastructure/Assets/FTPAssetStore.cs @@ -0,0 +1,158 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using FluentFTP; +using Squidex.Infrastructure.Log; + +namespace Squidex.Infrastructure.Assets +{ + public sealed class FTPAssetStore : IAssetStore, IInitializable + { + private readonly string path; + private readonly ISemanticLog log; + private readonly Func factory; + + public FTPAssetStore(Func factory, string path, ISemanticLog log) + { + Guard.NotNull(factory, nameof(factory)); + Guard.NotNullOrEmpty(path, nameof(path)); + Guard.NotNull(log, nameof(log)); + + this.factory = factory; + this.path = path; + + this.log = log; + } + + public string GeneratePublicUrl(string fileName) + { + return null; + } + + public async Task InitializeAsync(CancellationToken ct = default) + { + using (var client = factory()) + { + await client.ConnectAsync(ct); + + if (!await client.DirectoryExistsAsync(path, ct)) + { + await client.CreateDirectoryAsync(path, ct); + } + } + + log.LogInformation(w => w + .WriteProperty("action", "FTPAssetStoreConfigured") + .WriteProperty("path", path)); + } + + public async Task CopyAsync(string sourceFileName, string targetFileName, CancellationToken ct = default) + { + Guard.NotNullOrEmpty(sourceFileName, nameof(sourceFileName)); + Guard.NotNullOrEmpty(targetFileName, nameof(targetFileName)); + + using (var client = GetFtpClient()) + { + var tempPath = Path.Combine(Path.GetTempPath(), Path.GetTempFileName()); + + using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, 4096, FileOptions.DeleteOnClose)) + { + await DownloadAsync(client, sourceFileName, stream, ct); + await UploadAsync(client, targetFileName, stream, false, ct); + } + } + } + + public async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default) + { + Guard.NotNullOrEmpty(fileName, nameof(fileName)); + Guard.NotNull(stream, nameof(stream)); + + using (var client = GetFtpClient()) + { + await DownloadAsync(client, fileName, stream, ct); + } + } + + public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) + { + Guard.NotNullOrEmpty(fileName, nameof(fileName)); + Guard.NotNull(stream, nameof(stream)); + + using (var client = GetFtpClient()) + { + await UploadAsync(client, fileName, stream, overwrite, ct); + } + } + + private static async Task DownloadAsync(IFtpClient client, string fileName, Stream stream, CancellationToken ct) + { + try + { + await client.DownloadAsync(stream, fileName, token: ct); + } + catch (FtpException ex) when (IsNotFound(ex)) + { + throw new AssetNotFoundException(fileName, ex); + } + } + + private static async Task UploadAsync(IFtpClient client, string fileName, Stream stream, bool overwrite, CancellationToken ct) + { + if (!overwrite && await client.FileExistsAsync(fileName, ct)) + { + throw new AssetAlreadyExistsException(fileName); + } + + await client.UploadAsync(stream, fileName, overwrite ? FtpExists.Overwrite : FtpExists.Skip, true, null, ct); + } + + public async Task DeleteAsync(string fileName) + { + Guard.NotNullOrEmpty(fileName, nameof(fileName)); + + using (var client = GetFtpClient()) + { + try + { + await client.DeleteFileAsync(fileName); + } + catch (FtpException ex) + { + if (!IsNotFound(ex)) + { + throw ex; + } + } + } + } + + private IFtpClient GetFtpClient() + { + var client = factory(); + + client.SetWorkingDirectory(path); + client.Connect(); + + return client; + } + + private static bool IsNotFound(Exception exception) + { + if (exception is FtpCommandException command) + { + return command.CompletionCode == "550"; + } + + return exception.InnerException != null ? IsNotFound(exception.InnerException) : false; + } + } +} \ No newline at end of file diff --git a/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs b/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs index 3df128be3..c9a3e83f0 100644 --- a/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs +++ b/src/Squidex.Infrastructure/Assets/FolderAssetStore.cs @@ -82,7 +82,7 @@ namespace Squidex.Infrastructure.Assets public async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default) { - Guard.NotNullOrEmpty(fileName, nameof(fileName)); + Guard.NotNull(stream, nameof(stream)); var file = GetFile(fileName); @@ -101,7 +101,7 @@ namespace Squidex.Infrastructure.Assets public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) { - Guard.NotNullOrEmpty(fileName, nameof(fileName)); + Guard.NotNull(stream, nameof(stream)); var file = GetFile(fileName); @@ -120,8 +120,6 @@ namespace Squidex.Infrastructure.Assets public Task DeleteAsync(string fileName) { - Guard.NotNullOrEmpty(fileName, nameof(fileName)); - var file = GetFile(fileName); file.Delete(); diff --git a/src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs b/src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs index 10fa7fe84..20d9fc363 100644 --- a/src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs +++ b/src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs @@ -43,6 +43,7 @@ namespace Squidex.Infrastructure.Assets public virtual async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default) { Guard.NotNullOrEmpty(fileName, nameof(fileName)); + Guard.NotNull(stream, nameof(stream)); if (!streams.TryGetValue(fileName, out var sourceStream)) { @@ -65,6 +66,7 @@ namespace Squidex.Infrastructure.Assets public virtual async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) { Guard.NotNullOrEmpty(fileName, nameof(fileName)); + Guard.NotNull(stream, nameof(stream)); var memoryStream = new MemoryStream(); diff --git a/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index 882061c58..5877358e2 100644 --- a/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -8,6 +8,7 @@ True + diff --git a/src/Squidex/Config/Domain/AssetServices.cs b/src/Squidex/Config/Domain/AssetServices.cs index 63f813aa6..90b5ac434 100644 --- a/src/Squidex/Config/Domain/AssetServices.cs +++ b/src/Squidex/Config/Domain/AssetServices.cs @@ -5,6 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using FluentFTP; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using MongoDB.Driver; @@ -68,6 +70,24 @@ namespace Squidex.Config.Domain return new MongoGridFsAssetStore(gridFsbucket); }) .As(); + }, + ["Ftp"] = () => + { + var serverHost = config.GetRequiredValue("assetStore:ftp:serverHost"); + var serverPort = config.GetOptionalValue("assetStore:ftp:serverPort", 21); + + var username = config.GetRequiredValue("assetStore:ftp:username"); + var password = config.GetRequiredValue("assetStore:ftp:password"); + + var path = config.GetOptionalValue("assetStore:ftp:path", "/"); + + services.AddSingletonAs(c => + { + var factory = new Func(() => new FtpClient(serverHost, serverPort, username, password)); + + return new FTPAssetStore(factory, path, c.GetRequiredService()); + }) + .As(); } }); diff --git a/src/Squidex/appsettings.json b/src/Squidex/appsettings.json index 45c7851a7..d8c5ba74f 100644 --- a/src/Squidex/appsettings.json +++ b/src/Squidex/appsettings.json @@ -200,7 +200,7 @@ /* * Define the type of the read store. * - * Supported: Folder (local folder), MongoDb (GridFS), GoogleCloud (hosted in Google Cloud only), AzureBlob. + * Supported: Folder (local folder), MongoDb (GridFS), GoogleCloud (hosted in Google Cloud only), AzureBlob, FTP (not recommended). */ "type": "Folder", "folder": { @@ -241,6 +241,25 @@ */ "bucket": "fs" }, + "ftp": { + /* + *The host of the ftp service + */ + "serverHost": "", + /* + *The host of the ftp service + */ + "serverPort": "21", + /* + * Credentials. + */ + "username": "", + "password": "", + /* + * The relative or absolute path to the folder to store the assets. + */ + "path": "Assets" + }, /* * Allow to expose the url in graph ql url. */ diff --git a/tests/Squidex.Infrastructure.Tests/Assets/AssetStoreTests.cs b/tests/Squidex.Infrastructure.Tests/Assets/AssetStoreTests.cs index e066217e5..731944ac7 100644 --- a/tests/Squidex.Infrastructure.Tests/Assets/AssetStoreTests.cs +++ b/tests/Squidex.Infrastructure.Tests/Assets/AssetStoreTests.cs @@ -48,6 +48,48 @@ namespace Squidex.Infrastructure.Assets await Assert.ThrowsAsync(() => Sut.CopyAsync(fileName, sourceFile)); } + [Fact] + public async Task Should_throw_exception_if_stream_to_download_is_null() + { + await Assert.ThrowsAsync(() => Sut.DownloadAsync("File", null)); + } + + [Fact] + public async Task Should_throw_exception_if_stream_to_upload_is_null() + { + await Assert.ThrowsAsync(() => Sut.UploadAsync("File", null)); + } + + [Fact] + public async Task Should_throw_exception_if_source_file_name_to_copy_is_empty() + { + await CheckEmpty(v => Sut.CopyAsync(v, "Target")); + } + + [Fact] + public async Task Should_throw_exception_if_target_file_name_to_copy_is_empty() + { + await CheckEmpty(v => Sut.CopyAsync("Source", v)); + } + + [Fact] + public async Task Should_throw_exception_if_file_name_to_delete_is_empty() + { + await CheckEmpty(v => Sut.DeleteAsync(v)); + } + + [Fact] + public async Task Should_throw_exception_if_file_name_to_download_is_empty() + { + await CheckEmpty(v => Sut.DownloadAsync(v, new MemoryStream())); + } + + [Fact] + public async Task Should_throw_exception_if_file_name_to_upload_is_empty() + { + await CheckEmpty(v => Sut.UploadAsync(v, new MemoryStream())); + } + [Fact] public async Task Should_write_and_read_file() { @@ -111,5 +153,12 @@ namespace Squidex.Infrastructure.Assets await Sut.DeleteAsync(sourceFile); await Sut.DeleteAsync(sourceFile); } + + private async Task CheckEmpty(Func action) + { + await Assert.ThrowsAsync(() => action(null)); + await Assert.ThrowsAsync(() => action(string.Empty)); + await Assert.ThrowsAsync(() => action(" ")); + } } } diff --git a/tests/Squidex.Infrastructure.Tests/Assets/FTPAssetStoreFixture.cs b/tests/Squidex.Infrastructure.Tests/Assets/FTPAssetStoreFixture.cs new file mode 100644 index 000000000..ae2123934 --- /dev/null +++ b/tests/Squidex.Infrastructure.Tests/Assets/FTPAssetStoreFixture.cs @@ -0,0 +1,29 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using FakeItEasy; +using FluentFTP; +using Squidex.Infrastructure.Log; + +namespace Squidex.Infrastructure.Assets +{ + public sealed class FTPAssetStoreFixture : IDisposable + { + public FTPAssetStore AssetStore { get; } + + public FTPAssetStoreFixture() + { + AssetStore = new FTPAssetStore(() => new FtpClient("localhost", 21, "test", "test"), "assets", A.Fake()); + AssetStore.InitializeAsync().Wait(); + } + + public void Dispose() + { + } + } +} diff --git a/tests/Squidex.Infrastructure.Tests/Assets/FTPAssetStoreTests.cs b/tests/Squidex.Infrastructure.Tests/Assets/FTPAssetStoreTests.cs new file mode 100644 index 000000000..1ef2e53d9 --- /dev/null +++ b/tests/Squidex.Infrastructure.Tests/Assets/FTPAssetStoreTests.cs @@ -0,0 +1,35 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Xunit; + +namespace Squidex.Infrastructure.Assets +{ + [Trait("Category", "Dependencies")] + public class FTPAssetStoreTests : AssetStoreTests, IClassFixture + { + private readonly FTPAssetStoreFixture fixture; + + public FTPAssetStoreTests(FTPAssetStoreFixture fixture) + { + this.fixture = fixture; + } + + public override FTPAssetStore CreateStore() + { + return fixture.AssetStore; + } + + [Fact] + public void Should_calculate_source_url() + { + var url = Sut.GeneratePublicUrl(FileName); + + Assert.Null(url); + } + } +} From fa56430b1d982810eb1d3573acb8b30faea90e91 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 5 Jun 2019 18:54:17 +0200 Subject: [PATCH 005/107] Fix with local path. --- src/Squidex.Infrastructure/Assets/FTPAssetStore.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Squidex.Infrastructure/Assets/FTPAssetStore.cs b/src/Squidex.Infrastructure/Assets/FTPAssetStore.cs index 8e361de32..8619063f6 100644 --- a/src/Squidex.Infrastructure/Assets/FTPAssetStore.cs +++ b/src/Squidex.Infrastructure/Assets/FTPAssetStore.cs @@ -139,8 +139,8 @@ namespace Squidex.Infrastructure.Assets { var client = factory(); - client.SetWorkingDirectory(path); client.Connect(); + client.SetWorkingDirectory(path); return client; } @@ -152,7 +152,7 @@ namespace Squidex.Infrastructure.Assets return command.CompletionCode == "550"; } - return exception.InnerException != null ? IsNotFound(exception.InnerException) : false; + return exception.InnerException != null && IsNotFound(exception.InnerException); } } } \ No newline at end of file From 3d07ad5eeac85832477d809e216d72589cb84788 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 5 Jun 2019 19:02:33 +0200 Subject: [PATCH 006/107] Padding in wizard fixed. --- .../app/features/rules/pages/rules/rule-wizard.component.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.scss b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.scss index aa4ab7f00..927df2b24 100644 --- a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.scss +++ b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.scss @@ -11,6 +11,6 @@ margin-bottom: 1rem; font-weight: 400; font-size: 1.05rem; - padding: 1rem; + padding: 1rem 1.75rem; } From ee8701a1639cad60a5ee5c11029e192fecc1af9d Mon Sep 17 00:00:00 2001 From: Sebastian Date: Wed, 5 Jun 2019 21:29:57 +0200 Subject: [PATCH 007/107] A view styling improvements. --- .../api/pages/graphql/graphql-page.component.scss | 2 +- .../apps/pages/news-dialog.component.scss | 2 +- .../apps/pages/onboarding-dialog.component.scss | 2 +- .../assets/pages/assets-page.component.scss | 2 +- .../content/content-history-page.component.scss | 2 +- .../pages/contents/contents-page.component.scss | 2 +- .../content/shared/array-item.component.scss | 2 +- .../shared/contents-selector.component.scss | 4 ++-- .../dashboard/pages/dashboard-page.component.scss | 2 +- .../rules/pages/rules/rule-wizard.component.html | 8 ++++++++ .../rules/pages/rules/rule-wizard.component.scss | 2 +- .../pages/schema/field-wizard.component.scss | 2 +- .../pages/schemas/schema-form.component.scss | 2 +- .../angular/forms/code-editor.component.scss | 2 +- .../angular/forms/color-picker.component.scss | 2 +- .../angular/forms/form-alert.component.ts | 15 ++++++++++++++- .../angular/forms/json-editor.component.scss | 2 +- .../app/shared/components/asset.component.scss | 2 +- .../components/assets-selector.component.scss | 2 +- .../shared/components/history-list.component.scss | 2 +- 20 files changed, 41 insertions(+), 20 deletions(-) diff --git a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss index 6d3c84b1c..e47eee080 100644 --- a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss +++ b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss @@ -1,7 +1,7 @@ @import '_vars'; @import '_mixins'; -:host /deep/ { +::ng-deep { @import '~graphiql/graphiql'; .graphiql-container { diff --git a/src/Squidex/app/features/apps/pages/news-dialog.component.scss b/src/Squidex/app/features/apps/pages/news-dialog.component.scss index 787e1d95a..927e34891 100644 --- a/src/Squidex/app/features/apps/pages/news-dialog.component.scss +++ b/src/Squidex/app/features/apps/pages/news-dialog.component.scss @@ -1,7 +1,7 @@ @import '_vars'; @import '_mixins'; -:host /deep/ { +::ng-deep { img { @include box-shadow(0, 4px, 20px, .2); width: 80%; diff --git a/src/Squidex/app/features/apps/pages/onboarding-dialog.component.scss b/src/Squidex/app/features/apps/pages/onboarding-dialog.component.scss index a2dadddb3..01f6bf120 100644 --- a/src/Squidex/app/features/apps/pages/onboarding-dialog.component.scss +++ b/src/Squidex/app/features/apps/pages/onboarding-dialog.component.scss @@ -19,7 +19,7 @@ p { max-width: 489px; } -:host /deep/ .modal { +::ng-deep .modal { &-content, &-dialog { min-height: $size-height; diff --git a/src/Squidex/app/features/assets/pages/assets-page.component.scss b/src/Squidex/app/features/assets/pages/assets-page.component.scss index 6bc6bf7dc..1c98c09d3 100644 --- a/src/Squidex/app/features/assets/pages/assets-page.component.scss +++ b/src/Squidex/app/features/assets/pages/assets-page.component.scss @@ -6,7 +6,7 @@ padding: 1rem; } -:host /deep/ { +::ng-deep { .search { .form-control { @include border-radius-right; diff --git a/src/Squidex/app/features/content/pages/content/content-history-page.component.scss b/src/Squidex/app/features/content/pages/content/content-history-page.component.scss index cf0dec62a..d7f4584de 100644 --- a/src/Squidex/app/features/content/pages/content/content-history-page.component.scss +++ b/src/Squidex/app/features/content/pages/content/content-history-page.component.scss @@ -1,7 +1,7 @@ @import '_vars'; @import '_mixins'; -:host /deep/ { +::ng-deep { .user-ref { color: $color-title; } diff --git a/src/Squidex/app/features/content/pages/contents/contents-page.component.scss b/src/Squidex/app/features/content/pages/contents/contents-page.component.scss index e9f528877..34cd61105 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-page.component.scss +++ b/src/Squidex/app/features/content/pages/contents/contents-page.component.scss @@ -1,7 +1,7 @@ @import '_vars'; @import '_mixins'; -:host /deep/ .search-form { +::ng-deep .search-form { display: block; } diff --git a/src/Squidex/app/features/content/shared/array-item.component.scss b/src/Squidex/app/features/content/shared/array-item.component.scss index 7b54dfb82..976c37102 100644 --- a/src/Squidex/app/features/content/shared/array-item.component.scss +++ b/src/Squidex/app/features/content/shared/array-item.component.scss @@ -1,7 +1,7 @@ @import '_vars'; @import '_mixins'; -:host /deep/ { +::ng-deep { .ui-separator { border-color: $color-border !important; font-size: 1.2rem; diff --git a/src/Squidex/app/features/content/shared/contents-selector.component.scss b/src/Squidex/app/features/content/shared/contents-selector.component.scss index c9609948f..7ad2d6c0f 100644 --- a/src/Squidex/app/features/content/shared/contents-selector.component.scss +++ b/src/Squidex/app/features/content/shared/contents-selector.component.scss @@ -1,10 +1,10 @@ @import '_vars'; @import '_mixins'; -:host /deep/ .modal-body { +::ng-deep .modal-body { background: $color-background; } -:host /deep/ .modal-tabs { +::ng-deep .modal-tabs { background: $color-dark-foreground; } \ No newline at end of file diff --git a/src/Squidex/app/features/dashboard/pages/dashboard-page.component.scss b/src/Squidex/app/features/dashboard/pages/dashboard-page.component.scss index 936b11b25..1868983c1 100644 --- a/src/Squidex/app/features/dashboard/pages/dashboard-page.component.scss +++ b/src/Squidex/app/features/dashboard/pages/dashboard-page.component.scss @@ -19,7 +19,7 @@ } } -:host /deep/ canvas { +::ng-deep canvas { height: 12rem !important; margin-top: -1rem; margin-bottom: 0; diff --git a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html index fbe0b104a..422b0a98f 100644 --- a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html +++ b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html @@ -22,6 +22,10 @@
+ + The selection of the trigger type cannot be changed later. + +
@@ -68,6 +72,10 @@
+ + The selection of the action type cannot be changed later. + +
diff --git a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.scss b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.scss index 927df2b24..4d3d9e03e 100644 --- a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.scss +++ b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.scss @@ -2,7 +2,7 @@ @import '_mixins'; .rule-element { - margin: .25rem; + padding-right: .25rem; } .wizard-title { diff --git a/src/Squidex/app/features/schemas/pages/schema/field-wizard.component.scss b/src/Squidex/app/features/schemas/pages/schema/field-wizard.component.scss index c828581c5..fa8db78cb 100644 --- a/src/Squidex/app/features/schemas/pages/schema/field-wizard.component.scss +++ b/src/Squidex/app/features/schemas/pages/schema/field-wizard.component.scss @@ -3,7 +3,7 @@ $icon-size: 4.5rem; -:host /deep/ .table-items-row-details-tab { +::ng-deep .table-items-row-details-tab { padding: 1.5rem 1.75rem; } diff --git a/src/Squidex/app/features/schemas/pages/schemas/schema-form.component.scss b/src/Squidex/app/features/schemas/pages/schemas/schema-form.component.scss index 99b0c95da..803413d1f 100644 --- a/src/Squidex/app/features/schemas/pages/schemas/schema-form.component.scss +++ b/src/Squidex/app/features/schemas/pages/schemas/schema-form.component.scss @@ -8,7 +8,7 @@ $icon-size: 4.5rem; margin-left: -.5rem; } -:host /deep/ .editor { +::ng-deep .editor { height: 15rem !important; margin-bottom: .5rem; margin-top: 0; diff --git a/src/Squidex/app/framework/angular/forms/code-editor.component.scss b/src/Squidex/app/framework/angular/forms/code-editor.component.scss index cb89af65e..0fb96d3fb 100644 --- a/src/Squidex/app/framework/angular/forms/code-editor.component.scss +++ b/src/Squidex/app/framework/angular/forms/code-editor.component.scss @@ -3,7 +3,7 @@ // sass-lint:disable class-name-format -:host /deep/ { +::ng-deep { .ace_editor { background: $color-dark-foreground; border: 1px solid $color-input; diff --git a/src/Squidex/app/framework/angular/forms/color-picker.component.scss b/src/Squidex/app/framework/angular/forms/color-picker.component.scss index 85b930ea9..8b35bf458 100644 --- a/src/Squidex/app/framework/angular/forms/color-picker.component.scss +++ b/src/Squidex/app/framework/angular/forms/color-picker.component.scss @@ -1,7 +1,7 @@ @import '_mixins'; @import '_vars'; -:host /deep/ { +::ng-deep { .color-picker { & { border-color: $color-border; diff --git a/src/Squidex/app/framework/angular/forms/form-alert.component.ts b/src/Squidex/app/framework/angular/forms/form-alert.component.ts index 2553503dd..b424e0625 100644 --- a/src/Squidex/app/framework/angular/forms/form-alert.component.ts +++ b/src/Squidex/app/framework/angular/forms/form-alert.component.ts @@ -9,8 +9,15 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; @Component({ selector: 'sqx-form-alert', + styles: [` + :host { + display: block; + min-width: 100%; + max-width: 100%; + } + `], template: ` -
+
`, changeDetection: ChangeDetectionStrategy.OnPush @@ -18,4 +25,10 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; export class FormAlertComponent { @Input() public class: string; + + @Input() + public marginTop = 2; + + @Input() + public marginBottom = 4; } \ No newline at end of file diff --git a/src/Squidex/app/framework/angular/forms/json-editor.component.scss b/src/Squidex/app/framework/angular/forms/json-editor.component.scss index cb89af65e..0fb96d3fb 100644 --- a/src/Squidex/app/framework/angular/forms/json-editor.component.scss +++ b/src/Squidex/app/framework/angular/forms/json-editor.component.scss @@ -3,7 +3,7 @@ // sass-lint:disable class-name-format -:host /deep/ { +::ng-deep { .ace_editor { background: $color-dark-foreground; border: 1px solid $color-input; diff --git a/src/Squidex/app/shared/components/asset.component.scss b/src/Squidex/app/shared/components/asset.component.scss index 58ab1e5d0..a1ed483fb 100644 --- a/src/Squidex/app/shared/components/asset.component.scss +++ b/src/Squidex/app/shared/components/asset.component.scss @@ -44,7 +44,7 @@ $list-height: 2.375rem; padding: 0; } -:host /deep/ { +::ng-deep { .form-control { &.disabled, &:disabled { diff --git a/src/Squidex/app/shared/components/assets-selector.component.scss b/src/Squidex/app/shared/components/assets-selector.component.scss index 4a020bd99..0438b80c5 100644 --- a/src/Squidex/app/shared/components/assets-selector.component.scss +++ b/src/Squidex/app/shared/components/assets-selector.component.scss @@ -1,7 +1,7 @@ @import '_vars'; @import '_mixins'; -:host /deep/ { +::ng-deep { .search { .form-control { @include border-radius-right; diff --git a/src/Squidex/app/shared/components/history-list.component.scss b/src/Squidex/app/shared/components/history-list.component.scss index 5c1e18804..5226994c0 100644 --- a/src/Squidex/app/shared/components/history-list.component.scss +++ b/src/Squidex/app/shared/components/history-list.component.scss @@ -1,7 +1,7 @@ @import '_vars'; @import '_mixins'; -:host /deep/ { +::ng-deep { .user-ref { color: $color-title; } From ef8984eaa9e1c754f3a50ee53afb5b51c5fff636 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 6 Jun 2019 09:46:51 +0200 Subject: [PATCH 008/107] Added missing fields to edm model. --- .../Assets/Edm/EdmAssetModel.cs | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs b/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs index 590953cf6..4e7b83892 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs @@ -18,20 +18,27 @@ namespace Squidex.Domain.Apps.Entities.Assets.Edm { var entityType = new EdmEntityType("Squidex", "Asset"); - entityType.AddStructuralProperty(nameof(IAssetEntity.Id).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IAssetEntity.Created).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset); - entityType.AddStructuralProperty(nameof(IAssetEntity.CreatedBy).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IAssetEntity.LastModified).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset); - entityType.AddStructuralProperty(nameof(IAssetEntity.LastModifiedBy).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IAssetEntity.Version).ToCamelCase(), EdmPrimitiveTypeKind.Int64); - entityType.AddStructuralProperty(nameof(IAssetEntity.FileName).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IAssetEntity.FileSize).ToCamelCase(), EdmPrimitiveTypeKind.Int64); - entityType.AddStructuralProperty(nameof(IAssetEntity.FileVersion).ToCamelCase(), EdmPrimitiveTypeKind.Int64); - entityType.AddStructuralProperty(nameof(IAssetEntity.IsImage).ToCamelCase(), EdmPrimitiveTypeKind.Boolean); - entityType.AddStructuralProperty(nameof(IAssetEntity.MimeType).ToCamelCase(), EdmPrimitiveTypeKind.String); - entityType.AddStructuralProperty(nameof(IAssetEntity.PixelHeight).ToCamelCase(), EdmPrimitiveTypeKind.Int32); - entityType.AddStructuralProperty(nameof(IAssetEntity.PixelWidth).ToCamelCase(), EdmPrimitiveTypeKind.Int32); - entityType.AddStructuralProperty(nameof(IAssetEntity.Tags).ToCamelCase(), EdmPrimitiveTypeKind.String); + void AddProperty(string name, EdmPrimitiveTypeKind type) + { + entityType.AddStructuralProperty(name.ToCamelCase(), type); + } + + AddProperty(nameof(IAssetEntity.Id), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.Created), EdmPrimitiveTypeKind.DateTimeOffset); + AddProperty(nameof(IAssetEntity.CreatedBy), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.LastModified), EdmPrimitiveTypeKind.DateTimeOffset); + AddProperty(nameof(IAssetEntity.LastModifiedBy), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.Version), EdmPrimitiveTypeKind.Int64); + AddProperty(nameof(IAssetEntity.FileName), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.FileHash), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.FileSize), EdmPrimitiveTypeKind.Int64); + AddProperty(nameof(IAssetEntity.FileVersion), EdmPrimitiveTypeKind.Int64); + AddProperty(nameof(IAssetEntity.IsImage), EdmPrimitiveTypeKind.Boolean); + AddProperty(nameof(IAssetEntity.MimeType), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.PixelHeight), EdmPrimitiveTypeKind.Int32); + AddProperty(nameof(IAssetEntity.PixelWidth), EdmPrimitiveTypeKind.Int32); + AddProperty(nameof(IAssetEntity.Slug), EdmPrimitiveTypeKind.String); + AddProperty(nameof(IAssetEntity.Tags), EdmPrimitiveTypeKind.String); var container = new EdmEntityContainer("Squidex", "Container"); From 6c6494c1369d6ef4f247b6309afedabe3257f971 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 7 Jun 2019 08:57:16 +0200 Subject: [PATCH 009/107] Test --- src/Squidex.Web/Extensions.cs | 8 ++ src/Squidex.Web/PermissionExtensions.cs | 77 +++++++++++++++++++ src/Squidex.Web/Resource.cs | 52 +++++++++++++ src/Squidex.Web/ResourceLink.cs | 23 ++++++ src/Squidex.Web/UrlHelperExtensions.cs | 46 +++++++++++ .../Api/Controllers/Users/Models/UserDto.cs | 53 ++++++++++++- .../Api/Controllers/Users/Models/UsersDto.cs | 34 +++++++- .../Users/UserManagementController.cs | 22 +----- .../Api/Controllers/Users/UsersController.cs | 4 +- .../pages/users/user-page.component.html | 2 +- .../pages/users/user-page.component.ts | 6 +- .../pages/users/users-page.component.html | 30 +++----- .../administration/services/users.service.ts | 31 +++++--- .../administration/state/users.state.spec.ts | 49 +++++------- .../administration/state/users.state.ts | 57 ++++---------- .../framework/angular/http/hateos.pipes.ts | 23 ++++++ src/Squidex/app/framework/declarations.ts | 1 + src/Squidex/app/framework/internal.ts | 1 + src/Squidex/app/framework/module.ts | 6 ++ src/Squidex/app/framework/utils/hateos.ts | 25 ++++++ 20 files changed, 419 insertions(+), 131 deletions(-) create mode 100644 src/Squidex.Web/PermissionExtensions.cs create mode 100644 src/Squidex.Web/Resource.cs create mode 100644 src/Squidex.Web/ResourceLink.cs create mode 100644 src/Squidex.Web/UrlHelperExtensions.cs create mode 100644 src/Squidex/app/framework/angular/http/hateos.pipes.ts create mode 100644 src/Squidex/app/framework/utils/hateos.ts diff --git a/src/Squidex.Web/Extensions.cs b/src/Squidex.Web/Extensions.cs index b7f7594bf..4ab57d830 100644 --- a/src/Squidex.Web/Extensions.cs +++ b/src/Squidex.Web/Extensions.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Security.Claims; using Squidex.Infrastructure.Security; @@ -40,5 +41,12 @@ namespace Squidex.Web return (null, null); } + + public static bool IsUser(this ApiController controller, string userId) + { + var subject = controller.User.OpenIdSubject(); + + return string.Equals(subject, userId, StringComparison.OrdinalIgnoreCase); + } } } diff --git a/src/Squidex.Web/PermissionExtensions.cs b/src/Squidex.Web/PermissionExtensions.cs new file mode 100644 index 000000000..6dc7d0610 --- /dev/null +++ b/src/Squidex.Web/PermissionExtensions.cs @@ -0,0 +1,77 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Http; +using Squidex.Infrastructure.Security; +using Squidex.Shared; +using Squidex.Shared.Identity; + +namespace Squidex.Web +{ + public static class PermissionExtensions + { + private sealed class PermissionFeature + { + public PermissionSet Permissions { get; } + + public PermissionFeature(PermissionSet permissions) + { + Permissions = permissions; + } + } + + public static PermissionSet GetPermissions(this HttpContext httpContext) + { + var feature = httpContext.Features.Get(); + + if (feature == null) + { + feature = new PermissionFeature(httpContext.User.Permissions()); + + httpContext.Features.Set(feature); + } + + return feature.Permissions; + } + + public static bool HasPermission(this HttpContext httpContext, Permission permission) + { + return httpContext.GetPermissions().Includes(permission); + } + + public static bool HasPermission(this HttpContext httpContext, string id, string app = "*", string schema = "*") + { + return httpContext.GetPermissions().Includes(Permissions.ForApp(id, app, schema)); + } + + public static bool HasPermission(this ApiController controller, Permission permission) + { + return controller.HttpContext.GetPermissions().Includes(permission); + } + + public static bool HasPermission(this ApiController controller, string id, string app = "*", string schema = "*") + { + if (app == "*") + { + if (controller.RouteData.Values.TryGetValue("app", out var value) && value is string s) + { + app = s; + } + } + + if (schema == "*") + { + if (controller.RouteData.Values.TryGetValue("name", out var value) && value is string s) + { + schema = s; + } + } + + return controller.HttpContext.GetPermissions().Includes(Permissions.ForApp(id, app, schema)); + } + } +} diff --git a/src/Squidex.Web/Resource.cs b/src/Squidex.Web/Resource.cs new file mode 100644 index 000000000..b62a5f361 --- /dev/null +++ b/src/Squidex.Web/Resource.cs @@ -0,0 +1,52 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Newtonsoft.Json; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Net.Http; + +namespace Squidex.Web +{ + public abstract class Resource + { + [JsonProperty("_links")] + [Required] + [Display(Description = "The links.")] + public Dictionary Links { get; } = new Dictionary(); + + public void AddSelfLink(string href) + { + AddGetLink("self", href); + } + + public void AddGetLink(string rel, string href) + { + AddLink(rel, HttpMethod.Get, href); + } + + public void AddPostLink(string rel, string href) + { + AddLink(rel, HttpMethod.Post, href); + } + + public void AddPutLink(string rel, string href) + { + AddLink(rel, HttpMethod.Put, href); + } + + public void AddDeleteLink(string rel, string href) + { + AddLink(rel, HttpMethod.Delete, href); + } + + public void AddLink(string rel, HttpMethod method, string href) + { + Links[rel] = new ResourceLink { Href = href, Method = method }; + } + } +} diff --git a/src/Squidex.Web/ResourceLink.cs b/src/Squidex.Web/ResourceLink.cs new file mode 100644 index 000000000..48627ac3a --- /dev/null +++ b/src/Squidex.Web/ResourceLink.cs @@ -0,0 +1,23 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.ComponentModel.DataAnnotations; +using System.Net.Http; + +namespace Squidex.Web +{ + public class ResourceLink + { + [Required] + [Display(Description = "The link url.")] + public string Href { get; set; } + + [Required] + [Display(Description = "The link method.")] + public HttpMethod Method { get; set; } + } +} diff --git a/src/Squidex.Web/UrlHelperExtensions.cs b/src/Squidex.Web/UrlHelperExtensions.cs new file mode 100644 index 000000000..486d48a76 --- /dev/null +++ b/src/Squidex.Web/UrlHelperExtensions.cs @@ -0,0 +1,46 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Mvc; +using System; +using System.Linq.Expressions; +using System.Reflection; + +namespace Squidex.Web +{ + public static class UrlHelperExtensions + { + private static class NameOf + { + public static readonly string Controller; + + static NameOf() + { + const string suffix = "Controller"; + + var name = typeof(T).Name; + + if (name.EndsWith(suffix)) + { + name = name.Substring(0, name.Length - suffix.Length); + } + + Controller = name; + } + } + + public static string Url(this IUrlHelper urlHelper, Func action, object values = null) where T : Controller + { + return urlHelper.Action(action(null), NameOf.Controller, values); + } + + public static string Url(this Controller controller, Func action, object values = null) where T : Controller + { + return controller.Url.Url(action, values); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs index 8a2a5a2d4..2481f73c8 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/UserDto.cs @@ -8,12 +8,18 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.Security; using Squidex.Shared.Users; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Users.Models { - public sealed class UserDto + public sealed class UserDto : Resource { + private static readonly Permission LockPermission = new Permission(Shared.Permissions.AdminUsersLock); + private static readonly Permission UnlockPermission = new Permission(Shared.Permissions.AdminUsersUnlock); + private static readonly Permission UpdatePermission = new Permission(Shared.Permissions.AdminUsersUpdate); + /// /// The id of the user. /// @@ -44,11 +50,50 @@ namespace Squidex.Areas.Api.Controllers.Users.Models [Required] public IEnumerable Permissions { get; set; } - public static UserDto FromUser(IUser user) + public static UserDto FromUser(IUser user, ApiController controller) + { + var userPermssions = user.Permissions().ToIds(); + var userName = user.DisplayName(); + + var result = SimpleMapper.Map(user, new UserDto { DisplayName = userName, Permissions = userPermssions }); + + return CreateLinks(result, controller); + } + + private static UserDto CreateLinks(UserDto result, ApiController controller) { - var permissions = user.Permissions().ToIds(); + var values = new { id = result.Id }; + + if (controller is UserManagementController) + { + result.AddSelfLink(controller.Url(c => nameof(c.GetUser), values)); + } + else + { + result.AddSelfLink(controller.Url(c => nameof(c.GetUser), values)); + } + + if (!controller.IsUser(result.Id)) + { + if (controller.HasPermission(LockPermission) && !result.IsLocked) + { + result.AddPutLink("lock", controller.Url(c => nameof(c.LockUser), values)); + } + + if (controller.HasPermission(UnlockPermission) && result.IsLocked) + { + result.AddPutLink("unlock", controller.Url(c => nameof(c.UnlockUser), values)); + } + } + + if (controller.HasPermission(UpdatePermission)) + { + result.AddPutLink("update", controller.Url(c => nameof(c.PutUser), values)); + } + + result.AddGetLink("picture", controller.Url(c => nameof(c.GetUserPicture), values)); - return SimpleMapper.Map(user, new UserDto { DisplayName = user.DisplayName(), Permissions = permissions }); + return result; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/UsersDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/UsersDto.cs index 2d83ff47d..e866c8af4 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/UsersDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/UsersDto.cs @@ -5,10 +5,19 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Collections.Generic; +using System.Linq; +using Squidex.Domain.Users; +using Squidex.Infrastructure.Security; +using Squidex.Shared; +using Squidex.Web; + namespace Squidex.Areas.Api.Controllers.Users.Models { - public sealed class UsersDto + public sealed class UsersDto : Resource { + private static readonly Permission CreatePermissions = new Permission(Permissions.AdminUsersCreate); + /// /// The total number of users. /// @@ -18,5 +27,28 @@ namespace Squidex.Areas.Api.Controllers.Users.Models /// The users. /// public UserDto[] Items { get; set; } + + public static UsersDto FromResults(IEnumerable items, long total, ApiController controller) + { + var result = new UsersDto + { + Total = total, + Items = items.Select(x => UserDto.FromUser(x, controller)).ToArray() + }; + + return CreateLinks(result, controller); + } + + private static UsersDto CreateLinks(UsersDto result, ApiController controller) + { + result.AddSelfLink(controller.Url(c => nameof(c.GetUsers))); + + if (controller.HasPermission(CreatePermissions)) + { + result.AddPostLink("create", controller.Url(c => nameof(c.PostUser))); + } + + return result; + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs b/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs index 7d547be14..16862daab 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs @@ -5,8 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; @@ -14,7 +12,6 @@ using Squidex.Areas.Api.Controllers.Users.Models; using Squidex.Domain.Users; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Security; using Squidex.Shared; using Squidex.Web; @@ -43,11 +40,7 @@ namespace Squidex.Areas.Api.Controllers.Users await Task.WhenAll(taskForItems, taskForCount); - var response = new UsersDto - { - Total = taskForCount.Result, - Items = taskForItems.Result.Select(UserDto.FromUser).ToArray() - }; + var response = UsersDto.FromResults(taskForItems.Result, taskForCount.Result, this); return Ok(response); } @@ -64,7 +57,7 @@ namespace Squidex.Areas.Api.Controllers.Users return NotFound(); } - var response = UserDto.FromUser(entity); + var response = UserDto.FromUser(entity, this); return Ok(response); } @@ -96,7 +89,7 @@ namespace Squidex.Areas.Api.Controllers.Users [ApiPermission(Permissions.AdminUsersLock)] public async Task LockUser(string id) { - if (IsSelf(id)) + if (this.IsUser(id)) { throw new ValidationException("Locking user failed.", new ValidationError("You cannot lock yourself.")); } @@ -111,7 +104,7 @@ namespace Squidex.Areas.Api.Controllers.Users [ApiPermission(Permissions.AdminUsersUnlock)] public async Task UnlockUser(string id) { - if (IsSelf(id)) + if (this.IsUser(id)) { throw new ValidationException("Unlocking user failed.", new ValidationError("You cannot unlock yourself.")); } @@ -120,12 +113,5 @@ namespace Squidex.Areas.Api.Controllers.Users return NoContent(); } - - private bool IsSelf(string id) - { - var subject = User.OpenIdSubject(); - - return string.Equals(subject, id, StringComparison.OrdinalIgnoreCase); - } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs index 36c556adb..5311ff642 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs @@ -76,7 +76,7 @@ namespace Squidex.Areas.Api.Controllers.Users { var entities = await userResolver.QueryByEmailAsync(query); - var models = entities.Where(x => !x.IsHidden()).Select(UserDto.FromUser).ToArray(); + var models = entities.Where(x => !x.IsHidden()).Select(x => UserDto.FromUser(x, this)).ToArray(); return Ok(models); } @@ -110,7 +110,7 @@ namespace Squidex.Areas.Api.Controllers.Users if (entity != null) { - var response = UserDto.FromUser(entity); + var response = UserDto.FromUser(entity, this); return Ok(response); } diff --git a/src/Squidex/app/features/administration/pages/users/user-page.component.html b/src/Squidex/app/features/administration/pages/users/user-page.component.html index b9d1bdb51..d23721240 100644 --- a/src/Squidex/app/features/administration/pages/users/user-page.component.html +++ b/src/Squidex/app/features/administration/pages/users/user-page.component.html @@ -50,7 +50,7 @@
-
+
diff --git a/src/Squidex/app/features/administration/pages/users/user-page.component.ts b/src/Squidex/app/features/administration/pages/users/user-page.component.ts index 0c7e1efc2..9a7e9b71a 100644 --- a/src/Squidex/app/features/administration/pages/users/user-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/user-page.component.ts @@ -26,7 +26,7 @@ import { export class UserPageComponent extends ResourceOwner implements OnInit { public canUpdate = false; - public user?: { user: UserDto, isCurrentUser: boolean }; + public user?: UserDto; public userForm = new UserForm(this.formBuilder); constructor( @@ -45,7 +45,7 @@ export class UserPageComponent extends ResourceOwner implements OnInit { this.user = selectedUser!; if (selectedUser) { - this.userForm.load(selectedUser.user); + this.userForm.load(selectedUser); } })); } @@ -55,7 +55,7 @@ export class UserPageComponent extends ResourceOwner implements OnInit { if (value) { if (this.user) { - this.usersState.update(this.user.user, value) + this.usersState.update(this.user, value) .subscribe(() => { this.userForm.submitCompleted(); }, error => { diff --git a/src/Squidex/app/features/administration/pages/users/users-page.component.html b/src/Squidex/app/features/administration/pages/users/users-page.component.html index 223f584bf..167ee3eb9 100644 --- a/src/Squidex/app/features/administration/pages/users/users-page.component.html +++ b/src/Squidex/app/features/administration/pages/users/users-page.component.html @@ -48,32 +48,24 @@
- - + + diff --git a/src/Squidex/app/features/administration/services/users.service.ts b/src/Squidex/app/features/administration/services/users.service.ts index a657eee38..b42fe46a2 100644 --- a/src/Squidex/app/features/administration/services/users.service.ts +++ b/src/Squidex/app/features/administration/services/users.service.ts @@ -14,12 +14,19 @@ import { ApiUrlConfig, Model, pretifyError, - ResultSet + Resource, + ResourceLinks, + ResultSet, + withLinks } from '@app/shared'; -export class UsersDto extends ResultSet {} +export class UsersDto extends ResultSet { + public _links: ResourceLinks; +} export class UserDto extends Model { + public _links: ResourceLinks; + constructor( public readonly id: string, public readonly email: string, @@ -60,17 +67,19 @@ export class UsersService { public getUsers(take: number, skip: number, query?: string): Observable { const url = this.apiUrl.buildUrl(`api/user-management?take=${take}&skip=${skip}&query=${query || ''}`); - return this.http.get<{ total: number, items: any[] }>(url).pipe( + return this.http.get<{ total: number, items: any[] } & Resource>(url).pipe( map(body => { const users = body.items.map(item => - new UserDto( - item.id, - item.email, - item.displayName, - item.permissions, - item.isLocked)); - - return new UsersDto(body.total, users); + withLinks( + new UserDto( + item.id, + item.email, + item.displayName, + item.permissions, + item.isLocked), + item)); + + return withLinks(new UsersDto(body.total, users), body); }), pretifyError('Failed to load users. Please reload.')); } diff --git a/src/Squidex/app/features/administration/state/users.state.spec.ts b/src/Squidex/app/features/administration/state/users.state.spec.ts index c2a912ecd..e735870a5 100644 --- a/src/Squidex/app/features/administration/state/users.state.spec.ts +++ b/src/Squidex/app/features/administration/state/users.state.spec.ts @@ -16,7 +16,7 @@ import { UsersService } from '@app/features/administration/internal'; -import { SnapshotUser, UsersState } from './users.state'; +import { UsersState } from './users.state'; describe('UsersState', () => { const oldUsers = [ @@ -26,21 +26,15 @@ describe('UsersState', () => { const newUser = new UserDto('id3', 'mail3@mail.de', 'name3', ['Permission3'], false); - let authService: IMock; let dialogs: IMock; let usersService: IMock; let usersState: UsersState; beforeEach(() => { - authService = Mock.ofType(); - - authService.setup(x => x.user) - .returns(() => { id: 'id2' }); - dialogs = Mock.ofType(); usersService = Mock.ofType(); - usersState = new UsersState(authService.object, dialogs.object, usersService.object); + usersState = new UsersState(dialogs.object, usersService.object); }); afterEach(() => { @@ -54,10 +48,7 @@ describe('UsersState', () => { usersState.load().subscribe(); - expect(usersState.snapshot.users.values).toEqual([ - { isCurrentUser: false, user: oldUsers[0] }, - { isCurrentUser: true, user: oldUsers[1] } - ]); + expect(usersState.snapshot.users.values).toEqual(oldUsers); expect(usersState.snapshot.usersPager.numberOfItems).toEqual(200); expect(usersState.snapshot.isLoaded).toBeTruthy(); @@ -91,7 +82,7 @@ describe('UsersState', () => { usersState.select('id1').subscribe(); usersState.load().subscribe(); - expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: newUsers[0] }); + expect(usersState.snapshot.selectedUser).toEqual(newUsers[0]); }); it('should load next page and prev page when paging', () => { @@ -127,32 +118,32 @@ describe('UsersState', () => { }); it('should return user on select and not load when already loaded', () => { - let selectedUser: SnapshotUser; + let selectedUser: UserDto; usersState.select('id1').subscribe(x => { selectedUser = x!; }); - expect(selectedUser!.user).toEqual(oldUsers[0]); - expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: oldUsers[0] }); + expect(selectedUser!).toEqual(oldUsers[0]); + expect(usersState.snapshot.selectedUser).toEqual(oldUsers[0]); }); it('should return user on select and load when not loaded', () => { usersService.setup(x => x.getUser('id3')) .returns(() => of(newUser)); - let selectedUser: SnapshotUser; + let selectedUser: UserDto; usersState.select('id3').subscribe(x => { selectedUser = x!; }); - expect(selectedUser!.user).toEqual(newUser); - expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: newUser }); + expect(selectedUser!).toEqual(newUser); + expect(usersState.snapshot.selectedUser).toEqual(newUser); }); it('should return null on select when unselecting user', () => { - let selectedUser: SnapshotUser; + let selectedUser: UserDto; usersState.select(null).subscribe(x => { selectedUser = x!; @@ -166,7 +157,7 @@ describe('UsersState', () => { usersService.setup(x => x.getUser('unknown')) .returns(() => throwError({})).verifiable(); - let selectedUser: SnapshotUser; + let selectedUser: UserDto; usersState.select('unknown').subscribe(x => { selectedUser = x!; @@ -185,7 +176,7 @@ describe('UsersState', () => { const user_1 = usersState.snapshot.users.at(0); - expect(user_1.user.isLocked).toBeTruthy(); + expect(user_1.isLocked).toBeTruthy(); expect(user_1).toBe(usersState.snapshot.selectedUser!); }); @@ -198,7 +189,7 @@ describe('UsersState', () => { const user_1 = usersState.snapshot.users.at(1); - expect(user_1.user.isLocked).toBeFalsy(); + expect(user_1.isLocked).toBeFalsy(); expect(user_1).toBe(usersState.snapshot.selectedUser!); }); @@ -213,9 +204,9 @@ describe('UsersState', () => { const user_1 = usersState.snapshot.users.at(0); - expect(user_1.user.email).toEqual(request.email); - expect(user_1.user.displayName).toEqual(request.displayName); - expect(user_1.user.permissions).toEqual(request.permissions); + expect(user_1.email).toEqual(request.email); + expect(user_1.displayName).toEqual(request.displayName); + expect(user_1.permissions).toEqual(request.permissions); expect(user_1).toBe(usersState.snapshot.selectedUser!); }); @@ -227,11 +218,7 @@ describe('UsersState', () => { usersState.create(request).subscribe(); - expect(usersState.snapshot.users.values).toEqual([ - { isCurrentUser: false, user: newUser }, - { isCurrentUser: false, user: oldUsers[0] }, - { isCurrentUser: true, user: oldUsers[1] } - ]); + expect(usersState.snapshot.users.values).toEqual([newUser, ...oldUsers]); expect(usersState.snapshot.usersPager.numberOfItems).toBe(201); }); }); diff --git a/src/Squidex/app/features/administration/state/users.state.ts b/src/Squidex/app/features/administration/state/users.state.ts index 7de907b24..49c5163cc 100644 --- a/src/Squidex/app/features/administration/state/users.state.ts +++ b/src/Squidex/app/features/administration/state/users.state.ts @@ -7,12 +7,11 @@ import { Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; -import { catchError, distinctUntilChanged, map, tap } from 'rxjs/operators'; +import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; import '@app/framework/utils/rxjs-extensions'; import { - AuthService, DialogService, ImmutableArray, Pager, @@ -27,14 +26,6 @@ import { UsersService } from './../services/users.service'; -export interface SnapshotUser { - // The user. - user: UserDto; - - // Indicates if the user is the current user. - isCurrentUser: boolean; -} - interface Snapshot { // The current users. users: UsersList; @@ -49,10 +40,10 @@ interface Snapshot { isLoaded?: boolean; // The selected user. - selectedUser?: SnapshotUser | null; + selectedUser?: UserDto | null; } -export type UsersList = ImmutableArray; +export type UsersList = ImmutableArray; export type UsersResult = { total: number, users: UsersList }; @Injectable() @@ -74,14 +65,13 @@ export class UsersState extends State { distinctUntilChanged()); constructor( - private readonly authState: AuthService, private readonly dialogs: DialogService, private readonly usersService: UsersService ) { super({ users: ImmutableArray.empty(), usersPager: new Pager(0) }); } - public select(id: string | null): Observable { + public select(id: string | null): Observable { return this.loadUser(id).pipe( tap(selectedUser => { this.next(s => ({ ...s, selectedUser })); @@ -94,13 +84,13 @@ export class UsersState extends State { return of(null); } - const found = this.snapshot.users.find(x => x.user.id === id); + const found = this.snapshot.users.find(x => x.id === id); if (found) { return of(found); } - return this.usersService.getUser(id).pipe(map(x => this.createUser(x)), catchError(() => of(null))); + return this.usersService.getUser(id).pipe(catchError(() => of(null))); } public load(isReload = false): Observable { @@ -125,12 +115,12 @@ export class UsersState extends State { this.next(s => { const usersPager = s.usersPager.setCount(total); - const users = ImmutableArray.of(items.map(x => this.createUser(x))); + const users = ImmutableArray.of(items); let selectedUser = s.selectedUser; if (selectedUser) { - selectedUser = users.find(x => x.user.id === selectedUser!.user.id) || selectedUser; + selectedUser = users.find(x => x.id === selectedUser!.id) || selectedUser; } return { ...s, users, usersPager, selectedUser, isLoaded: true }; @@ -143,7 +133,7 @@ export class UsersState extends State { return this.usersService.postUser(request).pipe( tap(created => { this.next(s => { - const users = s.users.pushFront(this.createUser(created)); + const users = s.users.pushFront(created); const usersPager = s.usersPager.incrementCount(); return { ...s, users, usersPager }; @@ -154,7 +144,7 @@ export class UsersState extends State { public update(user: UserDto, request: UpdateUserDto): Observable { return this.usersService.putUser(user.id, request).pipe( - map(() => update(user, request)), + switchMap(() => this.usersService.getUser(user.id)), tap(updated => { this.replaceUser(updated); }), @@ -163,7 +153,7 @@ export class UsersState extends State { public lock(user: UserDto): Observable { return this.usersService.lockUser(user.id).pipe( - map(() => setLocked(user, true)), + switchMap(() => this.usersService.getUser(user.id)), tap(updated => { this.replaceUser(updated); }), @@ -172,7 +162,7 @@ export class UsersState extends State { public unlock(user: UserDto): Observable { return this.usersService.unlockUser(user.id).pipe( - map(() => setLocked(user, false)), + switchMap(() => this.usersService.getUser(user.id)), tap(updated => { this.replaceUser(updated); }), @@ -199,30 +189,15 @@ export class UsersState extends State { private replaceUser(user: UserDto) { return this.next(s => { - const users = s.users.map(u => u.user.id === user.id ? this.createUser(user) : u); + const users = s.users.map(u => u.id === user.id ? user : u); const selectedUser = s.selectedUser && - s.selectedUser.user.id !== user.id ? + s.selectedUser.id !== user.id ? s.selectedUser : - users.find(x => x.user.id === user.id); + users.find(x => x.id === user.id); return { ...s, users, selectedUser }; }); } - - private get userId() { - return this.authState.user!.id; - } - - private createUser(user: UserDto): SnapshotUser { - return { user, isCurrentUser: user.id === this.userId }; - } -} - - -const update = (user: UserDto, request: UpdateUserDto) => - user.with(request); - -const setLocked = (user: UserDto, isLocked: boolean) => - user.with({ isLocked }); \ No newline at end of file +} \ No newline at end of file diff --git a/src/Squidex/app/framework/angular/http/hateos.pipes.ts b/src/Squidex/app/framework/angular/http/hateos.pipes.ts new file mode 100644 index 000000000..979c95205 --- /dev/null +++ b/src/Squidex/app/framework/angular/http/hateos.pipes.ts @@ -0,0 +1,23 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { Resource } from '@app/framework/internal'; + +@Pipe({ + name: 'sqxHasLink', + pure: true +}) +export class HasLinkPipe implements PipeTransform { + public transform(value: Resource, rel: string) { + return value._links && !!value._links[rel]; + } +} + +@Pipe({ + name: 'sqxHasNoLink', + pure: true +}) +export class HasNoLinkPipe implements PipeTransform { + public transform(value: Resource, rel: string) { + return !value._links || !value._links[rel]; + } +} \ No newline at end of file diff --git a/src/Squidex/app/framework/declarations.ts b/src/Squidex/app/framework/declarations.ts index 7111dde33..5cf90253c 100644 --- a/src/Squidex/app/framework/declarations.ts +++ b/src/Squidex/app/framework/declarations.ts @@ -31,6 +31,7 @@ export * from './angular/forms/validators'; export * from './angular/http/caching.interceptor'; export * from './angular/http/loading.interceptor'; +export * from './angular/http/hateos.pipes'; export * from './angular/http/http-extensions'; export * from './angular/modals/dialog-renderer.component'; diff --git a/src/Squidex/app/framework/internal.ts b/src/Squidex/app/framework/internal.ts index 776e7df49..73b658619 100644 --- a/src/Squidex/app/framework/internal.ts +++ b/src/Squidex/app/framework/internal.ts @@ -23,6 +23,7 @@ export * from './utils/date-helper'; export * from './utils/date-time'; export * from './utils/duration'; export * from './utils/error'; +export * from './utils/hateos'; export * from './utils/interpolator'; export * from './utils/immutable-array'; export * from './utils/math-helper'; diff --git a/src/Squidex/app/framework/module.ts b/src/Squidex/app/framework/module.ts index 5921bf736..b18aad34b 100644 --- a/src/Squidex/app/framework/module.ts +++ b/src/Squidex/app/framework/module.ts @@ -42,6 +42,8 @@ import { FormHintComponent, FromNowPipe, FullDateTimePipe, + HasLinkPipe, + HasNoLinkPipe, HoverBackgroundDirective, IFrameEditorComponent, IgnoreScrollbarDirective, @@ -122,6 +124,8 @@ import { FormHintComponent, FromNowPipe, FullDateTimePipe, + HasLinkPipe, + HasNoLinkPipe, HoverBackgroundDirective, IFrameEditorComponent, IgnoreScrollbarDirective, @@ -188,6 +192,8 @@ import { FormsModule, FromNowPipe, FullDateTimePipe, + HasLinkPipe, + HasNoLinkPipe, HoverBackgroundDirective, IFrameEditorComponent, IgnoreScrollbarDirective, diff --git a/src/Squidex/app/framework/utils/hateos.ts b/src/Squidex/app/framework/utils/hateos.ts new file mode 100644 index 000000000..14e5357d8 --- /dev/null +++ b/src/Squidex/app/framework/utils/hateos.ts @@ -0,0 +1,25 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + + export interface Resource { + _links?: { [rel: string]: ResourceLink }; + } + + export type ResourceLinks = { [rel: string]: ResourceLink }; + export type ResourceLink = { href: string; method: ResourceMethod; }; + + export function withLinks(value: T, source: Resource) { + value._links = source._links; + + return value; + } + + export type ResourceMethod = + 'get' | + 'post' | + 'put' | + 'delete'; \ No newline at end of file From ca77b20d6ad0029089bf250dad73b91cb89a156b Mon Sep 17 00:00:00 2001 From: Sebastian Date: Fri, 7 Jun 2019 15:56:49 +0200 Subject: [PATCH 010/107] HATEOS for users. --- .../UserManagerExtensions.cs | 16 +- src/Squidex.Web/Resource.cs | 15 +- src/Squidex.Web/ResourceLink.cs | 3 +- .../Contents/ContentsController.cs | 4 +- .../Controllers/Users/Models/CreateUserDto.cs | 8 +- .../Controllers/Users/Models/PublicUserDto.cs | 26 ---- .../Controllers/Users/Models/UpdateUserDto.cs | 8 +- .../Users/Models/UserCreatedDto.cs | 26 ---- .../Users/UserManagementController.cs | 28 +++- .../Api/Controllers/Users/UsersController.cs | 8 +- .../guards/user-must-exist.guard.spec.ts | 4 +- .../pages/users/user-page.component.html | 14 +- .../pages/users/user-page.component.ts | 10 +- .../pages/users/users-page.component.html | 11 +- .../pages/users/users-page.component.ts | 4 +- .../services/users.service.spec.ts | 139 +++++++++++------- .../administration/services/users.service.ts | 84 ++++++----- .../administration/state/users.state.spec.ts | 88 +++++------ .../administration/state/users.state.ts | 25 ++-- .../framework/angular/http/hateos.pipes.ts | 23 ++- src/Squidex/app/framework/utils/hateos.ts | 47 ++++-- 21 files changed, 330 insertions(+), 261 deletions(-) delete mode 100644 src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs delete mode 100644 src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs diff --git a/src/Squidex.Domain.Users/UserManagerExtensions.cs b/src/Squidex.Domain.Users/UserManagerExtensions.cs index 8fa0115a5..2db94237f 100644 --- a/src/Squidex.Domain.Users/UserManagerExtensions.cs +++ b/src/Squidex.Domain.Users/UserManagerExtensions.cs @@ -115,7 +115,7 @@ namespace Squidex.Domain.Users return result; } - public static async Task CreateAsync(this UserManager userManager, IUserFactory factory, UserValues values) + public static async Task CreateAsync(this UserManager userManager, IUserFactory factory, UserValues values) { var user = factory.Create(values.Email); @@ -142,10 +142,10 @@ namespace Squidex.Domain.Users throw; } - return user; + return await userManager.ResolveUserAsync(user); } - public static async Task UpdateAsync(this UserManager userManager, string id, UserValues values) + public static async Task UpdateAsync(this UserManager userManager, string id, UserValues values) { var user = await userManager.FindByIdAsync(id); @@ -155,6 +155,8 @@ namespace Squidex.Domain.Users } await UpdateAsync(userManager, user, values); + + return await userManager.ResolveUserAsync(user); } public static async Task UpdateSafeAsync(this UserManager userManager, IdentityUser user, UserValues values) @@ -193,7 +195,7 @@ namespace Squidex.Domain.Users } } - public static async Task LockAsync(this UserManager userManager, string id) + public static async Task LockAsync(this UserManager userManager, string id) { var user = await userManager.FindByIdAsync(id); @@ -203,9 +205,11 @@ namespace Squidex.Domain.Users } await DoChecked(() => userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)), "Cannot lock user."); + + return await userManager.ResolveUserAsync(user); } - public static async Task UnlockAsync(this UserManager userManager, string id) + public static async Task UnlockAsync(this UserManager userManager, string id) { var user = await userManager.FindByIdAsync(id); @@ -215,6 +219,8 @@ namespace Squidex.Domain.Users } await DoChecked(() => userManager.SetLockoutEndDateAsync(user, null), "Cannot unlock user."); + + return await userManager.ResolveUserAsync(user); } private static async Task DoChecked(Func> action, string message) diff --git a/src/Squidex.Web/Resource.cs b/src/Squidex.Web/Resource.cs index b62a5f361..a0e68197c 100644 --- a/src/Squidex.Web/Resource.cs +++ b/src/Squidex.Web/Resource.cs @@ -26,25 +26,30 @@ namespace Squidex.Web public void AddGetLink(string rel, string href) { - AddLink(rel, HttpMethod.Get, href); + AddLink(rel, "GET", href); + } + + public void AddPatchLink(string rel, string href) + { + AddLink(rel, "PATCH", href); } public void AddPostLink(string rel, string href) { - AddLink(rel, HttpMethod.Post, href); + AddLink(rel, "POST", href); } public void AddPutLink(string rel, string href) { - AddLink(rel, HttpMethod.Put, href); + AddLink(rel, "PUT", href); } public void AddDeleteLink(string rel, string href) { - AddLink(rel, HttpMethod.Delete, href); + AddLink(rel, "DELETE", href); } - public void AddLink(string rel, HttpMethod method, string href) + public void AddLink(string rel, string method, string href) { Links[rel] = new ResourceLink { Href = href, Method = method }; } diff --git a/src/Squidex.Web/ResourceLink.cs b/src/Squidex.Web/ResourceLink.cs index 48627ac3a..964610e7d 100644 --- a/src/Squidex.Web/ResourceLink.cs +++ b/src/Squidex.Web/ResourceLink.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.ComponentModel.DataAnnotations; -using System.Net.Http; namespace Squidex.Web { @@ -18,6 +17,6 @@ namespace Squidex.Web [Required] [Display(Description = "The link method.")] - public HttpMethod Method { get; set; } + public string Method { get; set; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index fc1e9e63f..bd374a986 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -278,9 +278,7 @@ namespace Squidex.Areas.Api.Controllers.Contents { await contentQuery.ThrowIfSchemaNotExistsAsync(Context(), name); - var publishPermission = Permissions.ForApp(Permissions.AppContentsPublish, app, name); - - if (publish && !User.Permissions().Includes(publishPermission)) + if (publish && !this.HasPermission(Permissions.AppContentsPublish, app, name)) { return new StatusCodeResult(123); } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs index b59f83b53..9dbd2feac 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/CreateUserDto.cs @@ -40,7 +40,13 @@ namespace Squidex.Areas.Api.Controllers.Users.Models public UserValues ToValues() { - return new UserValues { Email = Email, DisplayName = DisplayName, Password = Password, Permissions = new PermissionSet(Permissions) }; + return new UserValues + { + Email = Email, + DisplayName = DisplayName, + Password = Password, + Permissions = new PermissionSet(Permissions) + }; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs deleted file mode 100644 index e398c88be..000000000 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/PublicUserDto.cs +++ /dev/null @@ -1,26 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.ComponentModel.DataAnnotations; - -namespace Squidex.Areas.Api.Controllers.Users.Models -{ - public sealed class PublicUserDto - { - /// - /// The id of the user. - /// - [Required] - public string Id { get; set; } - - /// - /// The display name (usually first name and last name) of the user. - /// - [Required] - public string DisplayName { get; set; } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs index 77b41f567..d6391da83 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/UpdateUserDto.cs @@ -39,7 +39,13 @@ namespace Squidex.Areas.Api.Controllers.Users.Models public UserValues ToValues() { - return new UserValues { Email = Email, DisplayName = DisplayName, Password = Password, Permissions = new PermissionSet(Permissions) }; + return new UserValues + { + Email = Email, + DisplayName = DisplayName, + Password = Password, + Permissions = new PermissionSet(Permissions) + }; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs deleted file mode 100644 index 090d81023..000000000 --- a/src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs +++ /dev/null @@ -1,26 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschränkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using System.ComponentModel.DataAnnotations; - -namespace Squidex.Areas.Api.Controllers.Users.Models -{ - public sealed class UserCreatedDto - { - /// - /// The id of the user. - /// - [Required] - public string Id { get; set; } - - /// - /// Additional permissions for the user. - /// - [Required] - public string[] Permissions { get; set; } - } -} diff --git a/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs b/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs index 16862daab..6157b120b 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/UserManagementController.cs @@ -32,6 +32,7 @@ namespace Squidex.Areas.Api.Controllers.Users [HttpGet] [Route("user-management/")] + [ProducesResponseType(typeof(UsersDto), 200)] [ApiPermission(Permissions.AdminUsersRead)] public async Task GetUsers([FromQuery] string query = null, [FromQuery] int skip = 0, [FromQuery] int take = 10) { @@ -47,6 +48,7 @@ namespace Squidex.Areas.Api.Controllers.Users [HttpGet] [Route("user-management/{id}/")] + [ProducesResponseType(typeof(UserDto), 201)] [ApiPermission(Permissions.AdminUsersRead)] public async Task GetUser(string id) { @@ -64,28 +66,33 @@ namespace Squidex.Areas.Api.Controllers.Users [HttpPost] [Route("user-management/")] + [ProducesResponseType(typeof(UserDto), 201)] [ApiPermission(Permissions.AdminUsersCreate)] public async Task PostUser([FromBody] CreateUserDto request) { - var user = await userManager.CreateAsync(userFactory, request.ToValues()); + var entity = await userManager.CreateAsync(userFactory, request.ToValues()); - var response = new UserCreatedDto { Id = user.Id }; + var response = UserDto.FromUser(entity, this); return Ok(response); } [HttpPut] [Route("user-management/{id}/")] + [ProducesResponseType(typeof(UserDto), 201)] [ApiPermission(Permissions.AdminUsersUpdate)] public async Task PutUser(string id, [FromBody] UpdateUserDto request) { - await userManager.UpdateAsync(id, request.ToValues()); + var entity = await userManager.UpdateAsync(id, request.ToValues()); + + var response = UserDto.FromUser(entity, this); - return NoContent(); + return Ok(response); } [HttpPut] [Route("user-management/{id}/lock/")] + [ProducesResponseType(typeof(UserDto), 201)] [ApiPermission(Permissions.AdminUsersLock)] public async Task LockUser(string id) { @@ -94,13 +101,16 @@ namespace Squidex.Areas.Api.Controllers.Users throw new ValidationException("Locking user failed.", new ValidationError("You cannot lock yourself.")); } - await userManager.LockAsync(id); + var entity = await userManager.LockAsync(id); + + var response = UserDto.FromUser(entity, this); - return NoContent(); + return Ok(response); } [HttpPut] [Route("user-management/{id}/unlock/")] + [ProducesResponseType(typeof(UserDto), 201)] [ApiPermission(Permissions.AdminUsersUnlock)] public async Task UnlockUser(string id) { @@ -109,9 +119,11 @@ namespace Squidex.Areas.Api.Controllers.Users throw new ValidationException("Unlocking user failed.", new ValidationError("You cannot unlock yourself.")); } - await userManager.UnlockAsync(id); + var entity = await userManager.UnlockAsync(id); - return NoContent(); + var response = UserDto.FromUser(entity, this); + + return Ok(response); } } } diff --git a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs index 5311ff642..a5cf31c74 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs @@ -68,7 +68,7 @@ namespace Squidex.Areas.Api.Controllers.Users /// [HttpGet] [Route("users/")] - [ProducesResponseType(typeof(PublicUserDto[]), 200)] + [ProducesResponseType(typeof(UserDto[]), 200)] [ApiPermission] public async Task GetUsers(string query) { @@ -76,9 +76,9 @@ namespace Squidex.Areas.Api.Controllers.Users { var entities = await userResolver.QueryByEmailAsync(query); - var models = entities.Where(x => !x.IsHidden()).Select(x => UserDto.FromUser(x, this)).ToArray(); + var response = entities.Where(x => !x.IsHidden()).Select(x => UserDto.FromUser(x, this)).ToArray(); - return Ok(models); + return Ok(response); } catch (Exception ex) { @@ -100,7 +100,7 @@ namespace Squidex.Areas.Api.Controllers.Users /// [HttpGet] [Route("users/{id}/")] - [ProducesResponseType(typeof(PublicUserDto), 200)] + [ProducesResponseType(typeof(UserDto), 200)] [ApiPermission] public async Task GetUser(string id) { diff --git a/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts b/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts index a5c03338f..a12487077 100644 --- a/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts +++ b/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts @@ -9,7 +9,7 @@ import { Router } from '@angular/router'; import { of } from 'rxjs'; import { IMock, Mock, Times } from 'typemoq'; -import { SnapshotUser, UsersState } from '@app/features/administration/internal'; +import { UserDto, UsersState } from '@app/features/administration/internal'; import { UserMustExistGuard } from './user-must-exist.guard'; @@ -32,7 +32,7 @@ describe('UserMustExistGuard', () => { it('should load user and return true when found', () => { usersState.setup(x => x.select('123')) - .returns(() => of({})); + .returns(() => of({})); let result: boolean; diff --git a/src/Squidex/app/features/administration/pages/users/user-page.component.html b/src/Squidex/app/features/administration/pages/users/user-page.component.html index d23721240..44a638173 100644 --- a/src/Squidex/app/features/administration/pages/users/user-page.component.html +++ b/src/Squidex/app/features/administration/pages/users/user-page.component.html @@ -15,12 +15,14 @@ - - - - + + + + + + diff --git a/src/Squidex/app/features/administration/pages/users/user-page.component.ts b/src/Squidex/app/features/administration/pages/users/user-page.component.ts index 9a7e9b71a..53a9ac15e 100644 --- a/src/Squidex/app/features/administration/pages/users/user-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/user-page.component.ts @@ -9,7 +9,7 @@ import { Component, OnInit } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; -import { ResourceOwner } from '@app/shared'; +import { hasLink, ResourceOwner } from '@app/shared'; import { CreateUserDto, @@ -46,11 +46,19 @@ export class UserPageComponent extends ResourceOwner implements OnInit { if (selectedUser) { this.userForm.load(selectedUser); + + if (!hasLink(selectedUser, 'update')) { + this.userForm.form.disable(); + } } })); } public save() { + if (this.userForm.form.disabled) { + return; + } + const value = this.userForm.submit(); if (value) { diff --git a/src/Squidex/app/features/administration/pages/users/users-page.component.html b/src/Squidex/app/features/administration/pages/users/users-page.component.html index 167ee3eb9..f71733085 100644 --- a/src/Squidex/app/features/administration/pages/users/users-page.component.html +++ b/src/Squidex/app/features/administration/pages/users/users-page.component.html @@ -12,15 +12,18 @@ -
- + + + + +
diff --git a/src/Squidex/app/features/administration/pages/users/users-page.component.ts b/src/Squidex/app/features/administration/pages/users/users-page.component.ts index 065b20858..f029712b4 100644 --- a/src/Squidex/app/features/administration/pages/users/users-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/users-page.component.ts @@ -51,8 +51,8 @@ export class UsersPageComponent implements OnInit { this.usersState.unlock(user); } - public trackByUser(index: number, userInfo: { user: UserDto }) { - return userInfo.user.id; + public trackByUser(index: number, user: UserDto) { + return user.id; } } diff --git a/src/Squidex/app/features/administration/services/users.service.spec.ts b/src/Squidex/app/features/administration/services/users.service.spec.ts index ee74d079f..03b9b68fb 100644 --- a/src/Squidex/app/features/administration/services/users.service.spec.ts +++ b/src/Squidex/app/features/administration/services/users.service.spec.ts @@ -8,7 +8,7 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { inject, TestBed } from '@angular/core/testing'; -import { ApiUrlConfig } from '@app/framework'; +import { ApiUrlConfig, Resource } from '@app/framework'; import { UserDto, @@ -50,27 +50,15 @@ describe('UsersService', () => { req.flush({ total: 100, items: [ - { - id: '123', - email: 'mail1@domain.com', - displayName: 'User1', - permissions: ['Permission1'], - isLocked: true - }, - { - id: '456', - email: 'mail2@domain.com', - displayName: 'User2', - permissions: ['Permission2'], - isLocked: true - } + userResponse(12), + userResponse(13) ] }); expect(users!).toEqual( new UsersDto(100, [ - new UserDto('123', 'mail1@domain.com', 'User1', ['Permission1'], true), - new UserDto('456', 'mail2@domain.com', 'User2', ['Permission2'], true) + createUser(12), + createUser(13) ])); })); @@ -91,27 +79,15 @@ describe('UsersService', () => { req.flush({ total: 100, items: [ - { - id: '123', - email: 'mail1@domain.com', - displayName: 'User1', - permissions: ['Permission1'], - isLocked: true - }, - { - id: '456', - email: 'mail2@domain.com', - displayName: 'User2', - permissions: ['Permission2'], - isLocked: true - } + userResponse(12), + userResponse(13) ] }); expect(users!).toEqual( new UsersDto(100, [ - new UserDto('123', 'mail1@domain.com', 'User1', ['Permission1'], true), - new UserDto('456', 'mail2@domain.com', 'User2', ['Permission2'], true) + createUser(12), + createUser(13) ])); })); @@ -129,15 +105,9 @@ describe('UsersService', () => { expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({ - id: '123', - email: 'mail1@domain.com', - displayName: 'User1', - permissions: ['Permission1'], - isLocked: true - }); + req.flush(userResponse(12)); - expect(user!).toEqual(new UserDto('123', 'mail1@domain.com', 'User1', ['Permission1'], true)); + expect(user!).toEqual(createUser(12)); })); it('should make post request to create user', @@ -156,9 +126,9 @@ describe('UsersService', () => { expect(req.request.method).toEqual('POST'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({ id: '123', pictureUrl: 'path/to/image1' }); + req.flush(userResponse(12)); - expect(user!).toEqual(new UserDto('123', dto.email, dto.displayName, dto.permissions, false)); + expect(user!).toEqual(createUser(12)); })); it('should make put request to update user', @@ -166,39 +136,108 @@ describe('UsersService', () => { const dto = { email: 'mail@squidex.io', displayName: 'Squidex User', permissions: ['Permission1'], password: 'password' }; - userManagementService.putUser('123', dto).subscribe(); + const resource: Resource = { + _links: { + update: { method: 'PUT', href: 'api/user-management/123' } + } + }; + + let user: UserDto; + + userManagementService.putUser(resource, dto).subscribe(result => { + user = result; + }); const req = httpMock.expectOne('http://service/p/api/user-management/123'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(userResponse(12)); + + expect(user!).toEqual(createUser(12)); })); it('should make put request to lock user', inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { - userManagementService.lockUser('123').subscribe(); + const resource: Resource = { + _links: { + lock: { method: 'PUT', href: 'api/user-management/123/lock' } + } + }; + + let user: UserDto; + + userManagementService.lockUser(resource).subscribe(result => { + user = result; + }); const req = httpMock.expectOne('http://service/p/api/user-management/123/lock'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(userResponse(12)); + + expect(user!).toEqual(createUser(12)); })); it('should make put request to unlock user', inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => { - userManagementService.unlockUser('123').subscribe(); + const resource: Resource = { + _links: { + unlock: { method: 'PUT', href: 'api/user-management/123/unlock' } + } + }; + + let user: UserDto; + + userManagementService.unlockUser(resource).subscribe(result => { + user = result; + }); const req = httpMock.expectOne('http://service/p/api/user-management/123/unlock'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(userResponse(12)); + + expect(user!).toEqual(createUser(12)); })); -}); \ No newline at end of file + + function userResponse(id: number) { + return { + id: `${id}`, + email: `user${id}@domain.com`, + displayName: `user${id}`, + permissions: [ + `Permission${id}` + ], + isLocked: true, + _links: { + update: { + method: 'PUT', href: `/users/${id}` + } + } + }; + } +}); + +export function createUser(id: number, suffix = '') { + const result = new UserDto(`${id}`, + `user${id}${suffix}@domain.com`, + `user${id}${suffix}`, + [ + `Permission${id}${suffix}` + ], + true); + + result._links['update'] = { + method: 'PUT', href: `/users/${id}` + }; + + return result; +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/services/users.service.ts b/src/Squidex/app/features/administration/services/users.service.ts index b42fe46a2..a1eb3e9c8 100644 --- a/src/Squidex/app/features/administration/services/users.service.ts +++ b/src/Squidex/app/features/administration/services/users.service.ts @@ -12,7 +12,6 @@ import { map } from 'rxjs/operators'; import { ApiUrlConfig, - Model, pretifyError, Resource, ResourceLinks, @@ -21,11 +20,11 @@ import { } from '@app/shared'; export class UsersDto extends ResultSet { - public _links: ResourceLinks; + public readonly _links: ResourceLinks = {}; } -export class UserDto extends Model { - public _links: ResourceLinks; +export class UserDto { + public readonly _links: ResourceLinks = {}; constructor( public readonly id: string, @@ -34,11 +33,6 @@ export class UserDto extends Model { public readonly permissions: string[] = [], public readonly isLocked?: boolean ) { - super(); - } - - public with(value: Partial): UserDto { - return this.clone(value); } } @@ -69,15 +63,7 @@ export class UsersService { return this.http.get<{ total: number, items: any[] } & Resource>(url).pipe( map(body => { - const users = body.items.map(item => - withLinks( - new UserDto( - item.id, - item.email, - item.displayName, - item.permissions, - item.isLocked), - item)); + const users = body.items.map(item => parseUser(item)); return withLinks(new UsersDto(body.total, users), body); }), @@ -89,14 +75,7 @@ export class UsersService { return this.http.get(url).pipe( map(body => { - const user = new UserDto( - body.id, - body.email, - body.displayName, - body.permissions, - body.isLocked); - - return user; + return parseUser(body); }), pretifyError('Failed to load user. Please reload.')); } @@ -106,36 +85,55 @@ export class UsersService { return this.http.post(url, dto).pipe( map(body => { - const user = new UserDto( - body.id, - dto.email, - dto.displayName, - dto.permissions, - false); - - return user; + return parseUser(body); }), pretifyError('Failed to create user. Please reload.')); } - public putUser(id: string, dto: UpdateUserDto): Observable { - const url = this.apiUrl.buildUrl(`api/user-management/${id}`); + public putUser(user: Resource, dto: UpdateUserDto): Observable { + const link = user._links['update']; + + const url = this.apiUrl.buildUrl(link.href); - return this.http.put(url, dto).pipe( + return this.http.request(link.method, url, { body: dto }).pipe( + map(body => { + return parseUser(body); + }), pretifyError('Failed to update user. Please reload.')); } - public lockUser(id: string): Observable { - const url = this.apiUrl.buildUrl(`api/user-management/${id}/lock`); + public lockUser(user: Resource): Observable { + const link = user._links['lock']; + + const url = this.apiUrl.buildUrl(link.href); - return this.http.put(url, {}).pipe( + return this.http.request(link.method, url).pipe( + map(body => { + return parseUser(body); + }), pretifyError('Failed to load users. Please retry.')); } - public unlockUser(id: string): Observable { - const url = this.apiUrl.buildUrl(`api/user-management/${id}/unlock`); + public unlockUser(user: Resource): Observable { + const link = user._links['unlock']; - return this.http.put(url, {}).pipe( + const url = this.apiUrl.buildUrl(link.href); + + return this.http.request(link.method, url).pipe( + map(body => { + return parseUser(body); + }), pretifyError('Failed to load users. Please retry.')); } +} + +function parseUser(response: any) { + return withLinks( + new UserDto( + response.id, + response.email, + response.displayName, + response.permissions, + response.isLocked), + response); } \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/users.state.spec.ts b/src/Squidex/app/features/administration/state/users.state.spec.ts index e735870a5..b9e4971db 100644 --- a/src/Squidex/app/features/administration/state/users.state.spec.ts +++ b/src/Squidex/app/features/administration/state/users.state.spec.ts @@ -8,7 +8,7 @@ import { of, throwError } from 'rxjs'; import { IMock, It, Mock, Times } from 'typemoq'; -import { AuthService, DialogService } from '@app/shared'; +import { DialogService } from '@app/shared'; import { UserDto, @@ -16,15 +16,16 @@ import { UsersService } from '@app/features/administration/internal'; + import { UsersState } from './users.state'; +import { createUser } from './../services/users.service.spec'; + describe('UsersState', () => { - const oldUsers = [ - new UserDto('id1', 'mail1@mail.de', 'name1', ['Permission1'], false), - new UserDto('id2', 'mail2@mail.de', 'name2', ['Permission2'], true) - ]; + const user1 = createUser(1); + const user2 = createUser(2); - const newUser = new UserDto('id3', 'mail3@mail.de', 'name3', ['Permission3'], false); + const newUser = createUser(3); let dialogs: IMock; let usersService: IMock; @@ -44,11 +45,11 @@ describe('UsersState', () => { describe('Loading', () => { it('should load users', () => { usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => of(new UsersDto(200, oldUsers))).verifiable(); + .returns(() => of(new UsersDto(200, [user1, user2]))).verifiable(); usersState.load().subscribe(); - expect(usersState.snapshot.users.values).toEqual(oldUsers); + expect(usersState.snapshot.users.values).toEqual([user1, user2]); expect(usersState.snapshot.usersPager.numberOfItems).toEqual(200); expect(usersState.snapshot.isLoaded).toBeTruthy(); @@ -57,7 +58,7 @@ describe('UsersState', () => { it('should show notification on load when reload is true', () => { usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => of(new UsersDto(200, oldUsers))).verifiable(); + .returns(() => of(new UsersDto(200, [user1, user2]))).verifiable(); usersState.load(true).subscribe(); @@ -68,18 +69,18 @@ describe('UsersState', () => { it('should replace selected user when reloading', () => { const newUsers = [ - new UserDto('id1', 'mail1@mail.de_new', 'name1_new', ['Permission1_New'], false), - new UserDto('id2', 'mail2@mail.de_new', 'name2_new', ['Permission2_New'], true) + createUser(1, '_new'), + createUser(2, '_new') ]; usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => of(new UsersDto(200, oldUsers))).verifiable(Times.exactly(2)); + .returns(() => of(new UsersDto(200, [user1, user2]))).verifiable(Times.exactly(2)); usersService.setup(x => x.getUsers(10, 0, undefined)) .returns(() => of(new UsersDto(200, newUsers))); usersState.load().subscribe(); - usersState.select('id1').subscribe(); + usersState.select(user1.id).subscribe(); usersState.load().subscribe(); expect(usersState.snapshot.selectedUser).toEqual(newUsers[0]); @@ -87,7 +88,7 @@ describe('UsersState', () => { it('should load next page and prev page when paging', () => { usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => of(new UsersDto(200, oldUsers))).verifiable(Times.exactly(2)); + .returns(() => of(new UsersDto(200, [user1, user2]))).verifiable(Times.exactly(2)); usersService.setup(x => x.getUsers(10, 10, undefined)) .returns(() => of(new UsersDto(200, []))).verifiable(); @@ -112,7 +113,7 @@ describe('UsersState', () => { describe('Updates', () => { beforeEach(() => { usersService.setup(x => x.getUsers(10, 0, undefined)) - .returns(() => of(new UsersDto(200, oldUsers))).verifiable(); + .returns(() => of(new UsersDto(200, [user1, user2]))).verifiable(); usersState.load().subscribe(); }); @@ -120,12 +121,12 @@ describe('UsersState', () => { it('should return user on select and not load when already loaded', () => { let selectedUser: UserDto; - usersState.select('id1').subscribe(x => { + usersState.select(user1.id).subscribe(x => { selectedUser = x!; }); - expect(selectedUser!).toEqual(oldUsers[0]); - expect(usersState.snapshot.selectedUser).toEqual(oldUsers[0]); + expect(selectedUser!).toEqual(user1); + expect(usersState.snapshot.selectedUser).toEqual(user1); }); it('should return user on select and load when not loaded', () => { @@ -168,46 +169,49 @@ describe('UsersState', () => { }); it('should mark as locked when locked', () => { - usersService.setup(x => x.lockUser('id1')) - .returns(() => of({})).verifiable(); + const updated = createUser(2, '_new'); + + usersService.setup(x => x.lockUser(user2)) + .returns(() => of(updated)).verifiable(); - usersState.select('id1').subscribe(); - usersState.lock(oldUsers[0]).subscribe(); + usersState.select(user2.id).subscribe(); + usersState.lock(user2).subscribe(); - const user_1 = usersState.snapshot.users.at(0); + const userUser2 = usersState.snapshot.users.at(1); - expect(user_1.isLocked).toBeTruthy(); - expect(user_1).toBe(usersState.snapshot.selectedUser!); + expect(userUser2).toBe(usersState.snapshot.selectedUser!); }); it('should unmark as locked when unlocked', () => { - usersService.setup(x => x.unlockUser('id2')) - .returns(() => of({})).verifiable(); + const updated = createUser(2, '_new'); - usersState.select('id2').subscribe(); - usersState.unlock(oldUsers[1]).subscribe(); + usersService.setup(x => x.unlockUser(user2)) + .returns(() => of(updated)).verifiable(); - const user_1 = usersState.snapshot.users.at(1); + usersState.select(user2.id).subscribe(); + usersState.unlock(user2).subscribe(); - expect(user_1.isLocked).toBeFalsy(); - expect(user_1).toBe(usersState.snapshot.selectedUser!); + const newUser2 = usersState.snapshot.users.at(1); + + expect(newUser2).toEqual(updated); + expect(newUser2).toBe(usersState.snapshot.selectedUser!); }); it('should update user properties when updated', () => { const request = { email: 'new@mail.com', displayName: 'New', permissions: ['Permission1'] }; - usersService.setup(x => x.putUser('id1', request)) - .returns(() => of({})).verifiable(); + const updated = createUser(2, '_new'); + + usersService.setup(x => x.putUser(user2, request)) + .returns(() => of(updated)).verifiable(); - usersState.select('id1').subscribe(); - usersState.update(oldUsers[0], request).subscribe(); + usersState.select(user2.id).subscribe(); + usersState.update(user2, request).subscribe(); - const user_1 = usersState.snapshot.users.at(0); + const newUser2 = usersState.snapshot.users.at(1); - expect(user_1.email).toEqual(request.email); - expect(user_1.displayName).toEqual(request.displayName); - expect(user_1.permissions).toEqual(request.permissions); - expect(user_1).toBe(usersState.snapshot.selectedUser!); + expect(newUser2).toEqual(updated); + expect(newUser2).toBe(usersState.snapshot.selectedUser!); }); it('should add user to snapshot when created', () => { @@ -218,7 +222,7 @@ describe('UsersState', () => { usersState.create(request).subscribe(); - expect(usersState.snapshot.users.values).toEqual([newUser, ...oldUsers]); + expect(usersState.snapshot.users.values).toEqual([newUser, user1, user2]); expect(usersState.snapshot.usersPager.numberOfItems).toBe(201); }); }); diff --git a/src/Squidex/app/features/administration/state/users.state.ts b/src/Squidex/app/features/administration/state/users.state.ts index 49c5163cc..04a4bc80a 100644 --- a/src/Squidex/app/features/administration/state/users.state.ts +++ b/src/Squidex/app/features/administration/state/users.state.ts @@ -7,7 +7,7 @@ import { Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; -import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'; +import { catchError, distinctUntilChanged, map, tap } from 'rxjs/operators'; import '@app/framework/utils/rxjs-extensions'; @@ -15,6 +15,7 @@ import { DialogService, ImmutableArray, Pager, + ResourceLinks, shareSubscribed, State } from '@app/shared'; @@ -36,6 +37,9 @@ interface Snapshot { // The query to filter users. usersQuery?: string; + // The resource links. + links: ResourceLinks; + // Indicates if the users are loaded. isLoaded?: boolean; @@ -56,6 +60,10 @@ export class UsersState extends State { this.changes.pipe(map(x => x.usersPager), distinctUntilChanged()); + public links = + this.changes.pipe(map(x => x.links), + distinctUntilChanged()); + public selectedUser = this.changes.pipe(map(x => x.selectedUser), distinctUntilChanged()); @@ -68,7 +76,7 @@ export class UsersState extends State { private readonly dialogs: DialogService, private readonly usersService: UsersService ) { - super({ users: ImmutableArray.empty(), usersPager: new Pager(0) }); + super({ users: ImmutableArray.empty(), usersPager: new Pager(0), links: {} }); } public select(id: string | null): Observable { @@ -108,7 +116,7 @@ export class UsersState extends State { this.snapshot.usersPager.pageSize, this.snapshot.usersPager.skip, this.snapshot.usersQuery).pipe( - tap(({ total, items }) => { + tap(({ total, items, _links: links }) => { if (isReload) { this.dialogs.notifyInfo('Users reloaded.'); } @@ -123,7 +131,7 @@ export class UsersState extends State { selectedUser = users.find(x => x.id === selectedUser!.id) || selectedUser; } - return { ...s, users, usersPager, selectedUser, isLoaded: true }; + return { ...s, users, usersPager, links, selectedUser, isLoaded: true }; }); }), shareSubscribed(this.dialogs)); @@ -143,8 +151,7 @@ export class UsersState extends State { } public update(user: UserDto, request: UpdateUserDto): Observable { - return this.usersService.putUser(user.id, request).pipe( - switchMap(() => this.usersService.getUser(user.id)), + return this.usersService.putUser(user, request).pipe( tap(updated => { this.replaceUser(updated); }), @@ -152,8 +159,7 @@ export class UsersState extends State { } public lock(user: UserDto): Observable { - return this.usersService.lockUser(user.id).pipe( - switchMap(() => this.usersService.getUser(user.id)), + return this.usersService.lockUser(user).pipe( tap(updated => { this.replaceUser(updated); }), @@ -161,8 +167,7 @@ export class UsersState extends State { } public unlock(user: UserDto): Observable { - return this.usersService.unlockUser(user.id).pipe( - switchMap(() => this.usersService.getUser(user.id)), + return this.usersService.unlockUser(user).pipe( tap(updated => { this.replaceUser(updated); }), diff --git a/src/Squidex/app/framework/angular/http/hateos.pipes.ts b/src/Squidex/app/framework/angular/http/hateos.pipes.ts index 979c95205..f580b5bb0 100644 --- a/src/Squidex/app/framework/angular/http/hateos.pipes.ts +++ b/src/Squidex/app/framework/angular/http/hateos.pipes.ts @@ -1,14 +1,25 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + import { Pipe, PipeTransform } from '@angular/core'; -import { Resource } from '@app/framework/internal'; +import { + hasLink, + Resource, + ResourceLinks +} from '@app/framework/internal'; @Pipe({ name: 'sqxHasLink', pure: true }) export class HasLinkPipe implements PipeTransform { - public transform(value: Resource, rel: string) { - return value._links && !!value._links[rel]; + public transform(value: Resource | ResourceLinks, rel: string) { + return hasLink(value, rel); } } @@ -17,7 +28,7 @@ export class HasLinkPipe implements PipeTransform { pure: true }) export class HasNoLinkPipe implements PipeTransform { - public transform(value: Resource, rel: string) { - return !value._links || !value._links[rel]; + public transform(value: Resource | ResourceLinks, rel: string) { + return !hasLink(value, rel); } -} \ No newline at end of file +} diff --git a/src/Squidex/app/framework/utils/hateos.ts b/src/Squidex/app/framework/utils/hateos.ts index 14e5357d8..c1c2099c7 100644 --- a/src/Squidex/app/framework/utils/hateos.ts +++ b/src/Squidex/app/framework/utils/hateos.ts @@ -5,21 +5,40 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ - export interface Resource { - _links?: { [rel: string]: ResourceLink }; - } +export interface Resource { + readonly _links: { [rel: string]: ResourceLink }; +} - export type ResourceLinks = { [rel: string]: ResourceLink }; - export type ResourceLink = { href: string; method: ResourceMethod; }; +export type ResourceLinks = { [rel: string]: ResourceLink }; +export type ResourceLink = { href: string; method: ResourceMethod; }; - export function withLinks(value: T, source: Resource) { - value._links = source._links; +export function withLinks(value: T, source: Resource) { + if (value._links && source._links) { + for (let key in source._links) { + if (source._links.hasOwnProperty(key)) { + value._links[key] = source._links[key]; + } + } - return value; - } + Object.freeze(value._links); + } - export type ResourceMethod = - 'get' | - 'post' | - 'put' | - 'delete'; \ No newline at end of file + return value; +} + +export function hasLink(value: Resource | ResourceLinks, rel: string): boolean { + const link = getLink(value, rel); + + return !!(link && link.method && link.href); +} + +export function getLink(value: Resource | ResourceLinks, rel: string): ResourceLink { + return value ? (value._links ? value._links[rel] : value[rel]) : undefined; +} + +export type ResourceMethod = + 'GET' | + 'DELETE' | + 'PATCH' | + 'POST' | + 'PUT'; \ No newline at end of file From 368cfc7b828fe4464c3992abb76b7241fc70b1b7 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 8 Jun 2019 14:20:34 +0200 Subject: [PATCH 011/107] HATEOS for event consumers. --- .../Grains/EventConsumerGrain.cs | 29 ++-- .../Grains/EventConsumerManagerGrain.cs | 12 +- .../Grains/IEventConsumerGrain.cs | 6 +- .../Grains/IEventConsumerManagerGrain.cs | 10 +- src/Squidex.Web/Resource.cs | 1 - src/Squidex.Web/UrlHelperExtensions.cs | 2 - .../Contents/ContentsController.cs | 1 - .../EventConsumersController.cs | 31 +++-- .../EventConsumers/Models/EventConsumerDto.cs | 37 +++++- .../Models/EventConsumersDto.cs | 39 ++++++ .../event-consumers-page.component.html | 6 +- .../services/event-consumers.service.spec.ts | 124 +++++++++++++----- .../services/event-consumers.service.ts | 82 ++++++++---- .../services/users.service.spec.ts | 3 +- .../state/event-consumers.state.spec.ts | 60 +++++---- .../state/event-consumers.state.ts | 21 +-- .../administration/state/users.state.spec.ts | 10 +- 17 files changed, 323 insertions(+), 151 deletions(-) create mode 100644 src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumersDto.cs diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs index f37a9200f..bc5ada68d 100644 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs @@ -58,7 +58,12 @@ namespace Squidex.Infrastructure.EventSourcing.Grains public Task> GetStateAsync() { - return Task.FromResult(State.ToInfo(eventConsumer.Name).AsImmutable()); + return Task.FromResult(CreateInfo()); + } + + private Immutable CreateInfo() + { + return State.ToInfo(eventConsumer.Name).AsImmutable(); } public Task OnEventAsync(Immutable subscription, Immutable storedEvent) @@ -109,39 +114,43 @@ namespace Squidex.Infrastructure.EventSourcing.Grains return TaskHelper.Done; } - public Task StartAsync() + public async Task> StartAsync() { if (!State.IsStopped) { - return TaskHelper.Done; + return CreateInfo(); } - return DoAndUpdateStateAsync(() => + await DoAndUpdateStateAsync(() => { Subscribe(State.Position); State = State.Started(); }); + + return CreateInfo(); } - public Task StopAsync() + public async Task> StopAsync() { if (State.IsStopped) { - return TaskHelper.Done; + return CreateInfo(); } - return DoAndUpdateStateAsync(() => + await DoAndUpdateStateAsync(() => { Unsubscribe(); State = State.Stopped(); }); + + return CreateInfo(); } - public Task ResetAsync() + public async Task> ResetAsync() { - return DoAndUpdateStateAsync(async () => + await DoAndUpdateStateAsync(async () => { Unsubscribe(); @@ -151,6 +160,8 @@ namespace Squidex.Infrastructure.EventSourcing.Grains State = State.Reset(); }); + + return CreateInfo(); } private Task DoAndUpdateStateAsync(Action action, [CallerMemberName] string caller = null) diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs index ca9097142..4952088c0 100644 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs @@ -74,33 +74,31 @@ namespace Squidex.Infrastructure.EventSourcing.Grains { return Task.WhenAll( eventConsumers - .Select(c => GrainFactory.GetGrain(c.Name)) - .Select(c => c.StartAsync())); + .Select(c => StartAsync(c.Name))); } public Task StopAllAsync() { return Task.WhenAll( eventConsumers - .Select(c => GrainFactory.GetGrain(c.Name)) - .Select(c => c.StopAsync())); + .Select(c => StopAsync(c.Name))); } - public Task ResetAsync(string consumerName) + public Task> ResetAsync(string consumerName) { var eventConsumer = GrainFactory.GetGrain(consumerName); return eventConsumer.ResetAsync(); } - public Task StartAsync(string consumerName) + public Task> StartAsync(string consumerName) { var eventConsumer = GrainFactory.GetGrain(consumerName); return eventConsumer.StartAsync(); } - public Task StopAsync(string consumerName) + public Task> StopAsync(string consumerName) { var eventConsumer = GrainFactory.GetGrain(consumerName); diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs index 58b7bf2fb..fb7d82811 100644 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs @@ -16,11 +16,11 @@ namespace Squidex.Infrastructure.EventSourcing.Grains { Task> GetStateAsync(); - Task StopAsync(); + Task> StopAsync(); - Task StartAsync(); + Task> StartAsync(); - Task ResetAsync(); + Task> ResetAsync(); Task OnEventAsync(Immutable subscription, Immutable storedEvent); diff --git a/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs b/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs index c0b53d403..397db21f4 100644 --- a/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs +++ b/src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs @@ -16,15 +16,15 @@ namespace Squidex.Infrastructure.EventSourcing.Grains { Task ActivateAsync(string streamName); - Task StopAllAsync(); + Task StartAllAsync(); - Task StopAsync(string consumerName); + Task StopAllAsync(); - Task StartAllAsync(); + Task> StopAsync(string consumerName); - Task StartAsync(string consumerName); + Task> StartAsync(string consumerName); - Task ResetAsync(string consumerName); + Task> ResetAsync(string consumerName); Task>> GetConsumersAsync(); } diff --git a/src/Squidex.Web/Resource.cs b/src/Squidex.Web/Resource.cs index a0e68197c..59c4d10f9 100644 --- a/src/Squidex.Web/Resource.cs +++ b/src/Squidex.Web/Resource.cs @@ -8,7 +8,6 @@ using Newtonsoft.Json; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Net.Http; namespace Squidex.Web { diff --git a/src/Squidex.Web/UrlHelperExtensions.cs b/src/Squidex.Web/UrlHelperExtensions.cs index 486d48a76..27f00a1d9 100644 --- a/src/Squidex.Web/UrlHelperExtensions.cs +++ b/src/Squidex.Web/UrlHelperExtensions.cs @@ -7,8 +7,6 @@ using Microsoft.AspNetCore.Mvc; using System; -using System.Linq.Expressions; -using System.Reflection; namespace Squidex.Web { diff --git a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index bd374a986..61c74d203 100644 --- a/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -21,7 +21,6 @@ using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.GraphQL; using Squidex.Infrastructure.Commands; using Squidex.Shared; -using Squidex.Shared.Identity; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Contents diff --git a/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs b/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs index 1369eb9c7..9e17d5f09 100644 --- a/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs +++ b/src/Squidex/Areas/Api/Controllers/EventConsumers/EventConsumersController.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Orleans; @@ -30,44 +29,54 @@ namespace Squidex.Areas.Api.Controllers.EventConsumers [HttpGet] [Route("event-consumers/")] + [ProducesResponseType(typeof(EventConsumersDto), 200)] [ApiPermission(Permissions.AdminEventsRead)] public async Task GetEventConsumers() { var entities = await GetGrain().GetConsumersAsync(); - var response = entities.Value.OrderBy(x => x.Name).Select(EventConsumerDto.FromEventConsumerInfo).ToArray(); + var response = EventConsumersDto.FromResults(entities.Value, this); return Ok(response); } [HttpPut] [Route("event-consumers/{name}/start/")] + [ProducesResponseType(typeof(EventConsumerDto), 200)] [ApiPermission(Permissions.AdminEventsManage)] - public async Task Start(string name) + public async Task StartEventConsumer(string name) { - await GetGrain().StartAsync(name); + var entity = await GetGrain().StartAsync(name); - return NoContent(); + var response = EventConsumerDto.FromEventConsumerInfo(entity.Value, this); + + return Ok(response); } [HttpPut] [Route("event-consumers/{name}/stop/")] + [ProducesResponseType(typeof(EventConsumerDto), 200)] [ApiPermission(Permissions.AdminEventsManage)] - public async Task Stop(string name) + public async Task StopEventConsumer(string name) { - await GetGrain().StopAsync(name); + var entity = await GetGrain().StopAsync(name); + + var response = EventConsumerDto.FromEventConsumerInfo(entity.Value, this); - return NoContent(); + return Ok(response); } [HttpPut] [Route("event-consumers/{name}/reset/")] + [ProducesResponseType(typeof(EventConsumerDto), 200)] [ApiPermission(Permissions.AdminEventsManage)] - public async Task Reset(string name) + public async Task ResetEventConsumer(string name) { - await GetGrain().ResetAsync(name); + var entity = await GetGrain().ResetAsync(name); + + var response = EventConsumerDto.FromEventConsumerInfo(entity.Value, this); - return NoContent(); + return Ok(response); } private IEventConsumerManagerGrain GetGrain() diff --git a/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumerDto.cs b/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumerDto.cs index 3ef6535c3..df0846cbf 100644 --- a/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumerDto.cs +++ b/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumerDto.cs @@ -7,11 +7,16 @@ using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.Security; +using Squidex.Shared; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.EventConsumers.Models { - public sealed class EventConsumerDto + public sealed class EventConsumerDto : Resource { + private static readonly Permission EventsManagePermission = new Permission(Permissions.AdminEventsManage); + public bool IsStopped { get; set; } public bool IsResetting { get; set; } @@ -22,9 +27,35 @@ namespace Squidex.Areas.Api.Controllers.EventConsumers.Models public string Position { get; set; } - public static EventConsumerDto FromEventConsumerInfo(EventConsumerInfo eventConsumerInfo) + public static EventConsumerDto FromEventConsumerInfo(EventConsumerInfo eventConsumerInfo, ApiController controller) + { + var result = SimpleMapper.Map(eventConsumerInfo, new EventConsumerDto()); + + return CreateLinks(result, controller); + } + + private static EventConsumerDto CreateLinks(EventConsumerDto result, ApiController controller) { - return SimpleMapper.Map(eventConsumerInfo, new EventConsumerDto()); + if (controller.HasPermission(EventsManagePermission)) + { + var values = new { name = result.Name }; + + if (!result.IsResetting) + { + result.AddPutLink("reset", controller.Url(x => nameof(x.ResetEventConsumer), values)); + } + + if (result.IsStopped) + { + result.AddPutLink("start", controller.Url(x => nameof(x.StartEventConsumer), values)); + } + else + { + result.AddPutLink("stop", controller.Url(x => nameof(x.StopEventConsumer), values)); + } + } + + return result; } } } diff --git a/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumersDto.cs b/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumersDto.cs new file mode 100644 index 000000000..8f9a20766 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/EventConsumers/Models/EventConsumersDto.cs @@ -0,0 +1,39 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.Linq; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.EventConsumers.Models +{ + public sealed class EventConsumersDto : Resource + { + /// + /// The event consumers. + /// + public EventConsumerDto[] Items { get; set; } + + public static EventConsumersDto FromResults(IEnumerable items, ApiController controller) + { + var result = new EventConsumersDto + { + Items = items.Select(x => EventConsumerDto.FromEventConsumerInfo(x, controller)).ToArray() + }; + + return CreateLinks(result, controller); + } + + private static EventConsumersDto CreateLinks(EventConsumersDto result, ApiController controller) + { + result.AddSelfLink(controller.Url(c => nameof(c.GetEventConsumers))); + + return result; + } + } +} diff --git a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html index 6664745ac..8c31c3da2 100644 --- a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html +++ b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.html @@ -42,13 +42,13 @@ {{eventConsumer.position}}
diff --git a/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts b/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts index 5eacb6d62..33bb41306 100644 --- a/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts +++ b/src/Squidex/app/features/administration/services/event-consumers.service.spec.ts @@ -8,9 +8,13 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { inject, TestBed } from '@angular/core/testing'; -import { ApiUrlConfig } from '@app/framework'; +import { ApiUrlConfig, Resource } from '@app/framework'; -import { EventConsumerDto, EventConsumersService } from './event-consumers.service'; +import { + EventConsumerDto, + EventConsumersDto, + EventConsumersService +} from './event-consumers.service'; describe('EventConsumersService', () => { beforeEach(() => { @@ -32,7 +36,7 @@ describe('EventConsumersService', () => { it('should make get request to get event consumers', inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { - let eventConsumers: EventConsumerDto[]; + let eventConsumers: EventConsumersDto; eventConsumersService.getEventConsumers().subscribe(result => { eventConsumers = result; @@ -43,66 +47,120 @@ describe('EventConsumersService', () => { expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush([ - { - name: 'event-consumer1', - position: '13', - isStopped: true, - isResetting: true, - error: 'an error 1' - }, - { - name: 'event-consumer2', - position: '29', - isStopped: true, - isResetting: true, - error: 'an error 2' - } - ]); + req.flush({ + items: [ + eventConsumerResponse(12), + eventConsumerResponse(13) + ] + }); expect(eventConsumers!).toEqual( - [ - new EventConsumerDto('event-consumer1', true, true, 'an error 1', '13'), - new EventConsumerDto('event-consumer2', true, true, 'an error 2', '29') - ]); + new EventConsumersDto([ + createEventConsumer(12), + createEventConsumer(13) + ])); })); it('should make put request to start event consumer', inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { - eventConsumersService.putStart('event-consumer1').subscribe(); + const resource: Resource = { + _links: { + start: { method: 'PUT', href: 'api/event-consumers/event-consumer123/start' } + } + }; + + let eventConsumer: EventConsumerDto; - const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer1/start'); + eventConsumersService.putStart(resource).subscribe(response => { + eventConsumer = response; + }); + + const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer123/start'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(eventConsumerResponse(123)); + + expect(eventConsumer!).toEqual(createEventConsumer(123)); })); it('should make put request to stop event consumer', inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { - eventConsumersService.putStop('event-consumer1').subscribe(); + const resource: Resource = { + _links: { + stop: { method: 'PUT', href: 'api/event-consumers/event-consumer123/stop' } + } + }; + + let eventConsumer: EventConsumerDto; - const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer1/stop'); + eventConsumersService.putStop(resource).subscribe(response => { + eventConsumer = response; + }); + + const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer123/stop'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(eventConsumerResponse(12)); + + expect(eventConsumer!).toEqual(createEventConsumer(12)); })); it('should make put request to reset event consumer', inject([EventConsumersService, HttpTestingController], (eventConsumersService: EventConsumersService, httpMock: HttpTestingController) => { - eventConsumersService.putReset('event-consumer1').subscribe(); + const resource: Resource = { + _links: { + reset: { method: 'PUT', href: 'api/event-consumers/event-consumer123/reset' } + } + }; + + let eventConsumer: EventConsumerDto; - const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer1/reset'); + eventConsumersService.putReset(resource).subscribe(response => { + eventConsumer = response; + }); + + const req = httpMock.expectOne('http://service/p/api/event-consumers/event-consumer123/reset'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({}); + req.flush(eventConsumerResponse(12)); + + expect(eventConsumer!).toEqual(createEventConsumer(12)); })); -}); \ No newline at end of file + + function eventConsumerResponse(id: number) { + return { + name: `event-consumer${id}`, + position: `position-${id}`, + isStopped: true, + isResetting: true, + error: `failure-${id}`, + _links: { + reset: { method: 'PUT', href: `/event-consumers/${id}/reset` } + } + }; + } +}); + +export function createEventConsumer(id: number, suffix = '') { + const result = new EventConsumerDto( + `event-consumer${id}`, + true, + true, + `failure-${id}${suffix}`, + `position-${id}${suffix}`); + + result._links['reset'] = { + method: 'PUT', href: `/event-consumers/${id}/reset` + }; + + return result; +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/services/event-consumers.service.ts b/src/Squidex/app/features/administration/services/event-consumers.service.ts index 05b2cd0fd..5c49b1980 100644 --- a/src/Squidex/app/features/administration/services/event-consumers.service.ts +++ b/src/Squidex/app/features/administration/services/event-consumers.service.ts @@ -12,11 +12,24 @@ import { map } from 'rxjs/operators'; import { ApiUrlConfig, - Model, - pretifyError + pretifyError, + Resource, + ResourceLinks, + withLinks } from '@app/shared'; -export class EventConsumerDto extends Model { +export class EventConsumersDto { + public readonly _links: ResourceLinks = {}; + + constructor( + public readonly items: EventConsumerDto[] + ) { + } +} + +export class EventConsumerDto { + public readonly _links: ResourceLinks = {}; + constructor( public readonly name: string, public readonly isStopped?: boolean, @@ -24,7 +37,6 @@ export class EventConsumerDto extends Model { public readonly error?: string, public readonly position?: string ) { - super(); } } @@ -36,42 +48,62 @@ export class EventConsumersService { ) { } - public getEventConsumers(): Observable { + public getEventConsumers(): Observable { const url = this.apiUrl.buildUrl('/api/event-consumers'); - return this.http.get(url).pipe( + return this.http.get<{ items: any[] } & Resource>(url).pipe( map(body => { - const eventConsumers = body.map(item => - new EventConsumerDto( - item.name, - item.isStopped, - item.isResetting, - item.error, - item.position)); - - return eventConsumers; + const eventConsumers = body.items.map(item => parseEventConsumer(item)); + + return withLinks(new EventConsumersDto(eventConsumers), body); }), pretifyError('Failed to load event consumers. Please reload.')); } - public putStart(name: string): Observable { - const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/start`); + public putStart(eventConsumer: Resource): Observable { + const link = eventConsumer._links['start']; - return this.http.put(url, {}).pipe( + const url = this.apiUrl.buildUrl(link.href); + + return this.http.request(link.method, url).pipe( + map(body => { + return parseEventConsumer(body); + }), pretifyError('Failed to start event consumer. Please reload.')); } - public putStop(name: string): Observable { - const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/stop`); + public putStop(eventConsumer: Resource): Observable { + const link = eventConsumer._links['stop']; + + const url = this.apiUrl.buildUrl(link.href); - return this.http.put(url, {}).pipe( + return this.http.request(link.method, url).pipe( + map(body => { + return parseEventConsumer(body); + }), pretifyError('Failed to stop event consumer. Please reload.')); } - public putReset(name: string): Observable { - const url = this.apiUrl.buildUrl(`api/event-consumers/${name}/reset`); + public putReset(eventConsumer: Resource): Observable { + const link = eventConsumer._links['reset']; + + const url = this.apiUrl.buildUrl(link.href); - return this.http.put(url, {}).pipe( + return this.http.request(link.method, url).pipe( + map(body => { + return parseEventConsumer(body); + }), pretifyError('Failed to reset event consumer. Please reload.')); } -} \ No newline at end of file +} + +function parseEventConsumer(response: any): EventConsumerDto { + return withLinks( + new EventConsumerDto( + response.name, + response.isStopped, + response.isResetting, + response.error, + response.position), + response); +} diff --git a/src/Squidex/app/features/administration/services/users.service.spec.ts b/src/Squidex/app/features/administration/services/users.service.spec.ts index 03b9b68fb..825f962f6 100644 --- a/src/Squidex/app/features/administration/services/users.service.spec.ts +++ b/src/Squidex/app/features/administration/services/users.service.spec.ts @@ -227,7 +227,8 @@ describe('UsersService', () => { }); export function createUser(id: number, suffix = '') { - const result = new UserDto(`${id}`, + const result = new UserDto( + `${id}`, `user${id}${suffix}@domain.com`, `user${id}${suffix}`, [ diff --git a/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts b/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts index 4a69ffbeb..ba384f5d1 100644 --- a/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts +++ b/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts @@ -11,14 +11,14 @@ import { IMock, It, Mock, Times } from 'typemoq'; import { DialogService } from '@app/framework'; -import { EventConsumerDto, EventConsumersService } from '@app/features/administration/internal'; +import { EventConsumersDto, EventConsumersService } from '@app/features/administration/internal'; import { EventConsumersState } from './event-consumers.state'; +import { createEventConsumer } from './../services/event-consumers.service.spec'; + describe('EventConsumersState', () => { - const oldConsumers = [ - new EventConsumerDto('name1', false, false, 'error', '1'), - new EventConsumerDto('name2', true, true, 'error', '2') - ]; + const eventConsumer1 = createEventConsumer(1); + const eventConsumer2 = createEventConsumer(2); let dialogs: IMock; let eventConsumersService: IMock; @@ -38,11 +38,11 @@ describe('EventConsumersState', () => { describe('Loading', () => { it('should load event consumers', () => { eventConsumersService.setup(x => x.getEventConsumers()) - .returns(() => of(oldConsumers)).verifiable(); + .returns(() => of(new EventConsumersDto([eventConsumer1, eventConsumer2]))).verifiable(); eventConsumersState.load().subscribe(); - expect(eventConsumersState.snapshot.eventConsumers.values).toEqual(oldConsumers); + expect(eventConsumersState.snapshot.eventConsumers.values).toEqual([eventConsumer1, eventConsumer2]); expect(eventConsumersState.snapshot.isLoaded).toBeTruthy(); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); @@ -50,7 +50,7 @@ describe('EventConsumersState', () => { it('should show notification on load when reload is true', () => { eventConsumersService.setup(x => x.getEventConsumers()) - .returns(() => of(oldConsumers)).verifiable(); + .returns(() => of(new EventConsumersDto([eventConsumer1, eventConsumer2]))).verifiable(); eventConsumersState.load(true).subscribe(); @@ -74,42 +74,48 @@ describe('EventConsumersState', () => { describe('Updates', () => { beforeEach(() => { eventConsumersService.setup(x => x.getEventConsumers()) - .returns(() => of(oldConsumers)).verifiable(); + .returns(() => of(new EventConsumersDto([eventConsumer1, eventConsumer2]))).verifiable(); eventConsumersState.load().subscribe(); }); - it('should unmark as stopped when started', () => { - eventConsumersService.setup(x => x.putStart(oldConsumers[1].name)) - .returns(() => of({})).verifiable(); + it('should update evnet consumer when started', () => { + const updated = createEventConsumer(2, '_new'); + + eventConsumersService.setup(x => x.putStart(eventConsumer2)) + .returns(() => of(updated)).verifiable(); - eventConsumersState.start(oldConsumers[1]).subscribe(); + eventConsumersState.start(eventConsumer2).subscribe(); - const es_1 = eventConsumersState.snapshot.eventConsumers.at(1); + const newConsumer2 = eventConsumersState.snapshot.eventConsumers.at(1); - expect(es_1.isStopped).toBeFalsy(); + expect(newConsumer2).toEqual(updated); }); - it('should mark as stopped when stopped', () => { - eventConsumersService.setup(x => x.putStop(oldConsumers[0].name)) - .returns(() => of({})).verifiable(); + it('should update event consumer when stopped', () => { + const updated = createEventConsumer(2, '_new'); - eventConsumersState.stop(oldConsumers[0]).subscribe(); + eventConsumersService.setup(x => x.putStop(eventConsumer2)) + .returns(() => of(updated)).verifiable(); - const es_1 = eventConsumersState.snapshot.eventConsumers.at(0); + eventConsumersState.stop(eventConsumer2).subscribe(); - expect(es_1.isStopped).toBeTruthy(); + const newConsumer2 = eventConsumersState.snapshot.eventConsumers.at(1); + + expect(newConsumer2).toEqual(updated); }); - it('should mark as resetting when reset', () => { - eventConsumersService.setup(x => x.putReset(oldConsumers[0].name)) - .returns(() => of({})).verifiable(); + it('should update event consumer when reset', () => { + const updated = createEventConsumer(2, '_new'); + + eventConsumersService.setup(x => x.putReset(eventConsumer2)) + .returns(() => of(updated)).verifiable(); - eventConsumersState.reset(oldConsumers[0]).subscribe(); + eventConsumersState.reset(eventConsumer2).subscribe(); - const es_1 = eventConsumersState.snapshot.eventConsumers.at(0); + const newConsumer2 = eventConsumersState.snapshot.eventConsumers.at(1); - expect(es_1.isResetting).toBeTruthy(); + expect(newConsumer2).toEqual(updated); }); }); }); \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/event-consumers.state.ts b/src/Squidex/app/features/administration/state/event-consumers.state.ts index f5c5a0d1d..278578beb 100644 --- a/src/Squidex/app/features/administration/state/event-consumers.state.ts +++ b/src/Squidex/app/features/administration/state/event-consumers.state.ts @@ -51,12 +51,12 @@ export class EventConsumersState extends State { } return this.eventConsumersService.getEventConsumers().pipe( - tap(payload => { + tap(({ items }) => { if (isReload && !silent) { this.dialogs.notifyInfo('Event Consumers reloaded.'); } - const eventConsumers = ImmutableArray.of(payload); + const eventConsumers = ImmutableArray.of(items); this.next(s => { return { ...s, eventConsumers, isLoaded: true }; @@ -66,8 +66,7 @@ export class EventConsumersState extends State { } public start(eventConsumer: EventConsumerDto): Observable { - return this.eventConsumersService.putStart(eventConsumer.name).pipe( - map(() => setStopped(eventConsumer, false)), + return this.eventConsumersService.putStart(eventConsumer).pipe( tap(updated => { this.replaceEventConsumer(updated); }), @@ -75,8 +74,7 @@ export class EventConsumersState extends State { } public stop(eventConsumer: EventConsumerDto): Observable { - return this.eventConsumersService.putStop(eventConsumer.name).pipe( - map(() => setStopped(eventConsumer, true)), + return this.eventConsumersService.putStop(eventConsumer).pipe( tap(updated => { this.replaceEventConsumer(updated); }), @@ -84,8 +82,7 @@ export class EventConsumersState extends State { } public reset(eventConsumer: EventConsumerDto): Observable { - return this.eventConsumersService.putReset(eventConsumer.name).pipe( - map(() => reset(eventConsumer)), + return this.eventConsumersService.putReset(eventConsumer).pipe( tap(updated => { this.replaceEventConsumer(updated); }), @@ -99,10 +96,4 @@ export class EventConsumersState extends State { return { ...s, eventConsumers }; }); } -} - -const setStopped = (eventConsumer: EventConsumerDto, isStopped: boolean) => - eventConsumer.with({ isStopped }); - -const reset = (eventConsumer: EventConsumerDto) => - eventConsumer.with({ isResetting: true }); \ No newline at end of file +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/users.state.spec.ts b/src/Squidex/app/features/administration/state/users.state.spec.ts index b9e4971db..3000bf6f6 100644 --- a/src/Squidex/app/features/administration/state/users.state.spec.ts +++ b/src/Squidex/app/features/administration/state/users.state.spec.ts @@ -168,7 +168,7 @@ describe('UsersState', () => { expect(usersState.snapshot.selectedUser).toBeNull(); }); - it('should mark as locked when locked', () => { + it('should update user selected user when locked', () => { const updated = createUser(2, '_new'); usersService.setup(x => x.lockUser(user2)) @@ -177,12 +177,12 @@ describe('UsersState', () => { usersState.select(user2.id).subscribe(); usersState.lock(user2).subscribe(); - const userUser2 = usersState.snapshot.users.at(1); + const newUser2 = usersState.snapshot.users.at(1); - expect(userUser2).toBe(usersState.snapshot.selectedUser!); + expect(newUser2).toBe(usersState.snapshot.selectedUser!); }); - it('should unmark as locked when unlocked', () => { + it('should update user and selected user when unlocked', () => { const updated = createUser(2, '_new'); usersService.setup(x => x.unlockUser(user2)) @@ -197,7 +197,7 @@ describe('UsersState', () => { expect(newUser2).toBe(usersState.snapshot.selectedUser!); }); - it('should update user properties when updated', () => { + it('should update user and selected user when updated', () => { const request = { email: 'new@mail.com', displayName: 'New', permissions: ['Permission1'] }; const updated = createUser(2, '_new'); From 242df48c18e66466980b5a0ba6ba0de03ccb9e37 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 8 Jun 2019 21:10:30 +0200 Subject: [PATCH 012/107] Some progress. --- src/Squidex.Shared/Permissions.cs | 2 - src/Squidex.Web/PermissionExtensions.cs | 18 +-- .../Controllers/Apps/AppPatternsController.cs | 2 +- .../Api/Controllers/Apps/AppsController.cs | 4 +- .../Api/Controllers/Apps/Models/AppDto.cs | 91 ++++++++++- .../Controllers/Plans/AppPlansController.cs | 2 +- .../Schemas/Models/SchemaDetailsDto.cs | 70 +-------- .../Controllers/Schemas/Models/SchemaDto.cs | 20 ++- .../Controllers/Schemas/SchemasController.cs | 4 +- .../Controllers/Users/Models/ResourcesDto.cs | 39 +++++ .../Api/Controllers/Users/UsersController.cs | 17 ++ .../administration-area.component.html | 8 +- .../administration-area.component.ts | 6 + .../pages/dashboard-page.component.html | 2 +- .../pages/dashboard-page.component.ts | 5 +- .../settings/settings-area.component.html | 18 +-- .../framework/angular/http/hateos.pipes.ts | 20 +-- src/Squidex/app/framework/internal.ts | 1 - src/Squidex/app/framework/module.ts | 3 - .../app/framework/utils/permission.spec.ts | 145 ------------------ src/Squidex/app/framework/utils/permission.ts | 116 -------------- .../components/asset-uploader.component.html | 2 +- .../shared/components/permission.directive.ts | 133 ---------------- .../components/schema-category.component.html | 2 +- src/Squidex/app/shared/declarations.ts | 1 - src/Squidex/app/shared/module.ts | 3 - .../app/shared/services/apps.service.spec.ts | 7 +- .../app/shared/services/apps.service.ts | 37 +++-- .../app/shared/services/auth.service.ts | 21 +-- .../app/shared/services/users.service.spec.ts | 28 ++++ .../app/shared/services/users.service.ts | 21 ++- .../app/shared/state/apps.state.spec.ts | 9 +- src/Squidex/app/shared/state/apps.state.ts | 9 +- src/Squidex/app/shared/state/ui.state.spec.ts | 23 ++- src/Squidex/app/shared/state/ui.state.ts | 37 +++-- .../shell/pages/app/left-menu.component.html | 14 +- .../shell/pages/app/left-menu.component.ts | 3 +- .../pages/internal/apps-menu.component.html | 24 ++- .../internal/profile-menu.component.html | 8 +- .../pages/internal/profile-menu.component.ts | 4 +- 40 files changed, 369 insertions(+), 610 deletions(-) create mode 100644 src/Squidex/Areas/Api/Controllers/Users/Models/ResourcesDto.cs delete mode 100644 src/Squidex/app/framework/utils/permission.spec.ts delete mode 100644 src/Squidex/app/framework/utils/permission.ts delete mode 100644 src/Squidex/app/shared/components/permission.directive.ts diff --git a/src/Squidex.Shared/Permissions.cs b/src/Squidex.Shared/Permissions.cs index 964925efb..ee1e20cf4 100644 --- a/src/Squidex.Shared/Permissions.cs +++ b/src/Squidex.Shared/Permissions.cs @@ -79,7 +79,6 @@ namespace Squidex.Shared public const string AppRolesDelete = "squidex.apps.{app}.roles.delete"; public const string AppPatterns = "squidex.apps.{app}.patterns"; - public const string AppPatternsRead = "squidex.apps.{app}.patterns.read"; public const string AppPatternsCreate = "squidex.apps.{app}.patterns.create"; public const string AppPatternsUpdate = "squidex.apps.{app}.patterns.update"; public const string AppPatternsDelete = "squidex.apps.{app}.patterns.delete"; @@ -117,7 +116,6 @@ namespace Squidex.Shared public const string AppContents = "squidex.apps.{app}.contents.{name}"; public const string AppContentsRead = "squidex.apps.{app}.contents.{name}.read"; - public const string AppContentsGraphQL = "squidex.apps.{app}.contents.{name}.graphql"; public const string AppContentsCreate = "squidex.apps.{app}.contents.{name}.create"; public const string AppContentsUpdate = "squidex.apps.{app}.contents.{name}.update"; public const string AppContentsDiscard = "squidex.apps.{app}.contents.{name}.discard"; diff --git a/src/Squidex.Web/PermissionExtensions.cs b/src/Squidex.Web/PermissionExtensions.cs index 6dc7d0610..a6751e9e9 100644 --- a/src/Squidex.Web/PermissionExtensions.cs +++ b/src/Squidex.Web/PermissionExtensions.cs @@ -24,7 +24,7 @@ namespace Squidex.Web } } - public static PermissionSet GetPermissions(this HttpContext httpContext) + public static PermissionSet Permissions(this HttpContext httpContext) { var feature = httpContext.Features.Get(); @@ -38,22 +38,22 @@ namespace Squidex.Web return feature.Permissions; } - public static bool HasPermission(this HttpContext httpContext, Permission permission) + public static bool HasPermission(this HttpContext httpContext, Permission permission, PermissionSet permissions = null) { - return httpContext.GetPermissions().Includes(permission); + return httpContext.Permissions().Includes(permission) || permission?.Includes(permission) == true; } - public static bool HasPermission(this HttpContext httpContext, string id, string app = "*", string schema = "*") + public static bool HasPermission(this HttpContext httpContext, string id, string app = "*", string schema = "*", PermissionSet permissions = null) { - return httpContext.GetPermissions().Includes(Permissions.ForApp(id, app, schema)); + return httpContext.HasPermission(Shared.Permissions.ForApp(id, app, schema), permissions); } - public static bool HasPermission(this ApiController controller, Permission permission) + public static bool HasPermission(this ApiController controller, Permission permission, PermissionSet permissions = null) { - return controller.HttpContext.GetPermissions().Includes(permission); + return controller.HttpContext.HasPermission(permission, permissions); } - public static bool HasPermission(this ApiController controller, string id, string app = "*", string schema = "*") + public static bool HasPermission(this ApiController controller, string id, string app = "*", string schema = "*", PermissionSet permissions = null) { if (app == "*") { @@ -71,7 +71,7 @@ namespace Squidex.Web } } - return controller.HttpContext.GetPermissions().Includes(Permissions.ForApp(id, app, schema)); + return controller.HasPermission(Shared.Permissions.ForApp(id, app, schema), permissions); } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs index fd4fac1c5..fef02c374 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs @@ -43,7 +43,7 @@ namespace Squidex.Areas.Api.Controllers.Apps [HttpGet] [Route("apps/{app}/patterns/")] [ProducesResponseType(typeof(AppPatternDto[]), 200)] - [ApiPermission(Permissions.AppPatternsRead)] + [ApiPermission(Permissions.AppCommon)] [ApiCosts(0)] public IActionResult GetPatterns(string app) { diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index 4219f42dd..36f336ced 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -58,11 +58,11 @@ namespace Squidex.Areas.Api.Controllers.Apps public async Task GetApps() { var userOrClientId = HttpContext.User.UserOrClientId(); - var userPermissions = HttpContext.User.Permissions(); + var userPermissions = HttpContext.Permissions(); var entities = await appProvider.GetUserApps(userOrClientId, userPermissions); - var response = entities.ToArray(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider)); + var response = entities.ToArray(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider, this)); Response.Headers[HeaderNames.ETag] = response.ToManyEtag(); diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs index 7fe96d7bc..dbb2f4ded 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs @@ -9,6 +9,11 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using NodaTime; +using Squidex.Areas.Api.Controllers.Assets; +using Squidex.Areas.Api.Controllers.Backups; +using Squidex.Areas.Api.Controllers.Plans; +using Squidex.Areas.Api.Controllers.Rules; +using Squidex.Areas.Api.Controllers.Schemas; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Infrastructure; @@ -16,10 +21,11 @@ using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Security; using Squidex.Shared; using Squidex.Web; +using AllPermissions = Squidex.Shared.Permissions; namespace Squidex.Areas.Api.Controllers.Apps.Models { - public sealed class AppDto : IGenerateETag + public sealed class AppDto : Resource, IGenerateETag { /// /// The name of the app. @@ -63,7 +69,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models /// public string PlanUpgrade { get; set; } - public static AppDto FromApp(IAppEntity app, string userId, PermissionSet userPermissions, IAppPlansProvider plans) + public static AppDto FromApp(IAppEntity app, string userId, PermissionSet userPermissions, IAppPlansProvider plans, ApiController controller) { var permissions = new List(); @@ -77,13 +83,84 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models permissions.AddRange(userPermissions.ToAppPermissions(app.Name)); } - var response = SimpleMapper.Map(app, new AppDto()); + var result = SimpleMapper.Map(app, new AppDto()); - response.Permissions = permissions.ToArray(x => x.Id); - response.PlanName = plans.GetPlanForApp(app)?.Name; - response.PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name; + result.Permissions = permissions.ToArray(x => x.Id); + result.PlanName = plans.GetPlanForApp(app)?.Name; - return response; + if (controller.HasPermission(AllPermissions.AppPlansChange, app.Name)) + { + result.PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name; + } + + return CreateLinks(result, controller, new PermissionSet(permissions)); + } + + private static AppDto CreateLinks(AppDto result, ApiController controller, PermissionSet permissions) + { + var values = new { app = result.Name }; + + if (controller.HasPermission(AllPermissions.AppDelete, result.Name, permissions: permissions)) + { + result.AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteApp), values)); + } + + if (controller.HasPermission(AllPermissions.AppAssetsRead, result.Name, permissions: permissions)) + { + result.AddGetLink("assets", controller.Url(x => nameof(x.GetAssets), values)); + } + + if (controller.HasPermission(AllPermissions.AppBackupsRead, result.Name, permissions: permissions)) + { + result.AddGetLink("backups", controller.Url(x => nameof(x.GetJobs), values)); + } + + if (controller.HasPermission(AllPermissions.AppClientsRead, result.Name, permissions: permissions)) + { + result.AddGetLink("clients", controller.Url(x => nameof(x.GetClients), values)); + } + + if (controller.HasPermission(AllPermissions.AppContributorsRead, result.Name, permissions: permissions)) + { + result.AddGetLink("contributors", controller.Url(x => nameof(x.GetContributors), values)); + } + + if (controller.HasPermission(AllPermissions.AppCommon, result.Name, permissions: permissions)) + { + result.AddGetLink("languages", controller.Url(x => nameof(x.GetLanguages), values)); + } + + if (controller.HasPermission(AllPermissions.AppCommon, result.Name, permissions: permissions)) + { + result.AddGetLink("patterns", controller.Url(x => nameof(x.GetPatterns), values)); + } + + if (controller.HasPermission(AllPermissions.AppPlansRead, result.Name, permissions: permissions)) + { + result.AddGetLink("plans", controller.Url(x => nameof(x.GetPlans), values)); + } + + if (controller.HasPermission(AllPermissions.AppRolesRead, result.Name, permissions: permissions)) + { + result.AddGetLink("roles", controller.Url(x => nameof(x.GetRoles), values)); + } + + if (controller.HasPermission(AllPermissions.AppRulesRead, result.Name, permissions: permissions)) + { + result.AddGetLink("rules", controller.Url(x => nameof(x.GetRules), values)); + } + + if (controller.HasPermission(AllPermissions.AppCommon, result.Name, permissions: permissions)) + { + result.AddGetLink("schemas", controller.Url(x => nameof(x.GetSchemas), values)); + } + + if (controller.HasPermission(AllPermissions.AppSchemasCreate, result.Name, permissions: permissions)) + { + result.AddPostLink("schemas/create", controller.Url(x => nameof(x.PostSchema), values)); + } + + return result; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs b/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs index 9490a4f48..5611ac7ac 100644 --- a/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs +++ b/src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs @@ -74,7 +74,7 @@ namespace Squidex.Areas.Api.Controllers.Plans [ProducesResponseType(typeof(ErrorDto), 400)] [ApiPermission(Permissions.AppPlansChange)] [ApiCosts(0)] - public async Task ChangePlanAsync(string app, [FromBody] ChangePlanDto request) + public async Task PutPlan(string app, [FromBody] ChangePlanDto request) { var context = await CommandBus.PublishAsync(request.ToCommand()); diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs index a6ed2b46b..41494e0c1 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDetailsDto.cs @@ -5,49 +5,20 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using NodaTime; using Squidex.Areas.Api.Controllers.Schemas.Models.Converters; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Schemas; -using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Schemas.Models { - public sealed class SchemaDetailsDto + public sealed class SchemaDetailsDto : SchemaDto { private static readonly Dictionary EmptyPreviewUrls = new Dictionary(); - /// - /// The id of the schema. - /// - public Guid Id { get; set; } - - /// - /// The name of the schema. Unique within the app. - /// - [Required] - [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")] - public string Name { get; set; } - - /// - /// The name of the category. - /// - public string Category { get; set; } - - /// - /// Indicates if the schema is a singleton. - /// - public bool IsSingleton { get; set; } - - /// - /// Indicates if the schema is published. - /// - public bool IsPublished { get; set; } - /// /// The scripts. /// @@ -64,40 +35,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models [Required] public List Fields { get; set; } - /// - /// The schema properties. - /// - [Required] - public SchemaPropertiesDto Properties { get; set; } = new SchemaPropertiesDto(); - - /// - /// The user that has created the schema. - /// - [Required] - public RefToken CreatedBy { get; set; } - - /// - /// The user that has updated the schema. - /// - [Required] - public RefToken LastModifiedBy { get; set; } - - /// - /// The date and time when the schema has been created. - /// - public Instant Created { get; set; } - - /// - /// The date and time when the schema has been modified last. - /// - public Instant LastModified { get; set; } - - /// - /// The version of the schema. - /// - public long Version { get; set; } - - public static SchemaDetailsDto FromSchema(ISchemaEntity schema) + public static SchemaDetailsDto FromSchemaWithDetails(ISchemaEntity schema, ApiController controller, string app) { var response = new SchemaDetailsDto(); @@ -147,7 +85,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models response.Fields.Add(fieldDto); } - return response; + return CreateLinks(response, controller, app); } } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs index f66d64d23..4bb86680f 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/Models/SchemaDto.cs @@ -8,14 +8,16 @@ using System; using System.ComponentModel.DataAnnotations; using NodaTime; +using Squidex.Areas.Api.Controllers.Contents; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; +using Squidex.Shared; using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Schemas.Models { - public sealed class SchemaDto : IGenerateETag + public class SchemaDto : Resource, IGenerateETag { /// /// The id of the schema. @@ -77,7 +79,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models /// public long Version { get; set; } - public static SchemaDto FromSchema(ISchemaEntity schema) + public static SchemaDto FromSchema(ISchemaEntity schema, ApiController controller, string app) { var response = new SchemaDto { Properties = new SchemaPropertiesDto() }; @@ -85,6 +87,20 @@ namespace Squidex.Areas.Api.Controllers.Schemas.Models SimpleMapper.Map(schema.SchemaDef, response); SimpleMapper.Map(schema.SchemaDef.Properties, response.Properties); + return CreateLinks(response, controller, app); + } + + protected static T CreateLinks(T response, ApiController controller, string app) where T : SchemaDto + { + var values = new { app, name = response.Name }; + + response.AddSelfLink(controller.Url(x => nameof(x.GetSchema), values)); + + if (controller.HasPermission(Permissions.AppContentsRead, app, response.Name)) + { + response.AddGetLink("contents", controller.Url(x => nameof(x.GetContents), values)); + } + return response; } } diff --git a/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs b/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs index 1fb64ec83..7320e4484 100644 --- a/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs +++ b/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs @@ -51,7 +51,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas { var schemas = await appProvider.GetSchemasAsync(AppId); - var response = schemas.ToArray(SchemaDto.FromSchema); + var response = schemas.ToArray(x => SchemaDto.FromSchema(x, this, app)); Response.Headers[HeaderNames.ETag] = response.ToManyEtag(); @@ -90,7 +90,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas return NotFound(); } - var response = SchemaDetailsDto.FromSchema(entity); + var response = SchemaDetailsDto.FromSchemaWithDetails(entity, this, app); Response.Headers[HeaderNames.ETag] = entity.Version.ToString(); diff --git a/src/Squidex/Areas/Api/Controllers/Users/Models/ResourcesDto.cs b/src/Squidex/Areas/Api/Controllers/Users/Models/ResourcesDto.cs new file mode 100644 index 000000000..13570a1bf --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Users/Models/ResourcesDto.cs @@ -0,0 +1,39 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Areas.Api.Controllers.Backups; +using Squidex.Areas.Api.Controllers.EventConsumers; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Users.Models +{ + public sealed class ResourcesDto : Resource + { + public static ResourcesDto FromController(ApiController controller) + { + var result = new ResourcesDto(); + + if (controller.HasPermission(Permissions.AdminEventsRead)) + { + result.AddGetLink("admin/eventConsumers", controller.Url(x => nameof(x.GetEventConsumers))); + } + + if (controller.HasPermission(Permissions.AdminRestoreRead)) + { + result.AddGetLink("admin/restore", controller.Url(x => nameof(x.GetJob))); + } + + if (controller.HasPermission(Permissions.AdminUsersRead)) + { + result.AddGetLink("admin/users", controller.Url(x => nameof(x.GetUsers))); + } + + return result; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs index a5cf31c74..0ab2944c5 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs @@ -56,6 +56,23 @@ namespace Squidex.Areas.Api.Controllers.Users this.log = log; } + /// + /// Get the user resources. + /// + /// + /// 200 => User resources returned. + /// + [HttpGet] + [Route("user/resources/")] + [ProducesResponseType(typeof(ResourcesDto), 200)] + [ApiPermission] + public IActionResult GetUserResources() + { + var response = ResourcesDto.FromController(this); + + return Ok(response); + } + /// /// Get users by query. /// diff --git a/src/Squidex/app/features/administration/administration-area.component.html b/src/Squidex/app/features/administration/administration-area.component.html index c3b623cdb..3f2e2c3d0 100644 --- a/src/Squidex/app/features/administration/administration-area.component.html +++ b/src/Squidex/app/features/administration/administration-area.component.html @@ -1,18 +1,18 @@ - @@ -37,12 +38,12 @@
-
- +
diff --git a/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts b/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts index a3e1f140b..0e18f523f 100644 --- a/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts +++ b/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts @@ -29,6 +29,9 @@ export class ContentChangedTriggerComponent implements OnInit { @Input() public schemas: ImmutableArray; + @Input() + public canUpdate: boolean; + @Input() public trigger: any; diff --git a/src/Squidex/app/framework/angular/forms/toggle.component.ts b/src/Squidex/app/framework/angular/forms/toggle.component.ts index 7105ada32..33e53d9bf 100644 --- a/src/Squidex/app/framework/angular/forms/toggle.component.ts +++ b/src/Squidex/app/framework/angular/forms/toggle.component.ts @@ -28,6 +28,10 @@ export class ToggleComponent extends StatefulControlComponent { const version = new Version('1'); @@ -107,7 +107,7 @@ describe('RulesService', () => { it('should make get request to get app rules', inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => { - let rules: RuleDto[]; + let rules: RulesDto; rulesService.getRules('my-app').subscribe(result => { rules = result; @@ -118,49 +118,18 @@ describe('RulesService', () => { expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush([ - { - id: 'id1', - created: '2016-12-12T10:10', - createdBy: 'CreatedBy1', - lastModified: '2017-12-12T10:10', - lastModifiedBy: 'LastModifiedBy1', - url: 'http://squidex.io/hook', - version: '1', - trigger: { - param1: 1, - param2: 2, - triggerType: 'ContentChanged' - }, - action: { - param3: 3, - param4: 4, - actionType: 'Webhook' - }, - isEnabled: true - } - ]); + req.flush({ + items: [ + ruleResponse(12), + ruleResponse(13) + ] + }); expect(rules!).toEqual( - [ - new RuleDto('id1', 'CreatedBy1', 'LastModifiedBy1', - DateTime.parseISO_UTC('2016-12-12T10:10'), - DateTime.parseISO_UTC('2017-12-12T10:10'), - version, - true, - { - param1: 1, - param2: 2, - triggerType: 'ContentChanged' - }, - 'ContentChanged', - { - param3: 3, - param4: 4, - actionType: 'Webhook' - }, - 'Webhook') - ]); + new RulesDto(2, [ + createRule(12), + createRule(13) + ])); })); it('should make post request to create rule', @@ -179,7 +148,7 @@ describe('RulesService', () => { } }; - let rule: Versioned; + let rule: RuleDto; rulesService.postRule('my-app', dto).subscribe(result => { rule = result; @@ -190,18 +159,13 @@ describe('RulesService', () => { expect(req.request.method).toEqual('POST'); expect(req.request.headers.get('If-Match')).toBeNull(); - req.flush({ id: 'id1' }, { + req.flush(ruleResponse(12), { headers: { etag: '1' } }); - expect(rule!).toEqual({ - payload: { - id: 'id1' - }, - version - }); + expect(rule!).toEqual(createRule(12)); })); it('should make put request to update rule', @@ -216,46 +180,88 @@ describe('RulesService', () => { } }; - rulesService.putRule('my-app', '123', dto, version).subscribe(); + const resource: Resource = { + _links: { + update: { method: 'PUT', href: '/api/apps/my-app/rules/123' } + } + }; + + let rule: RuleDto; + + rulesService.putRule('my-app', resource, dto, version).subscribe(result => { + rule = result; + }); const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/123'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toEqual(version.value); - req.flush({}); + req.flush(ruleResponse(123)); + + expect(rule!).toEqual(createRule(123)); })); it('should make put request to enable rule', inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => { - rulesService.enableRule('my-app', '123', version).subscribe(); + const resource: Resource = { + _links: { + enable: { method: 'PUT', href: '/api/apps/my-app/rules/123/enable' } + } + }; + + let rule: RuleDto; + + rulesService.enableRule('my-app', resource, version).subscribe(result => { + rule = result; + }); const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/123/enable'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toEqual(version.value); - req.flush({}); + req.flush(ruleResponse(123)); + + expect(rule!).toEqual(createRule(123)); })); it('should make put request to disable rule', inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => { - rulesService.disableRule('my-app', '123', version).subscribe(); + const resource: Resource = { + _links: { + disable: { method: 'PUT', href: '/api/apps/my-app/rules/123/disable' } + } + }; + + let rule: RuleDto; + + rulesService.disableRule('my-app', resource, version).subscribe(result => { + rule = result; + }); const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/123/disable'); expect(req.request.method).toEqual('PUT'); expect(req.request.headers.get('If-Match')).toEqual(version.value); - req.flush({}); + req.flush(ruleResponse(123)); + + expect(rule!).toEqual(createRule(123)); })); it('should make delete request to delete rule', inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => { - rulesService.deleteRule('my-app', '123', version).subscribe(); + const resource: Resource = { + _links: { + delete: { method: 'DELETE', href: '/api/apps/my-app/rules/123' } + } + }; + + rulesService.deleteRule('my-app', resource, version).subscribe(); const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/123'); @@ -322,7 +328,13 @@ describe('RulesService', () => { it('should make put request to enqueue rule event', inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => { - rulesService.enqueueEvent('my-app', '123').subscribe(); + const resource: Resource = { + _links: { + update: { method: 'PUT', href: '/api/apps/my-app/rules/events/123' } + } + }; + + rulesService.enqueueEvent('my-app', resource).subscribe(); const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/events/123'); @@ -335,7 +347,13 @@ describe('RulesService', () => { it('should make delete request to cancel rule event', inject([RulesService, HttpTestingController], (rulesService: RulesService, httpMock: HttpTestingController) => { - rulesService.cancelEvent('my-app', '123').subscribe(); + const resource: Resource = { + _links: { + delete: { method: 'DELETE', href: '/api/apps/my-app/rules/events/123' } + } + }; + + rulesService.cancelEvent('my-app', resource).subscribe(); const req = httpMock.expectOne('http://service/p/api/apps/my-app/rules/events/123'); @@ -344,4 +362,58 @@ describe('RulesService', () => { req.flush({}); })); -}); \ No newline at end of file + + function ruleResponse(id: number, suffix = '') { + return { + id: `id${id}`, + created: `${id % 1000 + 2000}-12-12T10:10`, + createdBy: `creator-${id}`, + lastModified: `${id % 1000 + 2000}-11-11T10:10`, + lastModifiedBy: `modifier-${id}`, + isEnabled: id % 2 === 0, + trigger: { + param1: 1, + param2: 2, + triggerType: `ContentChanged${id}${suffix}` + }, + action: { + param3: 3, + param4: 4, + actionType: `Webhook${id}${suffix}` + }, + version: id, + _links: { + update: { method: 'PUT', href: `/rules/${id}` } + } + }; + } +}); + +export function createRule(id: number, suffix = '') { + const result = new RuleDto( + `id${id}`, + `creator-${id}`, + `modifier-${id}`, + DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10`), + DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10`), + new Version(`${id}`), + id % 2 === 0, + { + param1: 1, + param2: 2, + triggerType: `ContentChanged${id}${suffix}` + }, + `ContentChanged${id}${suffix}`, + { + param3: 3, + param4: 4, + actionType: `Webhook${id}${suffix}` + }, + `Webhook${id}${suffix}`); + + result._links['update'] = { + method: 'PUT', href: `/rules/${id}` + }; + + return result; +} \ No newline at end of file diff --git a/src/Squidex/app/shared/services/rules.service.ts b/src/Squidex/app/shared/services/rules.service.ts index 7af1acfb7..c0a7030de 100644 --- a/src/Squidex/app/shared/services/rules.service.ts +++ b/src/Squidex/app/shared/services/rules.service.ts @@ -15,12 +15,13 @@ import { ApiUrlConfig, DateTime, HTTP, - mapVersioned, Model, pretifyError, + Resource, + ResourceLinks, ResultSet, Version, - Versioned + withLinks } from '@app/framework'; export const ALL_TRIGGERS = { @@ -74,7 +75,13 @@ export class RuleElementPropertyDto { } } +export class RulesDto extends ResultSet { + public readonly _links: ResourceLinks = {}; +} + export class RuleDto extends Model { + public readonly _links: ResourceLinks = {}; + constructor( public readonly id: string, public readonly createdBy: string, @@ -92,9 +99,13 @@ export class RuleDto extends Model { } } -export class RuleEventsDto extends ResultSet { } +export class RuleEventsDto extends ResultSet { + public readonly _links: ResourceLinks = {}; +} export class RuleEventDto extends Model { + public readonly _links: ResourceLinks = {}; + constructor( public readonly id: string, public readonly created: DateTime, @@ -115,10 +126,6 @@ export interface UpsertRuleDto { readonly action: RuleAction; } -export interface RuleCreatedDto { - readonly id: string; -} - export type RuleAction = { actionType: string } & any; export type RuleTrigger = { triggerType: string } & any; @@ -167,77 +174,87 @@ export class RulesService { pretifyError('Failed to load Rules. Please reload.')); } - public getRules(appName: string): Observable { + public getRules(appName: string): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules`); return HTTP.getVersioned(this.http, url).pipe( map(({ payload }) => { - const items: any[] = payload.body; + const items: any[] = payload.body.items; - const rules = items.map(item => - new RuleDto( - item.id, - item.createdBy, - item.lastModifiedBy, - DateTime.parseISO_UTC(item.created), - DateTime.parseISO_UTC(item.lastModified), - new Version(item.version.toString()), - item.isEnabled, - item.trigger, - item.trigger.triggerType, - item.action, - item.action.actionType)); - - return rules; + const rules = items.map(item => parseRule(item)); + + return withLinks(new RulesDto(rules.length, rules), payload.body); }), pretifyError('Failed to load Rules. Please reload.')); } - public postRule(appName: string, dto: UpsertRuleDto): Observable> { + public postRule(appName: string, dto: UpsertRuleDto): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules`); - return HTTP.postVersioned(this.http, url, dto).pipe( - mapVersioned(({ body }) => body!), + return HTTP.postVersioned(this.http, url, dto).pipe( + map(({ payload }) => { + return parseRule(payload.body); + }), tap(() => { this.analytics.trackEvent('Rule', 'Created', appName); }), pretifyError('Failed to create rule. Please reload.')); } - public putRule(appName: string, id: string, dto: Partial, version: Version): Observable> { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/${id}`); + public putRule(appName: string, resource: Resource, dto: Partial, version: Version): Observable { + const link = resource._links['update']; - return HTTP.putVersioned(this.http, url, dto, version).pipe( + const url = this.apiUrl.buildUrl(link.href); + + return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( + map(({ payload }) => { + return parseRule(payload.body); + }), tap(() => { this.analytics.trackEvent('Rule', 'Updated', appName); }), pretifyError('Failed to update rule. Please reload.')); } - public enableRule(appName: string, id: string, version: Version): Observable> { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/${id}/enable`); + public enableRule(appName: string, resource: Resource, version: Version): Observable { + const link = resource._links['enable']; - return HTTP.putVersioned(this.http, url, {}, version).pipe( + const url = this.apiUrl.buildUrl(link.href); + + return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( + map(({ payload }) => { + return parseRule(payload.body); + }), tap(() => { - this.analytics.trackEvent('Rule', 'Updated', appName); + this.analytics.trackEvent('Rule', 'Enabled', appName); }), pretifyError('Failed to enable rule. Please reload.')); } - public disableRule(appName: string, id: string, version: Version): Observable> { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/${id}/disable`); + public disableRule(appName: string, resource: Resource, version: Version): Observable { + const link = resource._links['disable']; - return HTTP.putVersioned(this.http, url, {}, version).pipe( + const url = this.apiUrl.buildUrl(link.href); + + return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( + map(({ payload }) => { + return parseRule(payload.body); + }), tap(() => { - this.analytics.trackEvent('Rule', 'Updated', appName); + this.analytics.trackEvent('Rule', 'Disabled', appName); }), pretifyError('Failed to disable rule. Please reload.')); } - public deleteRule(appName: string, id: string, version: Version): Observable { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/${id}`); + public deleteRule(appName: string, resource: Resource, version: Version): Observable { + const link = resource._links['delete']; - return HTTP.deleteVersioned(this.http, url, version).pipe( + const url = this.apiUrl.buildUrl(link.href); + + return HTTP.requestVersioned(this.http, link.method, url, version).pipe( + map(({ payload }) => { + return parseRule(payload.body); + }), tap(() => { this.analytics.trackEvent('Rule', 'Deleted', appName); }), @@ -270,23 +287,44 @@ export class RulesService { pretifyError('Failed to load events. Please reload.')); } - public enqueueEvent(appName: string, id: string): Observable { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/events/${id}`); + public enqueueEvent(appName: string, resource: Resource): Observable { + const link = resource._links['update']; + + const url = this.apiUrl.buildUrl(link.href); - return HTTP.putVersioned(this.http, url, {}).pipe( + return HTTP.requestVersioned(this.http, link.method, url).pipe( tap(() => { this.analytics.trackEvent('Rule', 'EventEnqueued', appName); }), pretifyError('Failed to enqueue rule event. Please reload.')); } - public cancelEvent(appName: string, id: string): Observable { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/events/${id}`); + public cancelEvent(appName: string, resource: Resource): Observable { + const link = resource._links['delete']; - return HTTP.deleteVersioned(this.http, url).pipe( + const url = this.apiUrl.buildUrl(link.href); + + return HTTP.requestVersioned(this.http, link.method, url).pipe( tap(() => { this.analytics.trackEvent('Rule', 'EventDequeued', appName); }), pretifyError('Failed to cancel rule event. Please reload.')); } +} + +function parseRule(resource: any) { + return withLinks( + new RuleDto( + resource.id, + resource.createdBy, + resource.lastModifiedBy, + DateTime.parseISO_UTC(resource.created), + DateTime.parseISO_UTC(resource.lastModified), + new Version(resource.version.toString()), + resource.isEnabled, + resource.trigger, + resource.trigger.triggerType, + resource.action, + resource.action.actionType), + resource); } \ No newline at end of file diff --git a/src/Squidex/app/shared/state/asset-uploader.state.spec.ts b/src/Squidex/app/shared/state/asset-uploader.state.spec.ts index 17413beff..c3a185314 100644 --- a/src/Squidex/app/shared/state/asset-uploader.state.spec.ts +++ b/src/Squidex/app/shared/state/asset-uploader.state.spec.ts @@ -22,7 +22,7 @@ import { createAsset } from './../services/assets.service.spec'; import { TestValues } from './_test-helpers'; -describe('AssetsState', () => { +describe('AssetUploaderState', () => { const { app, appsState @@ -155,7 +155,7 @@ describe('AssetsState', () => { it('should update status when uploading asset completes', () => { const file: File = { name: 'my-file' }; - let updated = createAsset(1, undefined, '-new'); + let updated = createAsset(1, undefined, '_new'); assetsService.setup(x => x.replaceFile(app, asset, file, asset.version)) .returns(() => of(10, 20, updated)).verifiable(); diff --git a/src/Squidex/app/shared/state/assets.state.spec.ts b/src/Squidex/app/shared/state/assets.state.spec.ts index c9f045a02..f8df2c6d7 100644 --- a/src/Squidex/app/shared/state/assets.state.spec.ts +++ b/src/Squidex/app/shared/state/assets.state.spec.ts @@ -41,6 +41,9 @@ describe('AssetsState', () => { dialogs = Mock.ofType(); assetsService = Mock.ofType(); + assetsService.setup(x => x.getTags(app)) + .returns(() => of({ tag1: 1, shared: 2, tag2: 1 })).verifiable(Times.atLeastOnce()); + assetsState = new AssetsState(appsState.object, assetsService.object, dialogs.object); }); @@ -50,12 +53,9 @@ describe('AssetsState', () => { describe('Loading', () => { it('should load assets', () => { - assetsService.setup(x => x.getAssets(app, 30, 0, undefined, [])) + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([]))) .returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(); - assetsService.setup(x => x.getTags(app)) - .returns(() => of({ tag1: 1, shared: 2, tag2: 1 })).verifiable(); - assetsState.load().subscribe(); expect(assetsState.snapshot.assets.values).toEqual([asset1, asset2]); @@ -66,11 +66,8 @@ describe('AssetsState', () => { }); it('should show notification on load when reload is true', () => { - assetsService.setup(x => x.getAssets(app, 30, 0, undefined, [])) - .returns(() => of(new AssetsDto(200, [asset1, asset2]))); - - assetsService.setup(x => x.getTags(app)) - .returns(() => of({ tag1: 1, shared: 2, tag2: 1 })).verifiable(); + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([]))) + .returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(); assetsState.load(true).subscribe(); @@ -80,20 +77,20 @@ describe('AssetsState', () => { }); it('should load with tags when tag toggled', () => { - assetsService.setup(x => x.getAssets(app, 30, 0, undefined, ['tag1'])) - .returns(() => of(new AssetsDto(0, []))); + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue(['tag1']))) + .returns(() => of(new AssetsDto(0, []))).verifiable(); assetsState.toggleTag('tag1').subscribe(); expect(assetsState.isTagSelected('tag1')).toBeTruthy(); }); - it('should load without tags when tag toggled', () => { - assetsService.setup(x => x.getAssets(app, 30, 0, undefined, ['tag1'])) - .returns(() => of(new AssetsDto(0, []))); + it('should load without tags when tag untoggled', () => { + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue(['tag1']))) + .returns(() => of(new AssetsDto(0, []))).verifiable(); - assetsService.setup(x => x.getAssets(app, 30, 0, undefined, [])) - .returns(() => of(new AssetsDto(0, []))); + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([]))) + .returns(() => of(new AssetsDto(0, []))).verifiable(); assetsState.toggleTag('tag1').subscribe(); assetsState.toggleTag('tag1').subscribe(); @@ -102,8 +99,8 @@ describe('AssetsState', () => { }); it('should load with tags when tags selected', () => { - assetsService.setup(x => x.getAssets(app, 30, 0, undefined, ['tag1', 'tag2'])) - .returns(() => of(new AssetsDto(0, []))); + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue(['tag1', 'tag2']))) + .returns(() => of(new AssetsDto(0, []))).verifiable(); assetsState.selectTags(['tag1', 'tag2']).subscribe(); @@ -111,8 +108,8 @@ describe('AssetsState', () => { }); it('should load without tags when tags reset', () => { - assetsService.setup(x => x.getAssets(app, 30, 0, undefined, [])) - .returns(() => of(new AssetsDto(0, []))); + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([]))) + .returns(() => of(new AssetsDto(0, []))).verifiable(); assetsState.resetTags().subscribe(); @@ -120,9 +117,13 @@ describe('AssetsState', () => { }); it('should load next page and prev page when paging', () => { - assetsService.setup(x => x.getAssets(app, 30, 30, undefined, [])) - .returns(() => of(new AssetsDto(200, []))); + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([]))) + .returns(() => of(new AssetsDto(200, []))).verifiable(Times.exactly(2)); + assetsService.setup(x => x.getAssets(app, 30, 30, undefined, It.isValue([]))) + .returns(() => of(new AssetsDto(200, []))).verifiable(); + + assetsState.load().subscribe(); assetsState.goNext().subscribe(); assetsState.goPrev().subscribe(); @@ -130,8 +131,8 @@ describe('AssetsState', () => { }); it('should load with query when searching', () => { - assetsService.setup(x => x.getAssets(app, 30, 0, 'my-query', [])) - .returns(() => of(new AssetsDto(0, []))); + assetsService.setup(x => x.getAssets(app, 30, 0, 'my-query', It.isValue([]))) + .returns(() => of(new AssetsDto(0, []))).verifiable(); assetsState.search('my-query').subscribe(); @@ -141,12 +142,9 @@ describe('AssetsState', () => { describe('Updates', () => { beforeEach(() => { - assetsService.setup(x => x.getAssets(app, 30, 0, undefined, [])) + assetsService.setup(x => x.getAssets(app, 30, 0, undefined, It.isValue([]))) .returns(() => of(new AssetsDto(200, [asset1, asset2]))).verifiable(); - assetsService.setup(x => x.getTags(app)) - .returns(() => of({ tag1: 1, shared: 2, tag2: 1 })).verifiable(); - assetsState.load(true).subscribe(); }); @@ -165,7 +163,7 @@ describe('AssetsState', () => { }); it('should update asset when updated', () => { - const update = createAsset(1, ['new'], '-new'); + const update = createAsset(1, ['new'], '_new'); assetsState.update(update); diff --git a/src/Squidex/app/shared/state/rule-events.state.spec.ts b/src/Squidex/app/shared/state/rule-events.state.spec.ts index 764654588..5adcd6f4a 100644 --- a/src/Squidex/app/shared/state/rule-events.state.spec.ts +++ b/src/Squidex/app/shared/state/rule-events.state.spec.ts @@ -76,24 +76,24 @@ describe('RuleEventsState', () => { }); it('should call service when enqueuing event', () => { - rulesService.setup(x => x.enqueueEvent(app, oldRuleEvents[0].id)) + rulesService.setup(x => x.enqueueEvent(app, oldRuleEvents[0])) .returns(() => of({})); ruleEventsState.enqueue(oldRuleEvents[0]).subscribe(); expect().nothing(); - rulesService.verify(x => x.enqueueEvent(app, oldRuleEvents[0].id), Times.once()); + rulesService.verify(x => x.enqueueEvent(app, oldRuleEvents[0]), Times.once()); }); it('should call service when cancelling event', () => { - rulesService.setup(x => x.cancelEvent(app, oldRuleEvents[0].id)) + rulesService.setup(x => x.cancelEvent(app, oldRuleEvents[0])) .returns(() => of({})); ruleEventsState.cancel(oldRuleEvents[0]).subscribe(); expect().nothing(); - rulesService.verify(x => x.cancelEvent(app, oldRuleEvents[0].id), Times.once()); + rulesService.verify(x => x.cancelEvent(app, oldRuleEvents[0]), Times.once()); }); }); \ No newline at end of file diff --git a/src/Squidex/app/shared/state/rule-events.state.ts b/src/Squidex/app/shared/state/rule-events.state.ts index b9bf61996..8f777b4d5 100644 --- a/src/Squidex/app/shared/state/rule-events.state.ts +++ b/src/Squidex/app/shared/state/rule-events.state.ts @@ -82,7 +82,7 @@ export class RuleEventsState extends State { } public enqueue(event: RuleEventDto): Observable { - return this.rulesService.enqueueEvent(this.appsState.appName, event.id).pipe( + return this.rulesService.enqueueEvent(this.appsState.appName, event).pipe( tap(() => { this.dialogs.notifyInfo('Events enqueued. Will be resend in a few seconds.'); }), @@ -90,7 +90,7 @@ export class RuleEventsState extends State { } public cancel(event: RuleEventDto): Observable { - return this.rulesService.cancelEvent(this.appsState.appName, event.id).pipe( + return this.rulesService.cancelEvent(this.appsState.appName, event).pipe( tap(() => { return this.next(s => { const ruleEvents = s.ruleEvents.replaceBy('id', setCancelled(event)); diff --git a/src/Squidex/app/shared/state/rules.state.spec.ts b/src/Squidex/app/shared/state/rules.state.spec.ts index 6b8a8cdd5..90ed19ada 100644 --- a/src/Squidex/app/shared/state/rules.state.spec.ts +++ b/src/Squidex/app/shared/state/rules.state.spec.ts @@ -12,30 +12,27 @@ import { RulesState } from './rules.state'; import { DialogService, - RuleDto, + RulesDto, RulesService, versioned } from '@app/shared/internal'; +import { createRule } from '../services/rules.service.spec'; + import { TestValues } from './_test-helpers'; describe('RulesState', () => { const { app, appsState, - authService, - creation, - creator, - modified, - modifier, newVersion, version } = TestValues; - const oldRules = [ - new RuleDto('id1', creator, creator, creation, creation, version, false, {}, 'trigger1', {}, 'action1'), - new RuleDto('id2', creator, creator, creation, creation, version, true, {}, 'trigger2', {}, 'action2') - ]; + const rule1 = createRule(1); + const rule2 = createRule(2); + + const newRule = createRule(3); let dialogs: IMock; let rulesService: IMock; @@ -45,7 +42,7 @@ describe('RulesState', () => { dialogs = Mock.ofType(); rulesService = Mock.ofType(); - rulesState = new RulesState(appsState.object, authService.object, dialogs.object, rulesService.object); + rulesState = new RulesState(appsState.object, dialogs.object, rulesService.object); }); afterEach(() => { @@ -55,11 +52,11 @@ describe('RulesState', () => { describe('Loading', () => { it('should load rules', () => { rulesService.setup(x => x.getRules(app)) - .returns(() => of(oldRules)).verifiable(); + .returns(() => of(new RulesDto(2, [rule1, rule2]))).verifiable(); rulesState.load().subscribe(); - expect(rulesState.snapshot.rules.values).toEqual(oldRules); + expect(rulesState.snapshot.rules.values).toEqual([rule1, rule2]); expect(rulesState.snapshot.isLoaded).toBeTruthy(); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); @@ -67,7 +64,7 @@ describe('RulesState', () => { it('should show notification on load when reload is true', () => { rulesService.setup(x => x.getRules(app)) - .returns(() => of(oldRules)).verifiable(); + .returns(() => of(new RulesDto(2, [rule1, rule2]))).verifiable(); rulesState.load(true).subscribe(); @@ -81,89 +78,85 @@ describe('RulesState', () => { describe('Updates', () => { beforeEach(() => { rulesService.setup(x => x.getRules(app)) - .returns(() => of(oldRules)).verifiable(); + .returns(() => of(new RulesDto(2, [rule1, rule2]))).verifiable(); rulesState.load().subscribe(); }); it('should add rule to snapshot when created', () => { - const newRule = new RuleDto('id3', modifier, modifier, modified, modified, version, true, { value: 3 }, 'trigger3', { value: 1 }, 'action3'); - const request = { trigger: { triggerType: 'trigger3', value: 3 }, action: { actionType: 'action3', value: 1 } }; rulesService.setup(x => x.postRule(app, request)) - .returns(() => of(versioned(version, { id: 'id3' }))); + .returns(() => of(newRule)); - rulesState.create(request, modified).subscribe(); + rulesState.create(request).subscribe(); - expect(rulesState.snapshot.rules.values).toEqual([...oldRules, newRule]); + expect(rulesState.snapshot.rules.values).toEqual([rule1, rule2, newRule]); }); - it('should update action and update and user info when updated action', () => { + it('should update rule when updated action', () => { const newAction = {}; - rulesService.setup(x => x.putRule(app, oldRules[0].id, It.isAny(), version)) - .returns(() => of(versioned(newVersion))).verifiable(); + const updated = createRule(1, 'new'); + + rulesService.setup(x => x.putRule(app, rule1, It.isAny(), version)) + .returns(() => of(updated)).verifiable(); - rulesState.updateAction(oldRules[0], newAction, modified).subscribe(); + rulesState.updateAction(rule1, newAction).subscribe(); - const rule_1 = rulesState.snapshot.rules.at(0); + const newRule1 = rulesState.snapshot.rules.at(0); - expect(rule_1.action).toBe(newAction); - expectToBeModified(rule_1); + expect(newRule1).toEqual(updated); }); - it('should update trigger and update and user info when updated trigger', () => { + it('should update rule when updated trigger', () => { const newTrigger = {}; - rulesService.setup(x => x.putRule(app, oldRules[0].id, It.isAny(), version)) - .returns(() => of(versioned(newVersion))).verifiable(); + const updated = createRule(1, 'new'); - rulesState.updateTrigger(oldRules[0], newTrigger, modified).subscribe(); + rulesService.setup(x => x.putRule(app, rule1, It.isAny(), version)) + .returns(() => of(updated)).verifiable(); - const rule_1 = rulesState.snapshot.rules.at(0); + rulesState.updateTrigger(rule1, newTrigger).subscribe(); - expect(rule_1.trigger).toBe(newTrigger); - expectToBeModified(rule_1); + const rule1New = rulesState.snapshot.rules.at(0); + + expect(rule1New).toEqual(updated); }); - it('should mark as enabled and update and user info when enabled', () => { - rulesService.setup(x => x.enableRule(app, oldRules[0].id, version)) - .returns(() => of(versioned(newVersion))).verifiable(); + it('should update rule when enabled', () => { + const updated = createRule(1, 'new'); + + rulesService.setup(x => x.enableRule(app, rule1, version)) + .returns(() => of(updated)).verifiable(); - rulesState.enable(oldRules[0], modified).subscribe(); + rulesState.enable(rule1).subscribe(); - const rule_1 = rulesState.snapshot.rules.at(0); + const rule1New = rulesState.snapshot.rules.at(0); - expect(rule_1.isEnabled).toBeTruthy(); - expectToBeModified(rule_1); + expect(rule1New).toEqual(updated); }); - it('should mark as disabled and update and user info when disabled', () => { - rulesService.setup(x => x.disableRule(app, oldRules[1].id, version)) - .returns(() => of(versioned(newVersion))).verifiable(); + it('should update rule when disabled', () => { + const updated = createRule(1, 'new'); + + rulesService.setup(x => x.disableRule(app, rule1, version)) + .returns(() => of(updated)).verifiable(); - rulesState.disable(oldRules[1], modified).subscribe(); + rulesState.disable(rule1).subscribe(); - const rule_1 = rulesState.snapshot.rules.at(1); + const rule1New = rulesState.snapshot.rules.at(0); - expect(rule_1.isEnabled).toBeFalsy(); - expectToBeModified(rule_1); + expect(rule1New).toEqual(updated); }); it('should remove rule from snapshot when deleted', () => { - rulesService.setup(x => x.deleteRule(app, oldRules[0].id, version)) + rulesService.setup(x => x.deleteRule(app, rule1, version)) .returns(() => of(versioned(newVersion))).verifiable(); - rulesState.delete(oldRules[0]).subscribe(); + rulesState.delete(rule1).subscribe(); - expect(rulesState.snapshot.rules.values).toEqual([oldRules[1]]); + expect(rulesState.snapshot.rules.values).toEqual([rule2]); }); - - function expectToBeModified(rule_1: RuleDto) { - expect(rule_1.lastModified).toEqual(modified); - expect(rule_1.lastModifiedBy).toEqual(modifier); - expect(rule_1.version).toEqual(newVersion); - } }); }); \ No newline at end of file diff --git a/src/Squidex/app/shared/state/rules.state.ts b/src/Squidex/app/shared/state/rules.state.ts index 1a0bcac0f..2412433df 100644 --- a/src/Squidex/app/shared/state/rules.state.ts +++ b/src/Squidex/app/shared/state/rules.state.ts @@ -10,20 +10,16 @@ import { Observable } from 'rxjs'; import { distinctUntilChanged, map, tap } from 'rxjs/operators'; import { - DateTime, DialogService, ImmutableArray, + ResourceLinks, shareSubscribed, - State, - Version, - Versioned + State } from '@app/framework'; -import { AuthService} from './../services/auth.service'; import { AppsState } from './apps.state'; import { - RuleCreatedDto, RuleDto, RulesService, UpsertRuleDto @@ -33,6 +29,9 @@ interface Snapshot { // The current rules. rules: RulesList; + // The resource links. + links: ResourceLinks; + // Indicates if the rules are loaded. isLoaded?: boolean; } @@ -49,13 +48,16 @@ export class RulesState extends State { this.changes.pipe(map(x => !!x.isLoaded), distinctUntilChanged()); + public links = + this.changes.pipe(map(x => x.links), + distinctUntilChanged()); + constructor( private readonly appsState: AppsState, - private readonly authState: AuthService, private readonly dialogs: DialogService, private readonly rulesService: RulesService ) { - super({ rules: ImmutableArray.empty() }); + super({ rules: ImmutableArray.empty(), links: {} }); } public load(isReload = false): Observable { @@ -64,23 +66,22 @@ export class RulesState extends State { } return this.rulesService.getRules(this.appName).pipe( - tap(payload => { + tap(({ items, _links: links }) => { if (isReload) { this.dialogs.notifyInfo('Rules reloaded.'); } this.next(s => { - const rules = ImmutableArray.of(payload); + const rules = ImmutableArray.of(items); - return { ...s, rules, isLoaded: true }; + return { ...s, rules, isLoaded: true, links }; }); }), shareSubscribed(this.dialogs)); } - public create(request: UpsertRuleDto, now?: DateTime): Observable { + public create(request: UpsertRuleDto): Observable { return this.rulesService.postRule(this.appName, request).pipe( - map(payload => createRule(request, payload, this.user, now)), tap(created => { this.next(s => { const rules = s.rules.push(created); @@ -92,7 +93,7 @@ export class RulesState extends State { } public delete(rule: RuleDto): Observable { - return this.rulesService.deleteRule(this.appName, rule.id, rule.version).pipe( + return this.rulesService.deleteRule(this.appName, rule, rule.version).pipe( tap(() => { this.next(s => { const rules = s.rules.removeAll(x => x.id === rule.id); @@ -103,36 +104,32 @@ export class RulesState extends State { shareSubscribed(this.dialogs)); } - public updateAction(rule: RuleDto, action: any, now?: DateTime): Observable { - return this.rulesService.putRule(this.appName, rule.id, { action }, rule.version).pipe( - map(({ version }) => updateAction(rule, action, this.user, version, now)), + public updateAction(rule: RuleDto, action: any): Observable { + return this.rulesService.putRule(this.appName, rule, { action }, rule.version).pipe( tap(updated => { this.replaceRule(updated); }), shareSubscribed(this.dialogs)); } - public updateTrigger(rule: RuleDto, trigger: any, now?: DateTime): Observable { - return this.rulesService.putRule(this.appName, rule.id, { trigger }, rule.version).pipe( - map(({ version }) => updateTrigger(rule, trigger, this.user, version, now)), + public updateTrigger(rule: RuleDto, trigger: any): Observable { + return this.rulesService.putRule(this.appName, rule, { trigger }, rule.version).pipe( tap(updated => { this.replaceRule(updated); }), shareSubscribed(this.dialogs)); } - public enable(rule: RuleDto, now?: DateTime): Observable { - return this.rulesService.enableRule(this.appName, rule.id, rule.version).pipe( - map(({ version }) => setEnabled(rule, true, this.user, version, now)), + public enable(rule: RuleDto): Observable { + return this.rulesService.enableRule(this.appName, rule, rule.version).pipe( tap(updated => { this.replaceRule(updated); }), shareSubscribed(this.dialogs)); } - public disable(rule: RuleDto, now?: DateTime): Observable { - return this.rulesService.disableRule(this.appName, rule.id, rule.version).pipe( - map(({ version }) => setEnabled(rule, false, this.user, version, now)), + public disable(rule: RuleDto): Observable { + return this.rulesService.disableRule(this.appName, rule, rule.version).pipe( tap(updated => { this.replaceRule(updated); }), @@ -150,57 +147,4 @@ export class RulesState extends State { private get appName() { return this.appsState.appName; } - - private get user() { - return this.authState.user!.token; - } -} - -const updateTrigger = (rule: RuleDto, trigger: any, user: string, version: Version, now?: DateTime) => - rule.with({ - trigger, - triggerType: trigger.triggerType, - lastModified: now || DateTime.now(), - lastModifiedBy: user, - version - }); - -const updateAction = (rule: RuleDto, action: any, user: string, version: Version, now?: DateTime) => - rule.with({ - action, - actionType: action.actionType, - lastModified: now || DateTime.now(), - lastModifiedBy: user, - version - }); - -const setEnabled = (rule: RuleDto, isEnabled: boolean, user: string, version: Version, now?: DateTime) => - rule.with({ - isEnabled, - lastModified: now || DateTime.now(), - lastModifiedBy: user, - version - }); - -function createRule(request: UpsertRuleDto, { payload, version }: Versioned, user: string, now?: DateTime) { - now = now || DateTime.now(); - - const { triggerType, ...trigger } = request.trigger; - - const { actionType, ...action } = request.action; - - const rule = new RuleDto( - payload.id, - user, - user, - now, - now, - version, - true, - trigger, - triggerType, - action, - actionType); - - return rule; } \ No newline at end of file diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs index d26a8335c..ee40fbd6e 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetCommandMiddlewareTests.cs @@ -76,9 +76,9 @@ namespace Squidex.Domain.Apps.Entities.Assets var result = context.Result(); - Assert.Equal(assetId, result.IdOrValue); - Assert.Contains("tag1", result.Tags); - Assert.Contains("tag2", result.Tags); + Assert.Equal(assetId, result.Asset.Id); + Assert.Contains("tag1", result.Asset.Tags); + Assert.Contains("tag2", result.Asset.Tags); AssertAssetHasBeenUploaded(0, context.ContextId); AssertAssetImageChecked(); diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs index eb2c0ac26..4f3773f9b 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleGrainTests.cs @@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Rules var result = await sut.ExecuteAsync(CreateRuleCommand(command)); - result.ShouldBeEquivalent(EntityCreatedResult.Create(Id, 0)); + result.ShouldBeEquivalent(sut.Snapshot); Assert.Equal(AppId, sut.Snapshot.AppId.Id); @@ -110,7 +110,7 @@ namespace Squidex.Domain.Apps.Entities.Rules var result = await sut.ExecuteAsync(CreateRuleCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(2)); + result.ShouldBeEquivalent(sut.Snapshot); Assert.True(sut.Snapshot.RuleDef.IsEnabled); @@ -129,7 +129,7 @@ namespace Squidex.Domain.Apps.Entities.Rules var result = await sut.ExecuteAsync(CreateRuleCommand(command)); - result.ShouldBeEquivalent(new EntitySavedResult(1)); + result.ShouldBeEquivalent(sut.Snapshot); Assert.False(sut.Snapshot.RuleDef.IsEnabled); From 7a641431ca050f34f1c50a8d410b715b2a3e65bf Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 13 Jun 2019 12:40:41 +0100 Subject: [PATCH 015/107] Hateos for Backups. --- src/Squidex.Web/PermissionExtensions.cs | 1 - .../Api/Controllers/Apps/Models/AppDto.cs | 2 +- .../Controllers/Backups/BackupsController.cs | 7 +- .../Backups/Models/BackupJobDto.cs | 22 ++++++- .../Backups/Models/BackupJobsDto.cs | 49 ++++++++++++++ .../Controllers/Rules/Models/RuleEventsDto.cs | 1 - .../pages/backups/backups-page.component.html | 5 +- .../shared/services/backups.service.spec.ts | 31 ++++++--- .../app/shared/services/backups.service.ts | 65 ++++++++++++------- .../app/shared/state/backups.state.spec.ts | 19 +++--- src/Squidex/app/shared/state/backups.state.ts | 16 +++-- 11 files changed, 160 insertions(+), 58 deletions(-) create mode 100644 src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs diff --git a/src/Squidex.Web/PermissionExtensions.cs b/src/Squidex.Web/PermissionExtensions.cs index a6751e9e9..4bda25b58 100644 --- a/src/Squidex.Web/PermissionExtensions.cs +++ b/src/Squidex.Web/PermissionExtensions.cs @@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Http; using Squidex.Infrastructure.Security; -using Squidex.Shared; using Squidex.Shared.Identity; namespace Squidex.Web diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs index dbb2f4ded..a41ad2dd0 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs @@ -112,7 +112,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models if (controller.HasPermission(AllPermissions.AppBackupsRead, result.Name, permissions: permissions)) { - result.AddGetLink("backups", controller.Url(x => nameof(x.GetJobs), values)); + result.AddGetLink("backups", controller.Url(x => nameof(x.GetBackups), values)); } if (controller.HasPermission(AllPermissions.AppClientsRead, result.Name, permissions: permissions)) diff --git a/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs b/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs index 0198fa186..e692bcfba 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs @@ -12,7 +12,6 @@ using Microsoft.AspNetCore.Mvc; using Orleans; using Squidex.Areas.Api.Controllers.Backups.Models; using Squidex.Domain.Apps.Entities.Backup; -using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Tasks; using Squidex.Shared; @@ -44,16 +43,16 @@ namespace Squidex.Areas.Api.Controllers.Backups /// [HttpGet] [Route("apps/{app}/backups/")] - [ProducesResponseType(typeof(List), 200)] + [ProducesResponseType(typeof(BackupJobsDto), 200)] [ApiPermission(Permissions.AppBackupsRead)] [ApiCosts(0)] - public async Task GetJobs(string app) + public async Task GetBackups(string app) { var backupGrain = grainFactory.GetGrain(AppId); var jobs = await backupGrain.GetStateAsync(); - var response = jobs.Value.ToArray(BackupJobDto.FromBackup); + var response = BackupJobsDto.FromBackups(jobs.Value, this, app); return Ok(response); } diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs index 475f4b059..8cc2fd455 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobDto.cs @@ -9,10 +9,12 @@ using System; using NodaTime; using Squidex.Domain.Apps.Entities.Backup; using Squidex.Infrastructure.Reflection; +using Squidex.Shared; +using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Backups.Models { - public sealed class BackupJobDto + public sealed class BackupJobDto : Resource { /// /// The id of the backup job. @@ -44,9 +46,23 @@ namespace Squidex.Areas.Api.Controllers.Backups.Models /// public JobStatus Status { get; set; } - public static BackupJobDto FromBackup(IBackupJob backup) + public static BackupJobDto FromBackup(IBackupJob backup, ApiController controller, string app) { - return SimpleMapper.Map(backup, new BackupJobDto()); + var result = SimpleMapper.Map(backup, new BackupJobDto()); + + return CreateLinks(result, controller, app); + } + + private static BackupJobDto CreateLinks(BackupJobDto result, ApiController controller, string app) + { + var values = new { app, id = result.Id }; + + if (controller.HasPermission(Permissions.AppBackupsDelete, app)) + { + result.AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteBackup), values)); + } + + return result; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs new file mode 100644 index 000000000..33c88bc25 --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Backups/Models/BackupJobsDto.cs @@ -0,0 +1,49 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Squidex.Domain.Apps.Entities.Backup; +using Squidex.Shared; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Backups.Models +{ + public sealed class BackupJobsDto : Resource + { + /// + /// The backups. + /// + [Required] + public BackupJobDto[] Items { get; set; } + + public static BackupJobsDto FromBackups(IEnumerable backups, ApiController controller, string app) + { + var result = new BackupJobsDto + { + Items = backups.Select(x => BackupJobDto.FromBackup(x, controller, app)).ToArray() + }; + + return CreateLinks(result, controller, app); + } + + private static BackupJobsDto CreateLinks(BackupJobsDto result, ApiController controller, string app) + { + var values = new { app }; + + result.AddSelfLink(controller.Url(x => nameof(x.GetBackups), values)); + + if (controller.HasPermission(Permissions.AppBackupsCreate, app)) + { + result.AddPostLink("create", controller.Url(x => nameof(x.PostBackup), values)); + } + + return result; + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs index add2af47f..83b6d4c6b 100644 --- a/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Rules/Models/RuleEventsDto.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; diff --git a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html index dddcf8cf6..ca99931ff 100644 --- a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html +++ b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html @@ -12,7 +12,7 @@ - @@ -78,7 +78,8 @@
-
- + - {{userInfo.user.displayName}} + {{user.displayName}} - {{userInfo.user.email}} + {{user.email}} - - - - - - - - + +
- - - - diff --git a/src/Squidex/app/features/administration/pages/users/user-page.component.ts b/src/Squidex/app/features/administration/pages/users/user-page.component.ts index 0abdd7eed..ee7498713 100644 --- a/src/Squidex/app/features/administration/pages/users/user-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/user-page.component.ts @@ -29,8 +29,6 @@ export class UserPageComponent extends ResourceOwner implements OnInit { public user?: UserDto; public userForm = new UserForm(this.formBuilder); - public isReadOnly = false; - constructor( public readonly usersState: UsersState, private readonly formBuilder: FormBuilder, @@ -49,9 +47,9 @@ export class UserPageComponent extends ResourceOwner implements OnInit { if (selectedUser) { this.userForm.load(selectedUser); - this.isReadOnly = !hasLink(this.user, 'update'); + this.canUpdate = hasLink(this.user, 'update'); - if (this.isReadOnly) { + if (!this.canUpdate) { this.userForm.form.disable(); } } @@ -59,7 +57,7 @@ export class UserPageComponent extends ResourceOwner implements OnInit { } public save() { - if (this.isReadOnly) { + if (!this.canUpdate) { return; } diff --git a/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts b/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts index ba384f5d1..2a4c32c14 100644 --- a/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts +++ b/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts @@ -79,7 +79,7 @@ describe('EventConsumersState', () => { eventConsumersState.load().subscribe(); }); - it('should update evnet consumer when started', () => { + it('should update event consumer when started', () => { const updated = createEventConsumer(2, '_new'); eventConsumersService.setup(x => x.putStart(eventConsumer2)) diff --git a/src/Squidex/app/features/administration/state/users.state.spec.ts b/src/Squidex/app/features/administration/state/users.state.spec.ts index 3000bf6f6..279ff67c2 100644 --- a/src/Squidex/app/features/administration/state/users.state.spec.ts +++ b/src/Squidex/app/features/administration/state/users.state.spec.ts @@ -168,7 +168,7 @@ describe('UsersState', () => { expect(usersState.snapshot.selectedUser).toBeNull(); }); - it('should update user selected user when locked', () => { + it('should update user and selected user when locked', () => { const updated = createUser(2, '_new'); usersService.setup(x => x.lockUser(user2)) @@ -177,9 +177,9 @@ describe('UsersState', () => { usersState.select(user2.id).subscribe(); usersState.lock(user2).subscribe(); - const newUser2 = usersState.snapshot.users.at(1); + const user2New = usersState.snapshot.users.at(1); - expect(newUser2).toBe(usersState.snapshot.selectedUser!); + expect(user2New).toBe(usersState.snapshot.selectedUser!); }); it('should update user and selected user when unlocked', () => { @@ -191,10 +191,10 @@ describe('UsersState', () => { usersState.select(user2.id).subscribe(); usersState.unlock(user2).subscribe(); - const newUser2 = usersState.snapshot.users.at(1); + const user2New = usersState.snapshot.users.at(1); - expect(newUser2).toEqual(updated); - expect(newUser2).toBe(usersState.snapshot.selectedUser!); + expect(user2New).toEqual(updated); + expect(user2New).toBe(usersState.snapshot.selectedUser!); }); it('should update user and selected user when updated', () => { @@ -208,10 +208,10 @@ describe('UsersState', () => { usersState.select(user2.id).subscribe(); usersState.update(user2, request).subscribe(); - const newUser2 = usersState.snapshot.users.at(1); + const user2New = usersState.snapshot.users.at(1); - expect(newUser2).toEqual(updated); - expect(newUser2).toBe(usersState.snapshot.selectedUser!); + expect(user2New).toEqual(updated); + expect(user2New).toBe(usersState.snapshot.selectedUser!); }); it('should add user to snapshot when created', () => { diff --git a/src/Squidex/app/features/administration/state/users.state.ts b/src/Squidex/app/features/administration/state/users.state.ts index 04a4bc80a..d638c5e0c 100644 --- a/src/Squidex/app/features/administration/state/users.state.ts +++ b/src/Squidex/app/features/administration/state/users.state.ts @@ -60,10 +60,6 @@ export class UsersState extends State { this.changes.pipe(map(x => x.usersPager), distinctUntilChanged()); - public links = - this.changes.pipe(map(x => x.links), - distinctUntilChanged()); - public selectedUser = this.changes.pipe(map(x => x.selectedUser), distinctUntilChanged()); @@ -72,6 +68,10 @@ export class UsersState extends State { this.changes.pipe(map(x => !!x.isLoaded), distinctUntilChanged()); + public links = + this.changes.pipe(map(x => x.links), + distinctUntilChanged()); + constructor( private readonly dialogs: DialogService, private readonly usersService: UsersService @@ -131,7 +131,7 @@ export class UsersState extends State { selectedUser = users.find(x => x.id === selectedUser!.id) || selectedUser; } - return { ...s, users, usersPager, links, selectedUser, isLoaded: true }; + return { ...s, users, usersPager, selectedUser, isLoaded: true, links }; }); }), shareSubscribed(this.dialogs)); diff --git a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html index 422b0a98f..ecb11c85b 100644 --- a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html +++ b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html @@ -106,12 +106,12 @@ - + - + diff --git a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts index c9b1a77a2..fd7dcd668 100644 --- a/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts +++ b/src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts @@ -5,11 +5,12 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { AfterViewInit, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { FormGroup } from '@angular/forms'; import { Form, + hasLink, ImmutableArray, RuleDto, RuleElementDto, @@ -26,7 +27,7 @@ export const MODE_EDIT_ACTION = 'EditAction'; styleUrls: ['./rule-wizard.component.scss'], templateUrl: './rule-wizard.component.html' }) -export class RuleWizardComponent implements OnInit { +export class RuleWizardComponent implements AfterViewInit, OnInit { public actionForm = new Form(new FormGroup({})); public actionType: string; public action: any = {}; @@ -35,6 +36,8 @@ export class RuleWizardComponent implements OnInit { public triggerType: string; public trigger: any = {}; + public canUpdate: boolean; + public step = 1; @Output() @@ -61,6 +64,8 @@ export class RuleWizardComponent implements OnInit { } public ngOnInit() { + this.canUpdate = !this.rule || hasLink(this.rule, 'update'); + if (this.mode === MODE_EDIT_ACTION) { this.step = 4; @@ -74,6 +79,14 @@ export class RuleWizardComponent implements OnInit { } } + public ngAfterViewInit() { + if (!this.canUpdate) { + this.actionForm.form.disable(); + + this.triggerForm.form.disable(); + } + } + public emitComplete() { this.complete.emit(); } @@ -132,6 +145,10 @@ export class RuleWizardComponent implements OnInit { } private updateTrigger() { + if (!this.canUpdate) { + return; + } + this.rulesState.updateTrigger(this.rule, this.trigger) .subscribe(() => { this.emitComplete(); @@ -143,6 +160,10 @@ export class RuleWizardComponent implements OnInit { } private updateAction() { + if (!this.canUpdate) { + return; + } + this.rulesState.updateAction(this.rule, this.action) .subscribe(() => { this.emitComplete(); diff --git a/src/Squidex/app/features/rules/pages/rules/rules-page.component.html b/src/Squidex/app/features/rules/pages/rules/rules-page.component.html index d6102c1be..0e97c61f2 100644 --- a/src/Squidex/app/features/rules/pages/rules/rules-page.component.html +++ b/src/Squidex/app/features/rules/pages/rules/rules-page.component.html @@ -6,16 +6,19 @@ - - - + + + + + @@ -23,7 +26,7 @@
No rule created yet. -
@@ -48,10 +51,11 @@
- + -
- + @@ -47,7 +50,7 @@
- + - {{contributorInfo.contributor.contributorId | sqxUserName}} + {{contributor.contributorId | sqxUserName}} - -
-