Browse Source

Pagination for contributors. (#408)

* Pagination for contributors.
pull/407/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
a85d49c25c
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      src/Squidex.Domain.Users/DefaultUserResolver.cs
  2. 9
      src/Squidex.Domain.Users/UserManagerExtensions.cs
  3. 3
      src/Squidex.Shared/Users/IUserResolver.cs
  4. 9
      src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs
  5. 19
      src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
  6. 9
      src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs
  7. 9
      src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs
  8. 9
      src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs
  9. 9
      src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs
  10. 6
      src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguageDto.cs
  11. 6
      src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguagesDto.cs
  12. 6
      src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs
  13. 10
      src/Squidex/Areas/Api/Controllers/Apps/Models/ClientsDto.cs
  14. 30
      src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs
  15. 38
      src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs
  16. 20
      src/Squidex/Areas/Api/Controllers/Apps/Models/RoleDto.cs
  17. 11
      src/Squidex/Areas/Api/Controllers/Apps/Models/RolesDto.cs
  18. 9
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs
  19. 10
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs
  20. 9
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs
  21. 8
      src/Squidex/app/features/administration/services/event-consumers.service.spec.ts
  22. 72
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html
  23. 12
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts
  24. 16
      src/Squidex/app/framework/angular/forms/dropdown.component.ts
  25. 10
      src/Squidex/app/framework/angular/highlight.pipe.ts
  26. 8
      src/Squidex/app/shared/services/assets.service.spec.ts
  27. 8
      src/Squidex/app/shared/services/contents.service.spec.ts
  28. 6
      src/Squidex/app/shared/services/contributors.service.spec.ts
  29. 2
      src/Squidex/app/shared/services/contributors.service.ts
  30. 8
      src/Squidex/app/shared/services/rules.service.spec.ts
  31. 16
      src/Squidex/app/shared/services/schemas.service.spec.ts
  32. 8
      src/Squidex/app/shared/state/clients.state.ts
  33. 2
      src/Squidex/app/shared/state/contents.state.ts
  34. 77
      src/Squidex/app/shared/state/contributors.state.spec.ts
  35. 73
      src/Squidex/app/shared/state/contributors.state.ts
  36. 7
      src/Squidex/app/shared/state/rules.state.ts
  37. 8
      src/Squidex/app/theme/_lists.scss
  38. 31
      tools/LoadTest/TestUtils.cs
  39. 1
      tools/LoadTest/WritingBenchmarks.cs

8
src/Squidex.Domain.Users/DefaultUserResolver.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -69,5 +70,12 @@ namespace Squidex.Domain.Users
return result.OfType<IUser>().ToList(); return result.OfType<IUser>().ToList();
} }
public async Task<Dictionary<string, IUser>> QueryManyAsync(string[] ids)
{
var result = await userManager.QueryByIdsAync(ids);
return result.OfType<IUser>().ToDictionary(x => x.Id);
}
} }
} }

9
src/Squidex.Domain.Users/UserManagerExtensions.cs

@ -85,6 +85,15 @@ namespace Squidex.Domain.Users
return Task.FromResult(count); return Task.FromResult(count);
} }
public static async Task<List<UserWithClaims>> QueryByIdsAync(this UserManager<IdentityUser> userManager, string[] ids)
{
var users = userManager.Users.Where(x => ids.Contains(x.Id)).ToList();
var result = await userManager.ResolveUsersAsync(users);
return result.ToList();
}
public static async Task<List<UserWithClaims>> QueryByEmailAsync(this UserManager<IdentityUser> userManager, string email = null, int take = 10, int skip = 0) public static async Task<List<UserWithClaims>> QueryByEmailAsync(this UserManager<IdentityUser> userManager, string email = null, int take = 10, int skip = 0)
{ {
var users = QueryUsers(userManager, email).Skip(skip).Take(take).ToList(); var users = QueryUsers(userManager, email).Skip(skip).Take(take).ToList();

3
src/Squidex.Shared/Users/IUserResolver.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
@ -17,5 +18,7 @@ namespace Squidex.Shared.Users
Task<IUser> FindByIdOrEmailAsync(string idOrEmail); Task<IUser> FindByIdOrEmailAsync(string idOrEmail);
Task<List<IUser>> QueryByEmailAsync(string email); Task<List<IUser>> QueryByEmailAsync(string email);
Task<Dictionary<string, IUser>> QueryManyAsync(string[] ids);
} }
} }

9
src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs

@ -48,7 +48,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
{ {
var response = Deferred.Response(() => var response = Deferred.Response(() =>
{ {
return ClientsDto.FromApp(App, this); return GetResponse(App);
}); });
Response.Headers[HeaderNames.ETag] = App.ToEtag(); Response.Headers[HeaderNames.ETag] = App.ToEtag();
@ -143,9 +143,14 @@ namespace Squidex.Areas.Api.Controllers.Apps
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<IAppEntity>(); var result = context.Result<IAppEntity>();
var response = ClientsDto.FromApp(result, this); var response = GetResponse(result);
return response; return response;
} }
private ClientsDto GetResponse(IAppEntity app)
{
return ClientsDto.FromApp(App, this);
}
} }
} }

19
src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs

@ -15,6 +15,7 @@ using Squidex.Domain.Apps.Entities.Apps.Invitation;
using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Domain.Apps.Entities.Apps.Services;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Shared.Users;
using Squidex.Web; using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Apps namespace Squidex.Areas.Api.Controllers.Apps
@ -26,11 +27,14 @@ namespace Squidex.Areas.Api.Controllers.Apps
public sealed class AppContributorsController : ApiController public sealed class AppContributorsController : ApiController
{ {
private readonly IAppPlansProvider appPlansProvider; private readonly IAppPlansProvider appPlansProvider;
private readonly IUserResolver userResolver;
public AppContributorsController(ICommandBus commandBus, IAppPlansProvider appPlansProvider) public AppContributorsController(ICommandBus commandBus, IAppPlansProvider appPlansProvider, IUserResolver userResolver)
: base(commandBus) : base(commandBus)
{ {
this.appPlansProvider = appPlansProvider; this.appPlansProvider = appPlansProvider;
this.userResolver = userResolver;
} }
/// <summary> /// <summary>
@ -48,9 +52,9 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiCosts(0)] [ApiCosts(0)]
public IActionResult GetContributors(string app) public IActionResult GetContributors(string app)
{ {
var response = Deferred.Response(() => var response = Deferred.AsyncResponse(() =>
{ {
return ContributorsDto.FromApp(App, appPlansProvider, this, false); return GetResponseAsync(App, false);
}); });
Response.Headers[HeaderNames.ETag] = App.ToEtag(); Response.Headers[HeaderNames.ETag] = App.ToEtag();
@ -112,12 +116,17 @@ namespace Squidex.Areas.Api.Controllers.Apps
if (context.PlainResult is InvitedResult invited) if (context.PlainResult is InvitedResult invited)
{ {
return ContributorsDto.FromApp(invited.App, appPlansProvider, this, true); return await GetResponseAsync(invited.App, true);
} }
else else
{ {
return ContributorsDto.FromApp(context.Result<IAppEntity>(), appPlansProvider, this, false); return await GetResponseAsync(context.Result<IAppEntity>(), false);
} }
} }
private Task<ContributorsDto> GetResponseAsync(IAppEntity app, bool invited)
{
return ContributorsDto.FromAppAsync(app, this, userResolver, appPlansProvider, invited);
}
} }
} }

9
src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs

@ -47,7 +47,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
{ {
var response = Deferred.Response(() => var response = Deferred.Response(() =>
{ {
return AppLanguagesDto.FromApp(App, this); return GetResponse(App);
}); });
Response.Headers[HeaderNames.ETag] = App.ToEtag(); Response.Headers[HeaderNames.ETag] = App.ToEtag();
@ -132,11 +132,16 @@ namespace Squidex.Areas.Api.Controllers.Apps
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<IAppEntity>(); var result = context.Result<IAppEntity>();
var response = AppLanguagesDto.FromApp(result, this); var response = GetResponse(result);
return response; return response;
} }
private AppLanguagesDto GetResponse(IAppEntity result)
{
return AppLanguagesDto.FromApp(result, this);
}
private static Language ParseLanguage(string language) private static Language ParseLanguage(string language)
{ {
try try

9
src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs

@ -49,7 +49,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
{ {
var response = Deferred.Response(() => var response = Deferred.Response(() =>
{ {
return PatternsDto.FromApp(App, this); return GetResponse(App);
}); });
Response.Headers[HeaderNames.ETag] = App.ToEtag(); Response.Headers[HeaderNames.ETag] = App.ToEtag();
@ -137,9 +137,14 @@ namespace Squidex.Areas.Api.Controllers.Apps
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<IAppEntity>(); var result = context.Result<IAppEntity>();
var response = PatternsDto.FromApp(result, this); var response = GetResponse(result);
return response; return response;
} }
private PatternsDto GetResponse(IAppEntity result)
{
return PatternsDto.FromApp(result, this);
}
} }
} }

9
src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs

@ -49,7 +49,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
{ {
var response = Deferred.Response(() => var response = Deferred.Response(() =>
{ {
return RolesDto.FromApp(App, this); return GetResponse(App);
}); });
Response.Headers[HeaderNames.ETag] = App.ToEtag(); Response.Headers[HeaderNames.ETag] = App.ToEtag();
@ -160,9 +160,14 @@ namespace Squidex.Areas.Api.Controllers.Apps
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<IAppEntity>(); var result = context.Result<IAppEntity>();
var response = RolesDto.FromApp(result, this); var response = GetResponse(result);
return response; return response;
} }
private RolesDto GetResponse(IAppEntity result)
{
return RolesDto.FromApp(result, this);
}
} }
} }

9
src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs

@ -50,7 +50,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
{ {
var response = Deferred.AsyncResponse(() => var response = Deferred.AsyncResponse(() =>
{ {
return WorkflowsDto.FromAppAsync(workflowsValidator, App, this); return GetResponse(App);
}); });
Response.Headers[HeaderNames.ETag] = App.ToEtag(); Response.Headers[HeaderNames.ETag] = App.ToEtag();
@ -135,9 +135,14 @@ namespace Squidex.Areas.Api.Controllers.Apps
var context = await CommandBus.PublishAsync(command); var context = await CommandBus.PublishAsync(command);
var result = context.Result<IAppEntity>(); var result = context.Result<IAppEntity>();
var response = await WorkflowsDto.FromAppAsync(workflowsValidator, result, this); var response = await GetResponse(result);
return response; return response;
} }
private async Task<WorkflowsDto> GetResponse(IAppEntity result)
{
return await WorkflowsDto.FromAppAsync(workflowsValidator, result, this);
}
} }
} }

6
src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguageDto.cs

@ -46,7 +46,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// </summary> /// </summary>
public bool IsOptional { get; set; } public bool IsOptional { get; set; }
public static AppLanguageDto FromLanguage(LanguageConfig language, IAppEntity app, ApiController controller) public static AppLanguageDto FromLanguage(LanguageConfig language, IAppEntity app)
{ {
var result = SimpleMapper.Map(language.Language, var result = SimpleMapper.Map(language.Language,
new AppLanguageDto new AppLanguageDto
@ -56,10 +56,10 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
Fallback = language.LanguageFallbacks.ToArray() Fallback = language.LanguageFallbacks.ToArray()
}); });
return result.CreateLinks(controller, app); return result;
} }
private AppLanguageDto CreateLinks(ApiController controller, IAppEntity app) public AppLanguageDto WithLinks(ApiController controller, IAppEntity app)
{ {
var values = new { app = app.Name, language = Iso2Code }; var values = new { app = app.Name, language = Iso2Code };

6
src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguagesDto.cs

@ -27,9 +27,9 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
var result = new AppLanguagesDto var result = new AppLanguagesDto
{ {
Items = app.LanguagesConfig.OfType<LanguageConfig>() Items = app.LanguagesConfig.OfType<LanguageConfig>()
.Select(x => AppLanguageDto.FromLanguage(x, app, controller)) .Select(x => AppLanguageDto.FromLanguage(x, app))
.OrderByDescending(x => x.IsMaster) .Select(x => x.WithLinks(controller, app))
.ThenBy(x => x.Iso2Code) .OrderByDescending(x => x.IsMaster).ThenBy(x => x.Iso2Code)
.ToArray() .ToArray()
}; };

6
src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs

@ -38,14 +38,14 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// </summary> /// </summary>
public string Role { get; set; } public string Role { get; set; }
public static ClientDto FromClient(string id, AppClient client, ApiController controller, string app) public static ClientDto FromClient(string id, AppClient client)
{ {
var result = SimpleMapper.Map(client, new ClientDto { Id = id }); var result = SimpleMapper.Map(client, new ClientDto { Id = id });
return result.CreateLinks(controller, app); return result;
} }
private ClientDto CreateLinks(ApiController controller, string app) public ClientDto WithLinks(ApiController controller, string app)
{ {
var values = new { app, id = Id }; var values = new { app, id = Id };

10
src/Squidex/Areas/Api/Controllers/Apps/Models/ClientsDto.cs

@ -23,12 +23,18 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
public static ClientsDto FromApp(IAppEntity app, ApiController controller) public static ClientsDto FromApp(IAppEntity app, ApiController controller)
{ {
var appName = app.Name;
var result = new ClientsDto var result = new ClientsDto
{ {
Items = app.Clients.Select(x => ClientDto.FromClient(x.Key, x.Value, controller, app.Name)).ToArray() Items =
app.Clients
.Select(x => ClientDto.FromClient(x.Key, x.Value))
.Select(x => x.WithLinks(controller, appName))
.ToArray()
}; };
return result.CreateLinks(controller, app.Name); return result.CreateLinks(controller, appName);
} }
private ClientsDto CreateLinks(ApiController controller, string app) private ClientsDto CreateLinks(ApiController controller, string app)

30
src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs

@ -5,33 +5,57 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Shared.Users;
using Squidex.Web; using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Apps.Models namespace Squidex.Areas.Api.Controllers.Apps.Models
{ {
public sealed class ContributorDto : Resource public sealed class ContributorDto : Resource
{ {
private const string NotFound = "- not found -";
/// <summary> /// <summary>
/// The id of the user that contributes to the app. /// The id of the user that contributes to the app.
/// </summary> /// </summary>
[Required] [Required]
public string ContributorId { get; set; } public string ContributorId { get; set; }
/// <summary>
/// The display name.
/// </summary>
[Required]
public string ContributorName { get; set; }
/// <summary> /// <summary>
/// The role of the contributor. /// The role of the contributor.
/// </summary> /// </summary>
public string Role { get; set; } public string Role { get; set; }
public static ContributorDto FromIdAndRole(string id, string role, ApiController controller, string app) public static ContributorDto FromIdAndRole(string id, string role)
{ {
var result = new ContributorDto { ContributorId = id, Role = role }; var result = new ContributorDto { ContributorId = id, Role = role };
return result.CreateLinks(controller, app); return result;
}
public ContributorDto WithUser(IDictionary<string, IUser> users)
{
if (users.TryGetValue(ContributorId, out var user))
{
ContributorName = user.DisplayName();
}
else
{
ContributorName = NotFound;
}
return this;
} }
private ContributorDto CreateLinks(ApiController controller, string app) public ContributorDto WithLinks(ApiController controller, string app)
{ {
if (!controller.IsUser(ContributorId)) if (!controller.IsUser(ContributorId))
{ {

38
src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs

@ -5,12 +5,15 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Threading.Tasks;
using Newtonsoft.Json; using Newtonsoft.Json;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Domain.Apps.Entities.Apps.Services;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Shared.Users;
using Squidex.Web; using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Apps.Models namespace Squidex.Areas.Api.Controllers.Apps.Models
@ -29,29 +32,50 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
public int MaxContributors { get; set; } public int MaxContributors { get; set; }
/// <summary> /// <summary>
/// The metadata. /// The metadata to provide information about this request.
/// </summary> /// </summary>
[JsonProperty("_meta")] [JsonProperty("_meta")]
public ContributorsMetadata Metadata { get; set; } public ContributorsMetadata Metadata { get; set; }
public static ContributorsDto FromApp(IAppEntity app, IAppPlansProvider plans, ApiController controller, bool isInvited) public static async Task<ContributorsDto> FromAppAsync(IAppEntity app, ApiController controller, IUserResolver userResolver, IAppPlansProvider plans, bool invited)
{ {
var users = await userResolver.QueryManyAsync(app.Contributors.Keys.ToArray());
var result = new ContributorsDto var result = new ContributorsDto
{ {
Items = app.Contributors.Select(x => ContributorDto.FromIdAndRole(x.Key, x.Value, controller, app.Name)).ToArray(), Items =
app.Contributors
.Select(x => ContributorDto.FromIdAndRole(x.Key, x.Value))
.Select(x => x.WithUser(users))
.Select(x => x.WithLinks(controller, app.Name))
.OrderBy(x => x.ContributorName)
.ToArray()
}; };
result.WithInvited(invited);
result.WithPlan(app, plans);
return result.CreateLinks(controller, app.Name);
}
private ContributorsDto WithPlan(IAppEntity app, IAppPlansProvider plans)
{
MaxContributors = plans.GetPlanForApp(app).MaxContributors;
return this;
}
private ContributorsDto WithInvited(bool isInvited)
{
if (isInvited) if (isInvited)
{ {
result.Metadata = new ContributorsMetadata Metadata = new ContributorsMetadata
{ {
IsInvited = isInvited.ToString() IsInvited = isInvited.ToString()
}; };
} }
result.MaxContributors = plans.GetPlanForApp(app).MaxContributors; return this;
return result.CreateLinks(controller, app.Name);
} }
private ContributorsDto CreateLinks(ApiController controller, string app) private ContributorsDto CreateLinks(ApiController controller, string app)

20
src/Squidex/Areas/Api/Controllers/Apps/Models/RoleDto.cs

@ -44,23 +44,33 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
[Required] [Required]
public IEnumerable<string> Permissions { get; set; } public IEnumerable<string> Permissions { get; set; }
public static RoleDto FromRole(Role role, IAppEntity app, ApiController controller) public static RoleDto FromRole(Role role, IAppEntity app)
{ {
var permissions = role.Permissions.WithoutApp(app.Name); var permissions = role.Permissions.WithoutApp(app.Name);
var result = new RoleDto var result = new RoleDto
{ {
Name = role.Name, Name = role.Name,
NumClients = app.Clients.Count(x => Role.IsRole(x.Value.Role, role.Name)), NumClients = GetNumClients(role, app),
NumContributors = app.Contributors.Count(x => Role.IsRole(x.Value, role.Name)), NumContributors = GetNumContributors(role, app),
Permissions = permissions.ToIds(), Permissions = permissions.ToIds(),
IsDefaultRole = Role.IsDefaultRole(role.Name) IsDefaultRole = Role.IsDefaultRole(role.Name)
}; };
return result.CreateLinks(controller, app.Name); return result;
} }
private RoleDto CreateLinks(ApiController controller, string app) private static int GetNumContributors(Role role, IAppEntity app)
{
return app.Contributors.Count(x => Role.IsRole(x.Value, role.Name));
}
private static int GetNumClients(Role role, IAppEntity app)
{
return app.Clients.Count(x => Role.IsRole(x.Value.Role, role.Name));
}
public RoleDto WithLinks(ApiController controller, string app)
{ {
var values = new { app, name = Name }; var values = new { app, name = Name };

11
src/Squidex/Areas/Api/Controllers/Apps/Models/RolesDto.cs

@ -23,12 +23,19 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
public static RolesDto FromApp(IAppEntity app, ApiController controller) public static RolesDto FromApp(IAppEntity app, ApiController controller)
{ {
var appName = app.Name;
var result = new RolesDto var result = new RolesDto
{ {
Items = app.Roles.Values.Select(x => RoleDto.FromRole(x, app, controller)).OrderBy(x => x.Name).ToArray() Items =
app.Roles.Values
.Select(x => RoleDto.FromRole(x, app))
.Select(x => x.WithLinks(controller, appName))
.OrderBy(x => x.Name)
.ToArray()
}; };
return result.CreateLinks(controller, app.Name); return result.CreateLinks(controller, appName);
} }
private RolesDto CreateLinks(ApiController controller, string app) private RolesDto CreateLinks(ApiController controller, string app)

9
src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs

@ -10,6 +10,7 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Web; using Squidex.Web;
@ -44,7 +45,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// </summary> /// </summary>
public Status Initial { get; set; } public Status Initial { get; set; }
public static WorkflowDto FromWorkflow(Guid id, Workflow workflow, ApiController controller, string app) public static WorkflowDto FromWorkflow(Guid id, Workflow workflow)
{ {
var result = SimpleMapper.Map(workflow, new WorkflowDto var result = SimpleMapper.Map(workflow, new WorkflowDto
{ {
@ -54,12 +55,12 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
Id = id Id = id
}); });
return result.CreateLinks(controller, app, id); return result;
} }
private WorkflowDto CreateLinks(ApiController controller, string app, Guid id) public WorkflowDto WithLinks(ApiController controller, string app)
{ {
var values = new { app, id }; var values = new { app, id = Id };
if (controller.HasPermission(Permissions.AppWorkflowsUpdate, app)) if (controller.HasPermission(Permissions.AppWorkflowsUpdate, app))
{ {

10
src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs

@ -31,16 +31,22 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
public static async Task<WorkflowsDto> FromAppAsync(IWorkflowsValidator workflowsValidator, IAppEntity app, ApiController controller) public static async Task<WorkflowsDto> FromAppAsync(IWorkflowsValidator workflowsValidator, IAppEntity app, ApiController controller)
{ {
var appName = app.Name;
var result = new WorkflowsDto var result = new WorkflowsDto
{ {
Items = app.Workflows.Select(x => WorkflowDto.FromWorkflow(x.Key, x.Value, controller, app.Name)).ToArray(), Items =
app.Workflows
.Select(x => WorkflowDto.FromWorkflow(x.Key, x.Value))
.Select(x => x.WithLinks(controller, appName))
.ToArray()
}; };
var errors = await workflowsValidator.ValidateAsync(app.Id, app.Workflows); var errors = await workflowsValidator.ValidateAsync(app.Id, app.Workflows);
result.Errors = errors.ToArray(); result.Errors = errors.ToArray();
return result.CreateLinks(controller, app.Name); return result.CreateLinks(controller, appName);
} }
private WorkflowsDto CreateLinks(ApiController controller, string app) private WorkflowsDto CreateLinks(ApiController controller, string app)

9
src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs

@ -36,8 +36,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
[Required] [Required]
public StatusInfoDto[] Statuses { get; set; } public StatusInfoDto[] Statuses { get; set; }
public static async Task<ContentsDto> FromContentsAsync(IResultList<IEnrichedContentEntity> contents, public static async Task<ContentsDto> FromContentsAsync(IResultList<IEnrichedContentEntity> contents, Context context, ApiController controller, ISchemaEntity schema, IContentWorkflow workflow)
Context context, ApiController controller, ISchemaEntity schema, IContentWorkflow contentWorkflow)
{ {
var result = new ContentsDto var result = new ContentsDto
{ {
@ -45,14 +44,14 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
Items = contents.Select(x => ContentDto.FromContent(context, x, controller)).ToArray() Items = contents.Select(x => ContentDto.FromContent(context, x, controller)).ToArray()
}; };
await result.AssignStatusesAsync(contentWorkflow, schema); await result.AssignStatusesAsync(workflow, schema);
return result.CreateLinks(controller, schema.AppId.Name, schema.SchemaDef.Name); return result.CreateLinks(controller, schema.AppId.Name, schema.SchemaDef.Name);
} }
private async Task AssignStatusesAsync(IContentWorkflow contentWorkflow, ISchemaEntity schema) private async Task AssignStatusesAsync(IContentWorkflow workflow, ISchemaEntity schema)
{ {
var allStatuses = await contentWorkflow.GetAllAsync(schema); var allStatuses = await workflow.GetAllAsync(schema);
Statuses = allStatuses.Select(StatusInfoDto.FromStatusInfo).ToArray(); Statuses = allStatuses.Select(StatusInfoDto.FromStatusInfo).ToArray();
} }

8
src/Squidex/app/features/administration/services/event-consumers.service.spec.ts

@ -139,10 +139,10 @@ describe('EventConsumersService', () => {
function eventConsumerResponse(id: number) { function eventConsumerResponse(id: number) {
return { return {
name: `event-consumer${id}`, name: `event-consumer${id}`,
position: `position-${id}`, position: `position${id}`,
isStopped: true, isStopped: true,
isResetting: true, isResetting: true,
error: `failure-${id}`, error: `failure${id}`,
_links: { _links: {
reset: { method: 'PUT', href: `/event-consumers/${id}/reset` } reset: { method: 'PUT', href: `/event-consumers/${id}/reset` }
} }
@ -159,6 +159,6 @@ export function createEventConsumer(id: number, suffix = '') {
`event-consumer${id}`, `event-consumer${id}`,
true, true,
true, true,
`failure-${id}${suffix}`, `failure${id}${suffix}`,
`position-${id}${suffix}`); `position${id}${suffix}`);
} }

72
src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html

@ -6,11 +6,17 @@
</ng-container> </ng-container>
<ng-container menu> <ng-container menu>
<button type="button" class="btn btn-text-secondary" (click)="reload()" title="Refresh contributors (CTRL + SHIFT + R)"> <button type="button" class="btn btn-text-secondary mr-2" (click)="reload()" title="Refresh contributors (CTRL + SHIFT + R)">
<i class="icon-reset"></i> Refresh <i class="icon-reset"></i> Refresh
</button> </button>
<sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut> <sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut>
<div class="form-inline">
<input class="form-control" placeholder="Search"
[ngModel]="contributorsState.query | async"
(ngModelChange)="search($event)" />
</div>
</ng-container> </ng-container>
<ng-container content> <ng-container content>
@ -21,33 +27,43 @@
</div> </div>
</ng-container> </ng-container>
<ng-container *ngIf="contributorsState.contributors | async; let contributors"> <ng-container *ngIf="contributorsState.contributorsPaged | async; let contributors">
<table class="table table-items table-fixed" *ngIf="rolesState.roles | async; let roles"> <ng-container *ngIf="contributors.length > 0; else noContributors">
<tbody *ngFor="let contributor of contributors; trackBy: trackByContributor"> <table class="table table-items table-fixed" *ngIf="rolesState.roles | async; let roles">
<tr> <tbody *ngFor="let contributor of contributors; trackBy: trackByContributor">
<td class="cell-user"> <tr>
<img class="user-picture" title="{{contributor.contributorId | sqxUserName}}" [attr.src]="contributor.contributorId | sqxUserPicture" /> <td class="cell-user">
</td> <img class="user-picture" title="{{contributor.contributorName}}" [attr.src]="contributor.contributorId | sqxUserPicture" />
<td class="cell-auto"> </td>
<span class="user-name table-cell">{{contributor.contributorId | sqxUserName}}</span> <td class="cell-auto">
</td> <span class="user-name table-cell" [innerHTML]="contributor.contributorName | sqxHighlight:contributorsState.snapshot.queryRegex"></span>
<td class="cell-time"> </td>
<select class="form-control" <td class="cell-time">
[ngModel]="contributor.role" <select class="form-control"
(ngModelChange)="changeRole(contributor, $event)" [ngModel]="contributor.role"
[disabled]="!contributor.canUpdate"> (ngModelChange)="changeRole(contributor, $event)"
<option *ngFor="let role of roles" [ngValue]="role.name">{{role.name}}</option> [disabled]="!contributor.canUpdate">
</select> <option *ngFor="let role of roles" [ngValue]="role.name">{{role.name}}</option>
</td> </select>
<td class="cell-actions"> </td>
<button type="button" class="btn btn-text-danger" [disabled]="!contributor.canRevoke" (click)="remove(contributor)"> <td class="cell-actions">
<i class="icon-bin2"></i> <button type="button" class="btn btn-text-danger" [disabled]="!contributor.canRevoke" (click)="remove(contributor)">
</button> <i class="icon-bin2"></i>
</td> </button>
</tr> </td>
<tr class="spacer"></tr> </tr>
</tbody> <tr class="spacer"></tr>
</table> </tbody>
</table>
<sqx-pager [pager]="contributorsState.contributorsPager | async" (prevPage)="goPrev()" (nextPage)="goNext()"></sqx-pager>
</ng-container>
<ng-template #noContributors>
<div class="table-items-row table-items-row-empty">
No contributors found.
</div>
</ng-template>
<ng-container *ngIf="contributorsState.canCreate | async"> <ng-container *ngIf="contributorsState.canCreate | async">
<div class="table-items-footer"> <div class="table-items-footer">

12
src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts

@ -78,6 +78,18 @@ export class ContributorsPageComponent implements OnInit {
this.contributorsState.load(true); this.contributorsState.load(true);
} }
public goPrev() {
this.contributorsState.goPrev();
}
public goNext() {
this.contributorsState.goNext();
}
public search(query: string) {
this.contributorsState.search(query);
}
public remove(contributor: ContributorDto) { public remove(contributor: ContributorDto) {
this.contributorsState.revoke(contributor); this.contributorsState.revoke(contributor);
} }

16
src/Squidex/app/framework/angular/forms/dropdown.component.ts

@ -24,7 +24,7 @@ interface State {
suggestedItems: any[]; suggestedItems: any[];
selectedItem: any; selectedItem: any;
selectedIndex: number; selectedIndex: number;
query?: string; query?: RegExp;
} }
@Component({ @Component({
@ -65,19 +65,17 @@ export class DropdownComponent extends StatefulControlComponent<State, any[]> im
public ngOnInit() { public ngOnInit() {
this.own( this.own(
this.queryInput.valueChanges.pipe( this.queryInput.valueChanges.pipe(
map((query: string) => { map((queryText: string) => {
if (!this.items || !query) { if (!this.items || !queryText) {
return { query, items: this.items }; return { query: undefined, items: this.items };
} else { } else {
query = query.trim().toLocaleLowerCase(); const query = new RegExp(queryText, 'i');
const items = this.items.filter(x => { const items = this.items.filter(x => {
if (Types.isString(x)) { if (Types.isString(x)) {
return x.toLocaleLowerCase().indexOf(query) >= 0; return query.test(x);
} else { } else {
const value: string = x[this.searchProperty]; return query.test(x[this.searchProperty]);
return value && value.toLocaleLowerCase().indexOf(query) >= 0;
} }
}); });

10
src/Squidex/app/framework/angular/highlight.pipe.ts

@ -9,16 +9,22 @@
import { Pipe, PipeTransform } from '@angular/core'; import { Pipe, PipeTransform } from '@angular/core';
import { Types } from '@app/framework/internal';
@Pipe({ @Pipe({
name: 'sqxHighlight', name: 'sqxHighlight',
pure: false pure: false
}) })
export class HighlightPipe implements PipeTransform { export class HighlightPipe implements PipeTransform {
public transform(text: string, highlight: string): string { public transform(text: string, highlight: string | RegExp | undefined): string {
if (!highlight) { if (!highlight) {
return text; return text;
} }
return text.replace(new RegExp(highlight, 'i'), s => `<b>${s}</b>`); if (Types.isString(highlight)) {
highlight = new RegExp(highlight, 'i');
}
return text.replace(highlight, s => `<b>${s}</b>`);
} }
} }

8
src/Squidex/app/shared/services/assets.service.spec.ts

@ -308,9 +308,9 @@ describe('AssetsService', () => {
return { return {
id: `id${id}`, id: `id${id}`,
created: `${id % 1000 + 2000}-12-12T10:10:00`, created: `${id % 1000 + 2000}-12-12T10:10:00`,
createdBy: `creator-${id}`, createdBy: `creator${id}`,
lastModified: `${id % 1000 + 2000}-11-11T10:10:00`, lastModified: `${id % 1000 + 2000}-11-11T10:10:00`,
lastModifiedBy: `modifier-${id}`, lastModifiedBy: `modifier${id}`,
fileName: `My Name${id}${suffix}.png`, fileName: `My Name${id}${suffix}.png`,
fileHash: `My Hash${id}${suffix}`, fileHash: `My Hash${id}${suffix}`,
fileType: 'png', fileType: 'png',
@ -344,8 +344,8 @@ export function createAsset(id: number, tags?: string[], suffix = '') {
return new AssetDto(links, meta, return new AssetDto(links, meta,
`id${id}`, `id${id}`,
DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), `creator-${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), `creator${id}`,
DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`), `modifier-${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`), `modifier${id}`,
`My Name${id}${suffix}.png`, `My Name${id}${suffix}.png`,
`My Hash${id}${suffix}`, `My Hash${id}${suffix}`,
'png', 'png',

8
src/Squidex/app/shared/services/contents.service.spec.ts

@ -356,9 +356,9 @@ describe('ContentsService', () => {
status: `Status${id}`, status: `Status${id}`,
statusColor: 'black', statusColor: 'black',
created: `${id % 1000 + 2000}-12-12T10:10:00`, created: `${id % 1000 + 2000}-12-12T10:10:00`,
createdBy: `creator-${id}`, createdBy: `creator${id}`,
lastModified: `${id % 1000 + 2000}-11-11T10:10:00`, lastModified: `${id % 1000 + 2000}-11-11T10:10:00`,
lastModifiedBy: `modifier-${id}`, lastModifiedBy: `modifier${id}`,
scheduleJob: { scheduleJob: {
status: 'Draft', status: 'Draft',
scheduledBy: `Scheduler${id}`, scheduledBy: `Scheduler${id}`,
@ -385,8 +385,8 @@ export function createContent(id: number, suffix = '') {
`id${id}`, `id${id}`,
`Status${id}${suffix}`, `Status${id}${suffix}`,
'black', 'black',
DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), `creator-${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), `creator${id}`,
DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`), `modifier-${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`), `modifier${id}`,
new ScheduleDto('Draft', `Scheduler${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`)), new ScheduleDto('Draft', `Scheduler${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`)),
true, true,
{}, {},

6
src/Squidex/app/shared/services/contributors.service.spec.ts

@ -120,7 +120,9 @@ describe('ContributorsService', () => {
function contributorsResponse(...ids: number[]) { function contributorsResponse(...ids: number[]) {
return { return {
items: ids.map(id => ({ items: ids.map(id => ({
contributorId: `id${id}`, role: id % 2 === 0 ? 'Owner' : 'Developer', contributorId: `id${id}`,
contributorName: `name${id}`,
role: id % 2 === 0 ? 'Owner' : 'Developer',
_links: { _links: {
update: { method: 'PUT', href: `/contributors/id${id}` } update: { method: 'PUT', href: `/contributors/id${id}` }
} }
@ -155,5 +157,5 @@ export function createContributor(id: number) {
update: { method: 'PUT', href: `/contributors/id${id}` } update: { method: 'PUT', href: `/contributors/id${id}` }
}; };
return new ContributorDto(links, `id${id}`, id % 2 === 0 ? 'Owner' : 'Developer'); return new ContributorDto(links, `id${id}`, `name${id}`, id % 2 === 0 ? 'Owner' : 'Developer');
} }

2
src/Squidex/app/shared/services/contributors.service.ts

@ -41,6 +41,7 @@ export class ContributorDto {
constructor( constructor(
links: ResourceLinks, links: ResourceLinks,
public readonly contributorId: string, public readonly contributorId: string,
public readonly contributorName: string,
public readonly role: string public readonly role: string
) { ) {
this._links = links; this._links = links;
@ -112,6 +113,7 @@ function parseContributors(response: any) {
const items = raw.map(item => const items = raw.map(item =>
new ContributorDto(item._links, new ContributorDto(item._links,
item.contributorId, item.contributorId,
item.contributorName,
item.role)); item.role));
const { maxContributors, _links, _meta } = response; const { maxContributors, _links, _meta } = response;

8
src/Squidex/app/shared/services/rules.service.spec.ts

@ -359,9 +359,9 @@ describe('RulesService', () => {
return { return {
id: `id${id}`, id: `id${id}`,
created: `${id % 1000 + 2000}-12-12T10:10`, created: `${id % 1000 + 2000}-12-12T10:10`,
createdBy: `creator-${id}`, createdBy: `creator${id}`,
lastModified: `${id % 1000 + 2000}-11-11T10:10`, lastModified: `${id % 1000 + 2000}-11-11T10:10`,
lastModifiedBy: `modifier-${id}`, lastModifiedBy: `modifier${id}`,
isEnabled: id % 2 === 0, isEnabled: id % 2 === 0,
trigger: { trigger: {
param1: 1, param1: 1,
@ -404,8 +404,8 @@ export function createRule(id: number, suffix = '') {
return new RuleDto(links, return new RuleDto(links,
`id${id}`, `id${id}`,
DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), `creator-${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), `creator${id}`,
DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`), `modifier-${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`), `modifier${id}`,
new Version(`${id}`), new Version(`${id}`),
id % 2 === 0, id % 2 === 0,
{ {

16
src/Squidex/app/shared/services/schemas.service.spec.ts

@ -612,9 +612,9 @@ describe('SchemasService', () => {
isSingleton: id % 2 === 0, isSingleton: id % 2 === 0,
isPublished: id % 3 === 0, isPublished: id % 3 === 0,
created: `${id % 1000 + 2000}-12-12T10:10:00`, created: `${id % 1000 + 2000}-12-12T10:10:00`,
createdBy: `creator-${id}`, createdBy: `creator${id}`,
lastModified: `${id % 1000 + 2000}-11-11T10:10:00`, lastModified: `${id % 1000 + 2000}-11-11T10:10:00`,
lastModifiedBy: `modifier-${id}`, lastModifiedBy: `modifier${id}`,
properties: { properties: {
label: `label${id}${suffix}`, label: `label${id}${suffix}`,
hints: `hints${id}${suffix}` hints: `hints${id}${suffix}`
@ -634,9 +634,9 @@ describe('SchemasService', () => {
isSingleton: id % 2 === 0, isSingleton: id % 2 === 0,
isPublished: id % 3 === 0, isPublished: id % 3 === 0,
created: `${id % 1000 + 2000}-12-12T10:10:00`, created: `${id % 1000 + 2000}-12-12T10:10:00`,
createdBy: `creator-${id}`, createdBy: `creator${id}`,
lastModified: `${id % 1000 + 2000}-11-11T10:10:00`, lastModified: `${id % 1000 + 2000}-11-11T10:10:00`,
lastModifiedBy: `modifier-${id}`, lastModifiedBy: `modifier${id}`,
properties: { properties: {
label: `label${id}${suffix}`, label: `label${id}${suffix}`,
hints: `hints${id}${suffix}` hints: `hints${id}${suffix}`
@ -816,8 +816,8 @@ export function createSchema(id: number, suffix = '') {
new SchemaPropertiesDto(`label${id}${suffix}`, `hints${id}${suffix}`), new SchemaPropertiesDto(`label${id}${suffix}`, `hints${id}${suffix}`),
id % 2 === 0, id % 2 === 0,
id % 3 === 0, id % 3 === 0,
DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), `creator-${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), `creator${id}`,
DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`), `modifier-${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`), `modifier${id}`,
new Version(`${id}`)); new Version(`${id}`));
} }
@ -833,8 +833,8 @@ export function createSchemaDetails(id: number, version: Version, suffix = '') {
new SchemaPropertiesDto(`label${id}${suffix}`, `hints${id}${suffix}`), new SchemaPropertiesDto(`label${id}${suffix}`, `hints${id}${suffix}`),
id % 2 === 0, id % 2 === 0,
id % 3 === 0, id % 3 === 0,
DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), `creator-${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), `creator${id}`,
DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`), `modifier-${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`), `modifier${id}`,
version, version,
[ [
new RootFieldDto({}, 11, 'field11', createProperties('Array'), 'language', true, true, true, [ new RootFieldDto({}, 11, 'field11', createProperties('Array'), 'language', true, true, true, [

8
src/Squidex/app/shared/state/clients.state.ts

@ -110,7 +110,13 @@ export class ClientsState extends State<Snapshot> {
const clients = ImmutableArray.of(items); const clients = ImmutableArray.of(items);
this.next(s => { this.next(s => {
return { ...s, clients, isLoaded: true, version, canCreate }; return {
...s,
canCreate,
clients,
isLoaded: true,
version
};
}); });
} }

2
src/Squidex/app/shared/state/contents.state.ts

@ -96,7 +96,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
private readonly contentsService: ContentsService, private readonly contentsService: ContentsService,
private readonly dialogs: DialogService private readonly dialogs: DialogService
) { ) {
super({ contents: ImmutableArray.of(), contentsPager: new Pager(0), contentsQueryJson: '' }); super({ contents: ImmutableArray.empty(), contentsPager: new Pager(0), contentsQueryJson: '' });
} }
public select(id: string | null): Observable<ContentDto | null> { public select(id: string | null): Observable<ContentDto | null> {

77
src/Squidex/app/shared/state/contributors.state.spec.ts

@ -9,6 +9,7 @@ import { of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, It, Mock, Times } from 'typemoq';
import { import {
ContributorDto,
ContributorsPayload, ContributorsPayload,
ContributorsService, ContributorsService,
ContributorsState, ContributorsState,
@ -28,7 +29,13 @@ describe('ContributorsState', () => {
version version
} = TestValues; } = TestValues;
const oldContributors = createContributors(1, 2, 3); let allIds: number[] = [];
for (let i = 1; i <= 20; i++) {
allIds.push(i);
}
const oldContributors = createContributors(...allIds);
let dialogs: IMock<DialogService>; let dialogs: IMock<DialogService>;
let contributorsService: IMock<ContributorsService>; let contributorsService: IMock<ContributorsService>;
@ -39,6 +46,9 @@ describe('ContributorsState', () => {
contributorsService = Mock.ofType<ContributorsService>(); contributorsService = Mock.ofType<ContributorsService>();
contributorsState = new ContributorsState(contributorsService.object, appsState.object, dialogs.object); contributorsState = new ContributorsState(contributorsService.object, appsState.object, dialogs.object);
contributorsService.setup(x => x.getContributors(app))
.returns(() => of(versioned(version, oldContributors))).verifiable();
}); });
afterEach(() => { afterEach(() => {
@ -47,9 +57,6 @@ describe('ContributorsState', () => {
describe('Loading', () => { describe('Loading', () => {
it('should load contributors', () => { it('should load contributors', () => {
contributorsService.setup(x => x.getContributors(app))
.returns(() => of(versioned(version, oldContributors))).verifiable();
contributorsState.load().subscribe(); contributorsState.load().subscribe();
expect(contributorsState.snapshot.contributors.values).toEqual(oldContributors.items); expect(contributorsState.snapshot.contributors.values).toEqual(oldContributors.items);
@ -60,10 +67,63 @@ describe('ContributorsState', () => {
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never()); dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never());
}); });
it('should show notification on load when reload is true', () => { it('should only current page of contributors', () => {
contributorsService.setup(x => x.getContributors(app)) contributorsState.load().subscribe();
.returns(() => of(versioned(version, oldContributors))).verifiable();
let contributors: ContributorDto[];
contributorsState.contributorsPaged.subscribe(result => {
contributors = result.values;
});
expect(contributors!).toEqual(oldContributors.items.slice(0, 10));
expect(contributorsState.snapshot.page).toEqual(0);
});
it('should show next of contributors when going next', () => {
contributorsState.load().subscribe();
contributorsState.goNext();
let contributors: ContributorDto[];
contributorsState.contributorsPaged.subscribe(result => {
contributors = result.values;
});
expect(contributors!).toEqual(oldContributors.items.slice(10, 20));
expect(contributorsState.snapshot.page).toEqual(1);
});
it('should show next of contributors when going prev', () => {
contributorsState.load().subscribe();
contributorsState.goNext();
contributorsState.goPrev();
let contributors: ContributorDto[];
contributorsState.contributorsPaged.subscribe(result => {
contributors = result.values;
});
expect(contributors!).toEqual(oldContributors.items.slice(0, 10));
expect(contributorsState.snapshot.page).toEqual(0);
});
it('should show filtered contributors when searching', () => {
contributorsState.load().subscribe();
contributorsState.search('4');
let contributors: ContributorDto[];
contributorsState.contributorsPaged.subscribe(result => {
contributors = result.values;
});
expect(contributors!).toEqual(createContributors(4, 14).items);
expect(contributorsState.snapshot.page).toEqual(0);
});
it('should show notification on load when reload is true', () => {
contributorsState.load(true).subscribe(); contributorsState.load(true).subscribe();
expect().nothing(); expect().nothing();
@ -74,9 +134,6 @@ describe('ContributorsState', () => {
describe('Updates', () => { describe('Updates', () => {
beforeEach(() => { beforeEach(() => {
contributorsService.setup(x => x.getContributors(app))
.returns(() => of(versioned(version, oldContributors))).verifiable();
contributorsState.load().subscribe(); contributorsState.load().subscribe();
}); });

73
src/Squidex/app/shared/state/contributors.state.ts

@ -6,13 +6,14 @@
*/ */
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs'; import { combineLatest, Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators'; import { catchError, tap } from 'rxjs/operators';
import { import {
DialogService, DialogService,
ErrorDto, ErrorDto,
ImmutableArray, ImmutableArray,
Pager,
shareMapSubscribed, shareMapSubscribed,
shareSubscribed, shareSubscribed,
State, State,
@ -39,6 +40,15 @@ interface Snapshot {
// The maximum allowed users. // The maximum allowed users.
maxContributors: number; maxContributors: number;
// The current page.
page: number;
// The search query.
query?: string;
// Query regex.
queryRegex?: RegExp;
// The app version. // The app version.
version: Version; version: Version;
@ -53,21 +63,39 @@ export class ContributorsState extends State<Snapshot> {
public contributors = public contributors =
this.project(x => x.contributors); this.project(x => x.contributors);
public isLoaded = public page =
this.project(x => !!x.isLoaded); this.project(x => x.page);
public query =
this.project(x => x.query);
public queryRegex =
this.project(x => x.queryRegex);
public maxContributors = public maxContributors =
this.project(x => x.maxContributors); this.project(x => x.maxContributors);
public isLoaded =
this.project(x => !!x.isLoaded);
public canCreate = public canCreate =
this.project(x => !!x.canCreate); this.project(x => !!x.canCreate);
public filtered =
combineLatest(this.queryRegex, this.contributors, (q, c) => getFilteredContributors(c, q));
public contributorsPaged =
combineLatest(this.page, this.filtered, (p, c) => getPagedContributors(c, p));
public contributorsPager =
combineLatest(this.page, this.filtered, (p, c) => new Pager(c.length, p, PAGE_SIZE));
constructor( constructor(
private readonly contributorsService: ContributorsService, private readonly contributorsService: ContributorsService,
private readonly appsState: AppsState, private readonly appsState: AppsState,
private readonly dialogs: DialogService private readonly dialogs: DialogService
) { ) {
super({ contributors: ImmutableArray.empty(), version: Version.EMPTY, maxContributors: -1 }); super({ contributors: ImmutableArray.empty(), page: 0, maxContributors: -1, version: Version.EMPTY });
} }
public load(isReload = false): Observable<any> { public load(isReload = false): Observable<any> {
@ -86,6 +114,18 @@ export class ContributorsState extends State<Snapshot> {
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
public goNext() {
this.next(s => ({ ...s, page: s.page + 1 }));
}
public goPrev() {
this.next(s => ({ ...s, page: s.page - 1 }));
}
public search(query: string) {
this.next(s => ({ ...s, query, queryRegex: new RegExp(query, 'i') }));
}
public revoke(contributor: ContributorDto): Observable<any> { public revoke(contributor: ContributorDto): Observable<any> {
return this.contributorsService.deleteContributor(this.appName, contributor, this.version).pipe( return this.contributorsService.deleteContributor(this.appName, contributor, this.version).pipe(
tap(({ version, payload }) => { tap(({ version, payload }) => {
@ -115,7 +155,14 @@ export class ContributorsState extends State<Snapshot> {
const contributors = ImmutableArray.of(items); const contributors = ImmutableArray.of(items);
return { ...s, contributors, maxContributors, isLoaded: true, version, canCreate }; return {
canCreate,
contributors,
isLoaded: true,
maxContributors,
page: 0,
version
};
}); });
} }
@ -127,3 +174,19 @@ export class ContributorsState extends State<Snapshot> {
return this.snapshot.version; return this.snapshot.version;
} }
} }
const PAGE_SIZE = 10;
function getPagedContributors(contributors: ContributorsList, page: number) {
return ImmutableArray.of(contributors.values.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE));
}
function getFilteredContributors(contributors: ContributorsList, query?: RegExp) {
let filtered = contributors;
if (query) {
filtered = filtered.filter(x => query.test(x.contributorName));
}
return filtered;
}

7
src/Squidex/app/shared/state/rules.state.ts

@ -76,7 +76,12 @@ export class RulesState extends State<Snapshot> {
this.next(s => { this.next(s => {
const rules = ImmutableArray.of(items); const rules = ImmutableArray.of(items);
return { ...s, rules, isLoaded: true, canCreate, canReadEvents }; return { ...s,
canCreate,
canReadEvents,
isLoaded: true,
rules
};
}); });
}), }),
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));

8
src/Squidex/app/theme/_lists.scss

@ -144,11 +144,19 @@
} }
// Footer that typically contains an add-item-form. // Footer that typically contains an add-item-form.
&-header,
&-footer { &-footer {
padding: 1rem 1.25rem; padding: 1rem 1.25rem;
background: $color-table-footer; background: $color-table-footer;
border: 1px solid $color-border; border: 1px solid $color-border;
border-bottom-width: 2px; border-bottom-width: 2px;
}
&-header {
margin-bottom: .8rem;
}
&-footer {
margin-top: .8rem; margin-top: .8rem;
} }

31
tools/LoadTest/TestUtils.cs

@ -0,0 +1,31 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using LoadTest.Model;
using Squidex.ClientLibrary.Management;
using Xunit;
namespace LoadTest
{
public sealed class TestUtils
{
[Fact]
public async Task GenerateAppManyContributorsAsync()
{
var client = TestClient.ClientManager.CreateAppsClient();
for (var i = 0; i < 200; i++)
{
await client.PostContributorAsync("test", new AssignContributorDto
{
ContributorId = $"hello{i}@squidex.io", Invite = true, Role = "Editor"
});
}
}
}
}

1
tools/LoadTest/WritingBenchmarks.cs

@ -9,7 +9,6 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using LoadTest.Model; using LoadTest.Model;
using LoadTest.Utils;
using Xunit; using Xunit;
namespace LoadTest namespace LoadTest

Loading…
Cancel
Save