Browse Source

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

pull/410/head
Sebastian Stehle 7 years ago
parent
commit
4dc673d940
  1. 35
      src/Squidex.Domain.Apps.Core.Model/Apps/AppImage.cs
  2. 25
      src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs
  3. 45
      src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs
  4. 6
      src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs
  5. 13
      src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveAppImage.cs
  6. 16
      src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateApp.cs
  7. 20
      src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs
  8. 15
      src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs
  9. 44
      src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs
  10. 6
      src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs
  11. 26
      src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs
  12. 24
      src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs
  13. 16
      src/Squidex.Domain.Apps.Events/Apps/AppImageRemoved.cs
  14. 18
      src/Squidex.Domain.Apps.Events/Apps/AppImageUploaded.cs
  15. 19
      src/Squidex.Domain.Apps.Events/Apps/AppUpdated.cs
  16. 8
      src/Squidex.Domain.Users/DefaultUserResolver.cs
  17. 9
      src/Squidex.Domain.Users/UserManagerExtensions.cs
  18. 12
      src/Squidex.Infrastructure/RandomHash.cs
  19. 3
      src/Squidex.Shared/Permissions.cs
  20. 3
      src/Squidex.Shared/Users/IUserResolver.cs
  21. 9
      src/Squidex/Areas/Api/Controllers/Apps/AppClientsController.cs
  22. 19
      src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
  23. 9
      src/Squidex/Areas/Api/Controllers/Apps/AppLanguagesController.cs
  24. 9
      src/Squidex/Areas/Api/Controllers/Apps/AppPatternsController.cs
  25. 9
      src/Squidex/Areas/Api/Controllers/Apps/AppRolesController.cs
  26. 9
      src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs
  27. 197
      src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
  28. 47
      src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs
  29. 6
      src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguageDto.cs
  30. 6
      src/Squidex/Areas/Api/Controllers/Apps/Models/AppLanguagesDto.cs
  31. 5
      src/Squidex/Areas/Api/Controllers/Apps/Models/AssignContributorDto.cs
  32. 6
      src/Squidex/Areas/Api/Controllers/Apps/Models/ClientDto.cs
  33. 10
      src/Squidex/Areas/Api/Controllers/Apps/Models/ClientsDto.cs
  34. 30
      src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs
  35. 38
      src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorsDto.cs
  36. 20
      src/Squidex/Areas/Api/Controllers/Apps/Models/RoleDto.cs
  37. 11
      src/Squidex/Areas/Api/Controllers/Apps/Models/RolesDto.cs
  38. 30
      src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppDto.cs
  39. 9
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs
  40. 10
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowsDto.cs
  41. 2
      src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs
  42. 9
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs
  43. 15
      src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs
  44. 15
      src/Squidex/app-config/webpack.config.js
  45. 4
      src/Squidex/app/features/administration/pages/users/user-page.component.ts
  46. 8
      src/Squidex/app/features/administration/services/event-consumers.service.spec.ts
  47. 2
      src/Squidex/app/features/api/api-area.component.html
  48. 2
      src/Squidex/app/features/api/pages/graphql/graphql-page.component.html
  49. 33
      src/Squidex/app/features/apps/pages/apps-page.component.html
  50. 19
      src/Squidex/app/features/apps/pages/apps-page.component.scss
  51. 2
      src/Squidex/app/features/assets/pages/assets-page.component.html
  52. 2
      src/Squidex/app/features/content/pages/content/content-page.component.html
  53. 2
      src/Squidex/app/features/content/pages/contents/contents-page.component.html
  54. 2
      src/Squidex/app/features/content/pages/schemas/schemas-page.component.html
  55. 4
      src/Squidex/app/features/dashboard/pages/dashboard-page.component.html
  56. 2
      src/Squidex/app/features/rules/pages/events/rule-events-page.component.html
  57. 2
      src/Squidex/app/features/rules/pages/rules/rules-page.component.html
  58. 2
      src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.scss
  59. 2
      src/Squidex/app/features/schemas/pages/schema/schema-page.component.html
  60. 2
      src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html
  61. 1
      src/Squidex/app/features/settings/declarations.ts
  62. 7
      src/Squidex/app/features/settings/module.ts
  63. 12
      src/Squidex/app/features/settings/pages/backups/backups-page.component.html
  64. 29
      src/Squidex/app/features/settings/pages/backups/backups-page.component.scss
  65. 2
      src/Squidex/app/features/settings/pages/clients/clients-page.component.html
  66. 84
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html
  67. 27
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts
  68. 43
      src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.html
  69. 10
      src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.scss
  70. 96
      src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.ts
  71. 2
      src/Squidex/app/features/settings/pages/languages/languages-page.component.html
  72. 88
      src/Squidex/app/features/settings/pages/more/more-page.component.html
  73. 71
      src/Squidex/app/features/settings/pages/more/more-page.component.scss
  74. 94
      src/Squidex/app/features/settings/pages/more/more-page.component.ts
  75. 2
      src/Squidex/app/features/settings/pages/patterns/patterns-page.component.html
  76. 2
      src/Squidex/app/features/settings/pages/plans/plans-page.component.html
  77. 2
      src/Squidex/app/features/settings/pages/roles/roles-page.component.html
  78. 2
      src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html
  79. 12
      src/Squidex/app/features/settings/settings-area.component.html
  80. 10
      src/Squidex/app/features/settings/settings-area.component.scss
  81. 46
      src/Squidex/app/framework/angular/avatar.component.ts
  82. 8
      src/Squidex/app/framework/angular/forms/control-errors.component.ts
  83. 16
      src/Squidex/app/framework/angular/forms/dropdown.component.ts
  84. 9
      src/Squidex/app/framework/angular/forms/form-hint.component.ts
  85. 10
      src/Squidex/app/framework/angular/highlight.pipe.ts
  86. 16
      src/Squidex/app/framework/angular/http/http-extensions.ts
  87. 15
      src/Squidex/app/framework/angular/safe-html.pipe.ts
  88. 14
      src/Squidex/app/framework/angular/status-icon.component.html
  89. 41
      src/Squidex/app/framework/angular/status-icon.component.scss
  90. 25
      src/Squidex/app/framework/angular/status-icon.component.ts
  91. 8
      src/Squidex/app/framework/declarations.ts
  92. 1
      src/Squidex/app/framework/internal.ts
  93. 9
      src/Squidex/app/framework/module.ts
  94. 10
      src/Squidex/app/framework/utils/hateos.ts
  95. 71
      src/Squidex/app/framework/utils/picasso.ts
  96. 124
      src/Squidex/app/shared/services/apps.service.spec.ts
  97. 107
      src/Squidex/app/shared/services/apps.service.ts
  98. 30
      src/Squidex/app/shared/services/assets.service.spec.ts
  99. 30
      src/Squidex/app/shared/services/assets.service.ts
  100. 8
      src/Squidex/app/shared/services/contents.service.spec.ts

35
src/Squidex.Domain.Apps.Core.Model/Apps/AppImage.cs

@ -0,0 +1,35 @@
// ==========================================================================
// 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.Core.Apps
{
public sealed class AppImage
{
public string MimeType { get; }
public string Etag { get; }
public AppImage(string mimeType, string etag = null)
{
Guard.NotNullOrEmpty(mimeType, nameof(mimeType));
MimeType = mimeType;
if (string.IsNullOrWhiteSpace(etag))
{
Etag = RandomHash.Simple();
}
else
{
Etag = etag;
}
}
}
}

25
src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs

@ -10,24 +10,47 @@ using System.Threading.Tasks;
using Orleans; using Orleans;
using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Apps namespace Squidex.Domain.Apps.Entities.Apps
{ {
public sealed class AppCommandMiddleware : GrainCommandMiddleware<AppCommand, IAppGrain> public sealed class AppCommandMiddleware : GrainCommandMiddleware<AppCommand, IAppGrain>
{ {
private readonly IAssetStore assetStore;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
private readonly IContextProvider contextProvider; private readonly IContextProvider contextProvider;
public AppCommandMiddleware(IGrainFactory grainFactory, IContextProvider contextProvider) public AppCommandMiddleware(
IGrainFactory grainFactory,
IAssetStore assetStore,
IAssetThumbnailGenerator assetThumbnailGenerator,
IContextProvider contextProvider)
: base(grainFactory) : base(grainFactory)
{ {
Guard.NotNull(contextProvider, nameof(contextProvider)); Guard.NotNull(contextProvider, nameof(contextProvider));
Guard.NotNull(assetStore, nameof(assetStore));
Guard.NotNull(assetThumbnailGenerator, nameof(assetThumbnailGenerator));
this.assetStore = assetStore;
this.assetThumbnailGenerator = assetThumbnailGenerator;
this.contextProvider = contextProvider; this.contextProvider = contextProvider;
} }
public override async Task HandleAsync(CommandContext context, Func<Task> next) public override async Task HandleAsync(CommandContext context, Func<Task> next)
{ {
if (context.Command is UploadAppImage uploadImage)
{
var image = await assetThumbnailGenerator.GetImageInfoAsync(uploadImage.File());
if (image == null)
{
throw new ValidationException("File is not an image.");
}
await assetStore.UploadAsync(uploadImage.AppId.ToString(), uploadImage.File(), true);
}
await ExecuteCommandAsync(context); await ExecuteCommandAsync(context);
if (context.PlainResult is IAppEntity app) if (context.PlainResult is IAppEntity app)

45
src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs

@ -69,6 +69,36 @@ namespace Squidex.Domain.Apps.Entities.Apps
return Snapshot; return Snapshot;
}); });
case UpdateApp updateApp:
return UpdateReturn(updateApp, c =>
{
GuardApp.CanUpdate(c);
Update(c);
return Snapshot;
});
case UploadAppImage uploadImage:
return UpdateReturn(uploadImage, c =>
{
GuardApp.CanUploadImage(c);
UploadImage(c);
return Snapshot;
});
case RemoveAppImage removeImage:
return UpdateReturn(removeImage, c =>
{
GuardApp.CanRemoveImage(c);
RemoveImage(c);
return Snapshot;
});
case AssignContributor assignContributor: case AssignContributor assignContributor:
return UpdateReturnAsync(assignContributor, async c => return UpdateReturnAsync(assignContributor, async c =>
{ {
@ -324,6 +354,21 @@ namespace Squidex.Domain.Apps.Entities.Apps
} }
} }
public void Update(UpdateApp command)
{
RaiseEvent(SimpleMapper.Map(command, new AppUpdated()));
}
public void UploadImage(UploadAppImage command)
{
RaiseEvent(SimpleMapper.Map(command, new AppImageUploaded()));
}
public void RemoveImage(RemoveAppImage command)
{
RaiseEvent(SimpleMapper.Map(command, new AppImageRemoved()));
}
public void UpdateLanguage(UpdateLanguage command) public void UpdateLanguage(UpdateLanguage command)
{ {
RaiseEvent(SimpleMapper.Map(command, new AppLanguageUpdated())); RaiseEvent(SimpleMapper.Map(command, new AppLanguageUpdated()));

6
src/Squidex.Domain.Apps.Entities/Apps/Commands/AssignContributor.cs

@ -13,12 +13,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands
{ {
public string ContributorId { get; set; } public string ContributorId { get; set; }
public string Role { get; set; } = Roles.Developer; public string Role { get; set; } = Roles.Editor;
public bool IsRestore { get; set; } public bool IsRestore { get; set; }
public bool IsInviting { get; set; } public bool Invite { get; set; }
public bool IsCreated { get; set; }
} }
} }

13
src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveAppImage.cs

@ -0,0 +1,13 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
public sealed class RemoveAppImage : AppCommand
{
}
}

16
src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateApp.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
public sealed class UpdateApp : AppCommand
{
public string Label { get; set; }
public string Description { get; set; }
}
}

20
src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using Squidex.Domain.Apps.Core.Apps;
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
public sealed class UploadAppImage : AppCommand
{
public AppImage Image { get; set; }
public Func<Stream> File { get; set; }
}
}

15
src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs

@ -28,6 +28,21 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
}); });
} }
public static void CanUpdate(UpdateApp command)
{
Guard.NotNull(command, nameof(command));
}
public static void CanUploadImage(UploadAppImage command)
{
Guard.NotNull(command, nameof(command));
}
public static void CanRemoveImage(RemoveAppImage command)
{
Guard.NotNull(command, nameof(command));
}
public static void CanChangePlan(ChangePlan command, AppPlan plan, IAppPlansProvider appPlans) public static void CanChangePlan(ChangePlan command, AppPlan plan, IAppPlansProvider appPlans)
{ {
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));

44
src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppContributors.cs

@ -32,35 +32,39 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards
if (string.IsNullOrWhiteSpace(command.ContributorId)) if (string.IsNullOrWhiteSpace(command.ContributorId))
{ {
e(Not.Defined("Contributor id"), nameof(command.ContributorId)); e(Not.Defined("Contributor id"), nameof(command.ContributorId));
return;
} }
else
var user = await users.FindByIdOrEmailAsync(command.ContributorId);
if (user == null)
{ {
throw new DomainObjectNotFoundException(command.ContributorId, "Contributors", typeof(IAppEntity)); var user = await users.FindByIdOrEmailAsync(command.ContributorId);
}
command.ContributorId = user.Id;
if (!command.IsRestore) if (user == null)
{
if (string.Equals(command.ContributorId, command.Actor?.Identifier, StringComparison.OrdinalIgnoreCase))
{ {
throw new DomainForbiddenException("You cannot change your own role."); throw new DomainObjectNotFoundException(command.ContributorId, "Contributors", typeof(IAppEntity));
} }
if (contributors.TryGetValue(command.ContributorId, out var existing)) command.ContributorId = user.Id;
if (!command.IsRestore)
{ {
if (existing == command.Role) if (string.Equals(command.ContributorId, command.Actor?.Identifier, StringComparison.OrdinalIgnoreCase))
{ {
e(Not.New("Contributor", "role"), nameof(command.Role)); throw new DomainForbiddenException("You cannot change your own role.");
}
if (contributors.TryGetValue(command.ContributorId, out var role))
{
if (role == command.Role)
{
e(Not.New("Contributor", "role"), nameof(command.Role));
}
}
else
{
if (plan.MaxContributors > 0 && contributors.Count >= plan.MaxContributors)
{
e("You have reached the maximum number of contributors for your plan.");
}
} }
}
else if (plan.MaxContributors > 0 && contributors.Count >= plan.MaxContributors)
{
e("You have reached the maximum number of contributors for your plan.");
} }
} }
}); });

6
src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs

@ -18,10 +18,16 @@ namespace Squidex.Domain.Apps.Entities.Apps
{ {
string Name { get; } string Name { get; }
string Label { get; }
string Description { get; }
Roles Roles { get; } Roles Roles { get; }
AppPlan Plan { get; } AppPlan Plan { get; }
AppImage Image { get; }
AppClients Clients { get; } AppClients Clients { get; }
AppPatterns Patterns { get; } AppPatterns Patterns { get; }

26
src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs

@ -27,24 +27,26 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
public async Task HandleAsync(CommandContext context, Func<Task> next) public async Task HandleAsync(CommandContext context, Func<Task> next)
{ {
if (context.Command is AssignContributor assignContributor) if (context.Command is AssignContributor assignContributor && ShouldInvite(assignContributor))
{ {
if (assignContributor.IsInviting && assignContributor.ContributorId.IsEmail()) var created = await userResolver.CreateUserIfNotExists(assignContributor.ContributorId, true);
{
assignContributor.IsCreated = await userResolver.CreateUserIfNotExists(assignContributor.ContributorId, true);
await next();
if (assignContributor.IsCreated && context.PlainResult is IAppEntity app) await next();
{
context.Complete(new InvitedResult { App = app });
}
return; if (created && context.PlainResult is IAppEntity app)
{
context.Complete(new InvitedResult { App = app });
} }
} }
else
{
await next();
}
}
await next(); private bool ShouldInvite(AssignContributor assignContributor)
{
return assignContributor.Invite && assignContributor.ContributorId.IsEmail();
} }
} }
} }

24
src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs

@ -25,12 +25,21 @@ namespace Squidex.Domain.Apps.Entities.Apps.State
[DataMember] [DataMember]
public string Name { get; set; } public string Name { get; set; }
[DataMember]
public string Label { get; set; }
[DataMember]
public string Description { get; set; }
[DataMember] [DataMember]
public Roles Roles { get; set; } = Roles.Empty; public Roles Roles { get; set; } = Roles.Empty;
[DataMember] [DataMember]
public AppPlan Plan { get; set; } public AppPlan Plan { get; set; }
[DataMember]
public AppImage Image { get; set; }
[DataMember] [DataMember]
public AppClients Clients { get; set; } = AppClients.Empty; public AppClients Clients { get; set; } = AppClients.Empty;
@ -56,6 +65,21 @@ namespace Squidex.Domain.Apps.Entities.Apps.State
SimpleMapper.Map(@event, this); SimpleMapper.Map(@event, this);
} }
protected void On(AppUpdated @event)
{
SimpleMapper.Map(@event, this);
}
protected void On(AppImageUploaded @event)
{
Image = @event.Image;
}
protected void On(AppImageRemoved @event)
{
Image = null;
}
protected void On(AppPlanChanged @event) protected void On(AppPlanChanged @event)
{ {
Plan = AppPlan.Build(@event.Actor, @event.PlanId); Plan = AppPlan.Build(@event.Actor, @event.PlanId);

16
src/Squidex.Domain.Apps.Events/Apps/AppImageRemoved.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Apps
{
[EventType(nameof(AppImageRemoved))]
public sealed class AppImageRemoved : AppEvent
{
}
}

18
src/Squidex.Domain.Apps.Events/Apps/AppImageUploaded.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(AppImageUploaded))]
public sealed class AppImageUploaded : AppEvent
{
public AppImage Image { get; set; }
}
}

19
src/Squidex.Domain.Apps.Events/Apps/AppUpdated.cs

@ -0,0 +1,19 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Apps
{
[EventType(nameof(AppUpdated))]
public sealed class AppUpdated : AppEvent
{
public string Label { get; set; }
public string Description { get; set; }
}
}

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

12
src/Squidex.Infrastructure/RandomHash.cs

@ -15,7 +15,17 @@ namespace Squidex.Infrastructure
{ {
public static string New() public static string New()
{ {
return Guid.NewGuid().ToString().Sha256Base64().Replace("+", "x"); return Guid.NewGuid()
.ToString().Sha256Base64()
.ToLowerInvariant()
.Replace("+", "x")
.Replace("=", "x")
.Replace("/", "x");
}
public static string Simple()
{
return Guid.NewGuid().ToString().Replace("-", string.Empty);
} }
public static string Sha256Base64(this string value) public static string Sha256Base64(this string value)

3
src/Squidex.Shared/Permissions.cs

@ -53,6 +53,9 @@ namespace Squidex.Shared
public const string AppCommon = "squidex.apps.{app}.common"; public const string AppCommon = "squidex.apps.{app}.common";
public const string AppDelete = "squidex.apps.{app}.delete"; public const string AppDelete = "squidex.apps.{app}.delete";
public const string AppUpdate = "squidex.apps.{app}.update";
public const string AppUpdateImage = "squidex.apps.{app}.update";
public const string AppUpdateGeneral = "squidex.apps.{app}.general";
public const string AppClients = "squidex.apps.{app}.clients"; public const string AppClients = "squidex.apps.{app}.clients";
public const string AppClientsRead = "squidex.apps.{app}.clients.read"; public const string AppClientsRead = "squidex.apps.{app}.clients.read";

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

197
src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs

@ -5,16 +5,26 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
using NSwag.Annotations;
using Squidex.Areas.Api.Controllers.Apps.Models; using Squidex.Areas.Api.Controllers.Apps.Models;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Apps.Services; using Squidex.Domain.Apps.Entities.Apps.Services;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Security;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Web; using Squidex.Web;
@ -27,14 +37,20 @@ namespace Squidex.Areas.Api.Controllers.Apps
[ApiExplorerSettings(GroupName = nameof(Apps))] [ApiExplorerSettings(GroupName = nameof(Apps))]
public sealed class AppsController : ApiController public sealed class AppsController : ApiController
{ {
private readonly IAssetStore assetStore;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
private readonly IAppPlansProvider appPlansProvider; private readonly IAppPlansProvider appPlansProvider;
public AppsController(ICommandBus commandBus, public AppsController(ICommandBus commandBus,
IAssetStore assetStore,
IAssetThumbnailGenerator assetThumbnailGenerator,
IAppProvider appProvider, IAppProvider appProvider,
IAppPlansProvider appPlansProvider) IAppPlansProvider appPlansProvider)
: base(commandBus) : base(commandBus)
{ {
this.assetStore = assetStore;
this.assetThumbnailGenerator = assetThumbnailGenerator;
this.appProvider = appProvider; this.appProvider = appProvider;
this.appPlansProvider = appPlansProvider; this.appPlansProvider = appPlansProvider;
} }
@ -63,7 +79,7 @@ namespace Squidex.Areas.Api.Controllers.Apps
var response = Deferred.Response(() => var response = Deferred.Response(() =>
{ {
return apps.Select(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider, this)).ToArray(); return apps.OrderBy(x => x.Name).Select(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider, this)).ToArray();
}); });
Response.Headers[HeaderNames.ETag] = apps.ToEtag(); Response.Headers[HeaderNames.ETag] = apps.ToEtag();
@ -88,18 +104,143 @@ namespace Squidex.Areas.Api.Controllers.Apps
[Route("apps/")] [Route("apps/")]
[ProducesResponseType(typeof(AppDto), 201)] [ProducesResponseType(typeof(AppDto), 201)]
[ApiPermission] [ApiPermission]
[ApiCosts(1)] [ApiCosts(0)]
public async Task<IActionResult> PostApp([FromBody] CreateAppDto request) public async Task<IActionResult> PostApp([FromBody] CreateAppDto request)
{ {
var context = await CommandBus.PublishAsync(request.ToCommand()); var response = await InvokeCommandAsync(request.ToCommand());
var userOrClientId = HttpContext.User.UserOrClientId(); return CreatedAtAction(nameof(GetApps), response);
var userPermissions = HttpContext.Permissions(); }
var result = context.Result<IAppEntity>(); /// <summary>
var response = AppDto.FromApp(result, userOrClientId, userPermissions, appPlansProvider, this); /// Update the app.
/// </summary>
/// <param name="app">The name of the app to update.</param>
/// <param name="request">The values to update.</param>
/// <returns>
/// 200 => App updated.
/// 404 => App not found.
/// </returns>
[HttpPut]
[Route("apps/{app}/")]
[ProducesResponseType(typeof(AppDto), 200)]
[ApiPermission(Permissions.AppUpdateGeneral)]
[ApiCosts(0)]
public async Task<IActionResult> UpdateApp(string app, [FromBody] UpdateAppDto request)
{
var response = await InvokeCommandAsync(request.ToCommand());
return CreatedAtAction(nameof(GetApps), response); return Ok(response);
}
/// <summary>
/// Get the app image.
/// </summary>
/// <param name="app">The name of the app to update.</param>
/// <param name="file">The file to upload.</param>
/// <returns>
/// 200 => App image uploaded.
/// 404 => App not found.
/// </returns>
[HttpPost]
[Route("apps/{app}/image")]
[ProducesResponseType(typeof(AppDto), 201)]
[ApiPermission(Permissions.AppUpdateImage)]
[ApiCosts(0)]
public async Task<IActionResult> UploadImage(string app, [OpenApiIgnore] List<IFormFile> file)
{
var response = await InvokeCommandAsync(CreateCommand(file));
return Ok(response);
}
/// <summary>
/// Get the app image.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => App image found and content or (resized) image returned.
/// 404 => App not found.
/// </returns>
[HttpGet]
[Route("apps/{app}/image")]
[ProducesResponseType(typeof(FileResult), 200)]
[AllowAnonymous]
[ApiCosts(0)]
public IActionResult GetImage(string app)
{
if (App.Image == null)
{
return NotFound();
}
var etag = App.Image.Etag;
Response.Headers[HeaderNames.ETag] = etag;
var handler = new Func<Stream, Task>(async bodyStream =>
{
var assetId = App.Id.ToString();
var assetResizedId = $"{assetId}_{etag}_Resized";
try
{
await assetStore.DownloadAsync(assetResizedId, bodyStream);
}
catch (AssetNotFoundException)
{
using (Profiler.Trace("Resize"))
{
using (var sourceStream = GetTempStream())
{
using (var destinationStream = GetTempStream())
{
using (Profiler.Trace("ResizeDownload"))
{
await assetStore.DownloadAsync(assetId, sourceStream);
sourceStream.Position = 0;
}
using (Profiler.Trace("ResizeImage"))
{
await assetThumbnailGenerator.CreateThumbnailAsync(sourceStream, destinationStream, 150, 150, "Crop");
destinationStream.Position = 0;
}
using (Profiler.Trace("ResizeUpload"))
{
await assetStore.UploadAsync(assetResizedId, destinationStream);
destinationStream.Position = 0;
}
await destinationStream.CopyToAsync(bodyStream);
}
}
}
}
});
return new FileCallbackResult(App.Image.MimeType, null, true, handler);
}
/// <summary>
/// Remove the app image.
/// </summary>
/// <param name="app">The name of the app to update.</param>
/// <returns>
/// 200 => App image removed.
/// 404 => App not found.
/// </returns>
[HttpDelete]
[Route("apps/{app}/image")]
[ProducesResponseType(typeof(AppDto), 201)]
[ApiPermission(Permissions.AppUpdate)]
[ApiCosts(0)]
public async Task<IActionResult> DeleteImage(string app)
{
var response = await InvokeCommandAsync(new RemoveAppImage());
return Ok(response);
} }
/// <summary> /// <summary>
@ -113,12 +254,50 @@ namespace Squidex.Areas.Api.Controllers.Apps
[HttpDelete] [HttpDelete]
[Route("apps/{app}/")] [Route("apps/{app}/")]
[ApiPermission(Permissions.AppDelete)] [ApiPermission(Permissions.AppDelete)]
[ApiCosts(1)] [ApiCosts(0)]
public async Task<IActionResult> DeleteApp(string app) public async Task<IActionResult> DeleteApp(string app)
{ {
await CommandBus.PublishAsync(new ArchiveApp()); await CommandBus.PublishAsync(new ArchiveApp());
return NoContent(); return NoContent();
} }
private async Task<AppDto> InvokeCommandAsync(AppCommand command)
{
var context = await CommandBus.PublishAsync(command);
var userOrClientId = HttpContext.User.UserOrClientId();
var userPermissions = HttpContext.Permissions();
var result = context.Result<IAppEntity>();
var response = AppDto.FromApp(result, userOrClientId, userPermissions, appPlansProvider, this);
return response;
}
private UploadAppImage CreateCommand(IReadOnlyList<IFormFile> file)
{
if (file.Count != 1)
{
var error = new ValidationError($"Can only upload one file, found {file.Count} files.");
throw new ValidationException("Cannot create asset.", error);
}
return new UploadAppImage { File = file[0].OpenReadStream, Image = new AppImage(file[0].ContentType) };
}
private static FileStream GetTempStream()
{
var tempFileName = Path.GetTempFileName();
return new FileStream(tempFileName,
FileMode.Create,
FileAccess.ReadWrite,
FileShare.Delete, 1024 * 16,
FileOptions.Asynchronous |
FileOptions.DeleteOnClose |
FileOptions.SequentialScan);
}
} }
} }

47
src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs

@ -36,6 +36,16 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
[RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")] [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")]
public string Name { get; set; } public string Name { get; set; }
/// <summary>
/// The optional label of the app.
/// </summary>
public string Label { get; set; }
/// <summary>
/// The optional description of the app.
/// </summary>
public string Description { get; set; }
/// <summary> /// <summary>
/// The version of the app. /// The version of the app.
/// </summary> /// </summary>
@ -88,7 +98,6 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
var result = SimpleMapper.Map(app, new AppDto()); var result = SimpleMapper.Map(app, new AppDto());
result.Permissions = permissions.ToIds(); result.Permissions = permissions.ToIds();
result.PlanName = plans.GetPlanForApp(app)?.Name;
if (controller.Includes(AllPermissions.ForApp(AllPermissions.AppApi, app.Name), permissions)) if (controller.Includes(AllPermissions.ForApp(AllPermissions.AppApi, app.Name), permissions))
{ {
@ -100,10 +109,8 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
result.CanAccessContent = true; result.CanAccessContent = true;
} }
if (controller.HasPermission(AllPermissions.AppPlansChange, app.Name)) result.SetPlan(app, plans, controller);
{ result.SetImage(app, controller);
result.PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name;
}
return result.CreateLinks(controller, permissions); return result.CreateLinks(controller, permissions);
} }
@ -125,6 +132,24 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
return new PermissionSet(permissions); return new PermissionSet(permissions);
} }
private void SetPlan(IAppEntity app, IAppPlansProvider plans, ApiController controller)
{
if (controller.HasPermission(AllPermissions.AppPlansChange, app.Name))
{
PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name;
}
PlanName = plans.GetPlanForApp(app)?.Name;
}
private void SetImage(IAppEntity app, ApiController controller)
{
if (app.Image != null)
{
AddGetLink("image", controller.Url<AppsController>(x => nameof(x.GetImage), new { app = app.Name }));
}
}
private AppDto CreateLinks(ApiController controller, PermissionSet permissions) private AppDto CreateLinks(ApiController controller, PermissionSet permissions)
{ {
var values = new { app = Name }; var values = new { app = Name };
@ -136,6 +161,18 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
AddDeleteLink("delete", controller.Url<AppsController>(x => nameof(x.DeleteApp), values)); AddDeleteLink("delete", controller.Url<AppsController>(x => nameof(x.DeleteApp), values));
} }
if (controller.HasPermission(AllPermissions.AppUpdateGeneral, Name, additional: permissions))
{
AddPutLink("update", controller.Url<AppsController>(x => nameof(x.UpdateApp), values));
}
if (controller.HasPermission(AllPermissions.AppUpdateImage, Name, additional: permissions))
{
AddPostLink("image/upload", controller.Url<AppsController>(x => nameof(x.UploadImage), values));
AddDeleteLink("image/delete", controller.Url<AppsController>(x => nameof(x.DeleteImage), values));
}
if (controller.HasPermission(AllPermissions.AppAssetsRead, Name, additional: permissions)) if (controller.HasPermission(AllPermissions.AppAssetsRead, Name, additional: permissions))
{ {
AddGetLink("assets", controller.Url<AssetsController>(x => nameof(x.GetAssets), values)); AddGetLink("assets", controller.Url<AssetsController>(x => nameof(x.GetAssets), values));

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

5
src/Squidex/Areas/Api/Controllers/Apps/Models/AssignContributorDto.cs

@ -8,6 +8,7 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.Reflection;
using Roles = Squidex.Domain.Apps.Core.Apps.Role;
namespace Squidex.Areas.Api.Controllers.Apps.Models namespace Squidex.Areas.Api.Controllers.Apps.Models
{ {
@ -22,7 +23,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// <summary> /// <summary>
/// The role of the contributor. /// The role of the contributor.
/// </summary> /// </summary>
public string Role { get; set; } public string Role { get; set; } = Roles.Developer;
/// <summary> /// <summary>
/// Set to true to invite the user if he does not exist. /// Set to true to invite the user if he does not exist.
@ -31,7 +32,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
public AssignContributor ToCommand() public AssignContributor ToCommand()
{ {
return SimpleMapper.Map(this, new AssignContributor { IsInviting = Invite }); return SimpleMapper.Map(this, new AssignContributor());
} }
} }
} }

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)

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

@ -0,0 +1,30 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Apps.Models
{
public sealed class UpdateAppDto
{
/// <summary>
/// The optional label of your app.
/// </summary>
public string Label { get; set; }
/// <summary>
/// The optional description of your app.
/// </summary>
public string Description { get; set; }
public UpdateApp ToCommand()
{
return SimpleMapper.Map(this, new UpdateApp());
}
}
}

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)

2
src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs

@ -91,7 +91,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
[Route("assets/{id}/")] [Route("assets/{id}/")]
[ProducesResponseType(typeof(FileResult), 200)] [ProducesResponseType(typeof(FileResult), 200)]
[ApiCosts(0.5)] [ApiCosts(0.5)]
public async Task<IActionResult> GetAssetContent(Guid id, string more, [FromQuery] AssetQuery query) public async Task<IActionResult> GetAssetContent(Guid id, [FromQuery] AssetQuery query)
{ {
var asset = await assetRepository.FindAssetAsync(id); var asset = await assetRepository.FindAssetAsync(id);

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

15
src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs

@ -34,16 +34,19 @@ namespace Squidex.Areas.Frontend.Middlewares
await next(context); await next(context);
context.Response.Body = responseBody; if (context.Response.StatusCode != 304)
{
context.Response.Body = responseBody;
var html = Encoding.UTF8.GetString(responseBuffer.ToArray()); var html = Encoding.UTF8.GetString(responseBuffer.ToArray());
html = html.AdjustHtml(context); html = html.AdjustHtml(context);
context.Response.ContentLength = Encoding.UTF8.GetByteCount(html); context.Response.ContentLength = Encoding.UTF8.GetByteCount(html);
context.Response.Body = responseBody; context.Response.Body = responseBody;
await context.Response.WriteAsync(html); await context.Response.WriteAsync(html);
}
} }
else else
{ {

15
src/Squidex/app-config/webpack.config.js

@ -18,8 +18,8 @@ const plugins = {
CircularDependencyPlugin: require('circular-dependency-plugin'), CircularDependencyPlugin: require('circular-dependency-plugin'),
// https://github.com/jantimon/html-webpack-plugin // https://github.com/jantimon/html-webpack-plugin
HtmlWebpackPlugin: require('html-webpack-plugin'), HtmlWebpackPlugin: require('html-webpack-plugin'),
// https://github.com/mishoo/UglifyJS2/tree/harmony // https://webpack.js.org/plugins/terser-webpack-plugin/
UglifyJsPlugin: require('uglifyjs-webpack-plugin'), TerserPlugin: require('terser-webpack-plugin'),
// https://www.npmjs.com/package/@ngtools/webpack // https://www.npmjs.com/package/@ngtools/webpack
NgToolsWebpack: require('@ngtools/webpack'), NgToolsWebpack: require('@ngtools/webpack'),
// https://github.com/NMFR/optimize-css-assets-webpack-plugin // https://github.com/NMFR/optimize-css-assets-webpack-plugin
@ -268,14 +268,15 @@ module.exports = function (env) {
if (isProduction) { if (isProduction) {
config.optimization = { config.optimization = {
minimizer: [ minimizer: [
new plugins.UglifyJsPlugin({ new plugins.TerserPlugin({
uglifyOptions: { terserOptions: {
compress: false, compress: true,
ecma: 6, ecma: 5,
mangle: true, mangle: true,
output: { output: {
comments: false comments: false
} },
safari10: true
}, },
extractComments: true extractComments: true
}), }),

4
src/Squidex/app/features/administration/pages/users/user-page.component.ts

@ -42,9 +42,9 @@ export class UserPageComponent extends ResourceOwner implements OnInit {
this.own( this.own(
this.usersState.selectedUser this.usersState.selectedUser
.subscribe(selectedUser => { .subscribe(selectedUser => {
this.user = selectedUser!;
if (selectedUser) { if (selectedUser) {
this.user = selectedUser;
this.isEditable = this.user.canUpdate; this.isEditable = this.user.canUpdate;
this.userForm.load(selectedUser); this.userForm.load(selectedUser);

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

2
src/Squidex/app/features/api/api-area.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | Settings" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Settings" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel theme="dark" desiredWidth="12rem"> <sqx-panel theme="dark" desiredWidth="12rem">
<ng-container title> <ng-container title>

2
src/Squidex/app/features/api/pages/graphql/graphql-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | API | GraphQL" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | API | GraphQL" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel desiredWidth="*" minWidth="50rem" isFullSize="true"> <sqx-panel desiredWidth="*" minWidth="50rem" isFullSize="true">
<div inner #graphiQLContainer></div> <div inner #graphiQLContainer></div>

33
src/Squidex/app/features/apps/pages/apps-page.component.html

@ -16,17 +16,28 @@
<div class="card card-href card-app float-left" *ngFor="let app of apps; trackBy: trackByApp" [routerLink]="['/app', app.name]"> <div class="card card-href card-app float-left" *ngFor="let app of apps; trackBy: trackByApp" [routerLink]="['/app', app.name]">
<div class="card-body"> <div class="card-body">
<h4 class="card-title">{{app.name}}</h4> <div class="row no-gutters">
<div class="col-auto card-left">
<div class="card-text"> <sqx-avatar [image]="app.image" [identifier]="app.name"></sqx-avatar>
<a [routerLink]="['/app', app.name]" sqxStopClick>Edit</a> </div>
<div class="col">
<span class="deeplinks"> <h4 class="card-title">{{app.displayName}}</h4>
&nbsp;|
<a [routerLink]="['/app', app.name, 'content']" sqxStopClick>Content</a> &middot; <div class="card-text card-links">
<a [routerLink]="['/app', app.name, 'assets']" sqxStopClick>Assets</a> &middot; <a [routerLink]="['/app', app.name]" sqxStopClick>Edit</a>
<a [routerLink]="['/app', app.name, 'settings']" sqxStopClick>Settings</a>
</span> <span class="deeplinks">
&nbsp;|
<a [routerLink]="['/app', app.name, 'content']" sqxStopClick>Content</a> &middot;
<a [routerLink]="['/app', app.name, 'assets']" sqxStopClick>Assets</a> &middot;
<a [routerLink]="['/app', app.name, 'settings']" sqxStopClick>Settings</a>
</span>
</div>
<div class="card-text">
{{app.description}}
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

19
src/Squidex/app/features/apps/pages/apps-page.component.scss

@ -21,7 +21,7 @@
& { & {
margin-right: 1rem; margin-right: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
width: 16rem; width: 20rem;
float: left; float: left;
} }
@ -29,6 +29,14 @@
width: 33rem; width: 33rem;
} }
&-links {
margin-top: .5rem;
}
&-left {
padding-right: .75rem;
}
&-image { &-image {
text-align: center; text-align: center;
} }
@ -37,6 +45,7 @@
color: $color-text-decent; color: $color-text-decent;
font-weight: normal; font-weight: normal;
font-size: .9rem; font-size: .9rem;
margin-top: .5rem;
} }
&-more { &-more {
@ -51,13 +60,19 @@
color: $color-title; color: $color-title;
font-weight: light; font-weight: light;
font-size: 1.2rem; font-size: 1.2rem;
margin-top: .4rem; margin-top: 0;
margin-bottom: 0;
} }
&-template { &-template {
.card-body { .card-body {
min-height: 15.5rem; min-height: 15.5rem;
} }
.card-title {
margin-top: 1rem;
margin-bottom: .75rem;
}
} }
&-href { &-href {

2
src/Squidex/app/features/assets/pages/assets-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | Assets" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Assets" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel desiredWidth="*" minWidth="50rem" showSidebar="true"> <sqx-panel desiredWidth="*" minWidth="50rem" showSidebar="true">
<ng-container title> <ng-container title>

2
src/Squidex/app/features/content/pages/content/content-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | {schema} | Content" parameter1="app" parameter2="schema" [value1]="appsState.appName" [value2]="schema.displayName"></sqx-title> <sqx-title message="{app} | {schema} | Content" parameter1="app" parameter2="schema" [value1]="appsState.appDisplayName" [value2]="schema.displayName"></sqx-title>
<form [formGroup]="contentForm.form" (ngSubmit)="saveAndPublish()"> <form [formGroup]="contentForm.form" (ngSubmit)="saveAndPublish()">
<sqx-panel desiredWidth="*" minWidth="60rem" [showSidebar]="content"> <sqx-panel desiredWidth="*" minWidth="60rem" [showSidebar]="content">

2
src/Squidex/app/features/content/pages/contents/contents-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | {schema} | Contents" parameter1="app" parameter2="schema" [value1]="appsState.appName" [value2]="schema.displayName"></sqx-title> <sqx-title message="{app} | {schema} | Contents" parameter1="app" parameter2="schema" [value1]="appsState.appDisplayName" [value2]="schema.displayName"></sqx-title>
<sqx-panel desiredWidth="*" minWidth="50rem" contentClass="grid" showSidebar="true"> <sqx-panel desiredWidth="*" minWidth="50rem" contentClass="grid" showSidebar="true">
<ng-container title> <ng-container title>

2
src/Squidex/app/features/content/pages/schemas/schemas-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | Schemas" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Schemas" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel theme="dark" desiredWidth="16rem" showSecondHeader="true"> <sqx-panel theme="dark" desiredWidth="16rem" showSecondHeader="true">
<ng-container title> <ng-container title>

4
src/Squidex/app/features/dashboard/pages/dashboard-page.component.html

@ -1,5 +1,5 @@
<ng-container *ngIf="app | async; let app"> <ng-container *ngIf="app | async; let app">
<sqx-title message="{app} | Dashboard" parameter1="app" [value1]="app.name"></sqx-title> <sqx-title message="{app} | Dashboard" parameter1="app" [value1]="app.displayName"></sqx-title>
<div class="dashboard" @fade> <div class="dashboard" @fade>
<div class="dashboard-inner"> <div class="dashboard-inner">
@ -7,7 +7,7 @@
<h1 class="dashboard-title">Hi {{authState.user?.displayName}}</h1> <h1 class="dashboard-title">Hi {{authState.user?.displayName}}</h1>
<div class="subtext"> <div class="subtext">
Welcome to <span class="app-name">{{app.name}}</span> dashboard. Welcome to <span class="app-name">{{app.displayName}}</span> dashboard.
</div> </div>
</div> </div>

2
src/Squidex/app/features/rules/pages/events/rule-events-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | Rules Events" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Rules Events" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel desiredWidth="63rem"> <sqx-panel desiredWidth="63rem">
<ng-container title> <ng-container title>

2
src/Squidex/app/features/rules/pages/rules/rules-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | Rules" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Rules" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel desiredWidth="54rem" showSidebar="true"> <sqx-panel desiredWidth="54rem" showSidebar="true">
<ng-container title> <ng-container title>

2
src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.scss

@ -1,6 +1,6 @@
@import '_vars'; @import '_vars';
@import '_mixins'; @import '_mixins';
texarea { textarea {
resize: none; resize: none;
} }

2
src/Squidex/app/features/schemas/pages/schema/schema-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | {schema}" parameter1="app" [value1]="appsState.appName" parameter2="schema" [value2]="schemasState.schemaName"></sqx-title> <sqx-title message="{app} | {schema}" parameter1="app" [value1]="appsState.appDisplayName" parameter2="schema" [value2]="schemasState.schemaName"></sqx-title>
<sqx-panel desiredWidth="60rem" [showSidebar]="true"> <sqx-panel desiredWidth="60rem" [showSidebar]="true">
<ng-container title> <ng-container title>

2
src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | Schemas" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Schemas" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel theme="dark" desiredWidth="30rem" showSecondHeader="true"> <sqx-panel theme="dark" desiredWidth="30rem" showSecondHeader="true">
<ng-container title> <ng-container title>

1
src/Squidex/app/features/settings/declarations.ts

@ -10,6 +10,7 @@ export * from './pages/backups/pipes';
export * from './pages/clients/client.component'; export * from './pages/clients/client.component';
export * from './pages/clients/clients-page.component'; export * from './pages/clients/clients-page.component';
export * from './pages/contributors/contributors-page.component'; export * from './pages/contributors/contributors-page.component';
export * from './pages/contributors/import-contributors-dialog.component';
export * from './pages/languages/language.component'; export * from './pages/languages/language.component';
export * from './pages/languages/languages-page.component'; export * from './pages/languages/languages-page.component';
export * from './pages/more/more-page.component'; export * from './pages/more/more-page.component';

7
src/Squidex/app/features/settings/module.ts

@ -21,6 +21,7 @@ import {
ClientComponent, ClientComponent,
ClientsPageComponent, ClientsPageComponent,
ContributorsPageComponent, ContributorsPageComponent,
ImportContributorsDialogComponent,
LanguageComponent, LanguageComponent,
LanguagesPageComponent, LanguagesPageComponent,
MorePageComponent, MorePageComponent,
@ -42,10 +43,7 @@ const routes: Routes = [
component: SettingsAreaComponent, component: SettingsAreaComponent,
children: [ children: [
{ {
path: '' path: '',
},
{
path: 'more',
component: MorePageComponent component: MorePageComponent
}, },
{ {
@ -203,6 +201,7 @@ const routes: Routes = [
ClientComponent, ClientComponent,
ClientsPageComponent, ClientsPageComponent,
ContributorsPageComponent, ContributorsPageComponent,
ImportContributorsDialogComponent,
LanguageComponent, LanguageComponent,
LanguagesPageComponent, LanguagesPageComponent,
MorePageComponent, MorePageComponent,

12
src/Squidex/app/features/settings/pages/backups/backups-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | Backups | Settings" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Backups | Settings" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel desiredWidth="50rem" [showSidebar]="true"> <sqx-panel desiredWidth="50rem" [showSidebar]="true">
<ng-container title> <ng-container title>
@ -34,15 +34,7 @@
<div class="table-items-row" *ngFor="let backup of backups; trackBy: trackByBackup"> <div class="table-items-row" *ngFor="let backup of backups; trackBy: trackByBackup">
<div class="row"> <div class="row">
<div class="col-auto" [ngSwitch]="backup.status"> <div class="col-auto" [ngSwitch]="backup.status">
<div *ngSwitchCase="'Failed'" class="backup-status backup-status-failed"> <sqx-status-icon size="lg" [status]="backup.status"></sqx-status-icon>
<i class="icon-exclamation"></i>
</div>
<div *ngSwitchCase="'Completed'" class="backup-status backup-status-success">
<i class="icon-checkmark"></i>
</div>
<div *ngSwitchDefault class="backup-status backup-status-pending spin">
<i class="icon-hour-glass"></i>
</div>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<div> <div>

29
src/Squidex/app/features/settings/pages/backups/backups-page.component.scss

@ -1,29 +1,2 @@
@import '_vars'; @import '_vars';
@import '_mixins'; @import '_mixins';
$circle-size: 2.8rem;
.backup-status {
& {
@include circle($circle-size);
line-height: $circle-size + .1rem;
text-align: center;
font-size: .4 * $circle-size;
font-weight: normal;
background: $color-border;
color: $color-dark-foreground;
vertical-align: middle;
}
&-pending {
color: inherit;
}
&-failed {
background: $color-theme-error;
}
&-success {
background: $color-theme-green;
}
}

2
src/Squidex/app/features/settings/pages/clients/clients-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | Clients | Settings" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Clients | Settings" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel desiredWidth="50rem" [showSidebar]="true"> <sqx-panel desiredWidth="50rem" [showSidebar]="true">
<ng-container title> <ng-container title>

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

@ -1,4 +1,4 @@
<sqx-title message="{app} | Contributors | Settings" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Contributors | Settings" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel desiredWidth="50rem" [showSidebar]="true"> <sqx-panel desiredWidth="50rem" [showSidebar]="true">
<ng-container title> <ng-container title>
@ -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">
@ -74,6 +90,10 @@
</div> </div>
</form> </form>
</div> </div>
<sqx-form-hint class="text-right">
Big team? <a class="force" (click)="importDialog.show()">Hide many contributors at once</a>
</sqx-form-hint>
</ng-container> </ng-container>
</ng-container> </ng-container>
</ng-container> </ng-container>
@ -92,4 +112,10 @@
</ng-container> </ng-container>
</sqx-panel> </sqx-panel>
<ng-container *sqxModal="importDialog">
<sqx-import-contributors-dialog
(close)="importDialog.hide()">
</sqx-import-contributors-dialog>
</ng-container>
<router-outlet></router-outlet> <router-outlet></router-outlet>

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

@ -16,10 +16,9 @@ import {
AutocompleteSource, AutocompleteSource,
ContributorDto, ContributorDto,
ContributorsState, ContributorsState,
DialogModel,
DialogService, DialogService,
RolesState, RolesState,
Types,
UserDto,
UsersService UsersService
} from '@app/shared'; } from '@app/shared';
@ -57,6 +56,8 @@ export class UsersDataSource implements AutocompleteSource {
export class ContributorsPageComponent implements OnInit { export class ContributorsPageComponent implements OnInit {
public assignContributorForm = new AssignContributorForm(this.formBuilder); public assignContributorForm = new AssignContributorForm(this.formBuilder);
public importDialog = new DialogModel();
constructor( constructor(
public readonly appsState: AppsState, public readonly appsState: AppsState,
public readonly contributorsState: ContributorsState, public readonly contributorsState: ContributorsState,
@ -77,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);
} }
@ -89,15 +102,7 @@ export class ContributorsPageComponent implements OnInit {
const value = this.assignContributorForm.submit(); const value = this.assignContributorForm.submit();
if (value) { if (value) {
let user = value.user; this.contributorsState.assign(value)
if (Types.is(user, UserDto)) {
user = user.id;
}
const requestDto = { contributorId: user, role: 'Editor', invite: true };
this.contributorsState.assign(requestDto)
.subscribe(isCreated => { .subscribe(isCreated => {
this.assignContributorForm.submitCompleted(); this.assignContributorForm.submitCompleted();

43
src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.html

@ -0,0 +1,43 @@
<form [formGroup]="importForm.form" (ngSubmit)="import()">
<sqx-modal-dialog (close)="emitClose()">
<ng-container title>
Import contributors
</ng-container>
<ng-container content>
<div class="content">
<ng-container *ngIf="importStatus; else noStatus">
<div class="row pb-2" *ngFor="let status of importStatus">
<div class="col truncate">
{{status.email}}
</div>
<div class="col-auto">
<sqx-status-icon size="sm"
[status]="status.result"
[statusText]="status.resultText">
</sqx-status-icon>
</div>
</div>
</ng-container>
<ng-template #noStatus>
<textarea class="form-control content" placeholder="user1@squidex.io;user2.squidex.io" formControlName="import"></textarea>
</ng-template>
</div>
<sqx-form-hint>
<ng-container *ngIf="importForm.numberOfEmails | async; let emails">
Emails detected: {{emails}}
</ng-container>
&nbsp;
</sqx-form-hint>
</ng-container>
<ng-container footer>
<button type="reset" class="float-left btn btn-secondary" (click)="emitClose()">Cancel</button>
<button type="submit" class="float-right btn btn-success" [disabled]="importStatus || (importForm.hasNoUser | async)">Add Contributors</button>
</ng-container>
</sqx-modal-dialog>
</form>

10
src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.scss

@ -0,0 +1,10 @@
@import '_vars';
@import '_mixins';
textarea {
resize: none;
}
.content {
min-height: 300px;
}

96
src/Squidex/app/features/settings/pages/contributors/import-contributors-dialog.component.ts

@ -0,0 +1,96 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, EventEmitter, Output } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { empty, of } from 'rxjs';
import { catchError, mergeMap, tap } from 'rxjs/operators';
import {
ContributorsState,
ErrorDto,
ImportContributorsForm
} from '@app/shared';
interface ImportStatus {
email: string;
result: 'Pending' | 'Failed' | 'Success';
resultText: string;
}
@Component({
selector: 'sqx-import-contributors-dialog',
styleUrls: ['./import-contributors-dialog.component.scss'],
templateUrl: './import-contributors-dialog.component.html'
})
export class ImportContributorsDialogComponent {
@Output()
public close = new EventEmitter();
public importForm = new ImportContributorsForm(this.formBuilder);
public importStatus: ImportStatus[];
constructor(
private readonly formBuilder: FormBuilder,
private readonly contributorsState: ContributorsState
) {
}
public import() {
const contributors = this.importForm.submit();
if (contributors && contributors.length > 0) {
this.importStatus = [];
for (let contributor of contributors) {
this.importStatus.push({
email: contributor.contributorId,
result: 'Pending',
resultText: 'Pending'
});
}
of(...contributors).pipe(
mergeMap(c =>
this.contributorsState.assign(c, { silent: true }).pipe(
tap(created => {
let status = this.importStatus.find(x => x.email === c.contributorId);
if (status) {
status.resultText = getSuccess(created);
status.result = 'Success';
}
}),
catchError((error: ErrorDto) => {
let status = this.importStatus.find(x => x.email === c.contributorId);
if (status) {
status.resultText = getError(error);
status.result = 'Failed';
}
return empty();
})
), 1)
).subscribe();
}
}
public emitClose() {
this.close.emit();
}
}
function getError(error: ErrorDto): string {
return error.details[0];
}
function getSuccess(created: boolean | undefined): string {
return created ?
'User has been invited and assigned.' :
'User has been assigned';
}

2
src/Squidex/app/features/settings/pages/languages/languages-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | Languages | Settings" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Languages | Settings" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel desiredWidth="50rem" [showSidebar]="true"> <sqx-panel desiredWidth="50rem" [showSidebar]="true">
<ng-container title> <ng-container title>

88
src/Squidex/app/features/settings/pages/more/more-page.component.html

@ -1,11 +1,91 @@
<sqx-title message="{app} | More | Settings" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | More | Settings" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel desiredWidth="50rem"> <sqx-panel desiredWidth="46rem">
<ng-container title> <ng-container title>
More Settings
</ng-container> </ng-container>
<ng-container content> <ng-container content>
<div class="card mb-4">
<h3 class="card-header">General</h3>
<div class="card-body">
<form [formGroup]="updateForm.form" (ngSubmit)="save()">
<sqx-form-error [error]="updateForm.error | async"></sqx-form-error>
<div class="form-group">
<label for="email">Name</label>
<input type="text" class="form-control" readonly [value]="app.name" />
</div>
<div class="form-group">
<label for="label">Label</label>
<sqx-control-errors for="label"></sqx-control-errors>
<input type="text" class="form-control" id="label" maxlength="100" formControlName="label" />
</div>
<div class="form-group">
<label for="description">Description</label>
<sqx-control-errors for="description"></sqx-control-errors>
<input type="text" class="form-control" id="description" maxlength="100" formControlName="description" />
</div>
<div class="form-group">
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
<div class="card mb-4">
<h3 class="card-header">Image</h3>
<div class="card-body">
<div class="row">
<div class="col-auto">
<div class="app-image"
(sqxDropFile)="uploadImage($event)"
[sqxDropDisabled]="uploading || !isImageEditable"
[noDrop]="true">
<div class="app-progress" *ngIf="uploading; else notUploading">
<sqx-progress-bar mode="Circle" [value]="uploadProgress"></sqx-progress-bar>
</div>
<ng-template #notUploading>
<div>
<sqx-avatar [image]="app.image" [identifier]="app.name" [size]="150"></sqx-avatar>
<ng-container *ngIf="isImageEditable && app.image">
<button class="btn btn-danger btn-sm app-image-remove" title="Remove image" (click)="removeImage()">
<i class="icon-bin2"></i>
</button>
</ng-container>
</div>
<div class="drop-overlay align-items-center justify-content-center">
<div class="drop-overlay-background"></div>
<div class="drop-overlay-text">Drop to update</div>
</div>
</ng-template>
</div>
</div>
<div class="auto align-self-center pl-4">
<sqx-form-hint>Drop an file to replace the app image. Use a square size.</sqx-form-hint>
<span class="btn btn-success upload-button" [class.disabled]="!isImageEditable" (click)="fileInput.click()">
<span>Upload File</span>
<input type="file" (change)="uploadImage($event.target.files)" #fileInput single accept="image/x-png,image/gif,image/jpeg" />
</span>
</div>
</div>
</div>
</div>
<div class="card"> <div class="card">
<h3 class="card-header">Danger Zone</h3> <h3 class="card-header">Danger Zone</h3>
@ -19,7 +99,7 @@
</sqx-form-hint> </sqx-form-hint>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<button type="button" class="btn btn-danger" <button type="button" class="btn btn-danger" [disabled]="!isDeletable"
(sqxConfirmClick)="archiveApp()" (sqxConfirmClick)="archiveApp()"
confirmTitle="Archive App" confirmTitle="Archive App"
confirmText="Do you really want to archive this app?"> confirmText="Do you really want to archive this app?">

71
src/Squidex/app/features/settings/pages/more/more-page.component.scss

@ -1,2 +1,71 @@
@import '_vars'; @import '_vars';
@import '_mixins'; @import '_mixins';
.app {
&-image {
position: relative;
min-width: 150px;
min-height: 150px;
}
&-image-remove {
@include absolute(auto, .5rem, .5rem, auto);
}
&-progress {
@include absolute(1rem, 1rem, 1rem, 1rem);
}
}
@mixin overlay {
& {
@include transition(opacity .4s ease);
@include absolute(0, 0, 0, 0);
@include opacity(0);
@include flex-box;
color: $color-dark-foreground;
}
&-background {
@include absolute(0, 0, 0, 0);
@include opacity(.7);
background: $color-dark-black;
}
}
.upload-button {
& {
margin-top: 1rem;
}
input {
@include hidden;
}
}
.drop-overlay {
& {
@include overlay;
pointer-events: none;
}
&-text {
position: absolute;
font-size: 1.25rem;
font-weight: lighter;
}
}
.drag {
.drop-overlay {
@include opacity(1);
}
.app-image-remove {
display: none;
}
}
.disabled {
pointer-events: none;
}

94
src/Squidex/app/features/settings/pages/more/more-page.component.ts

@ -5,25 +5,111 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Component } from '@angular/core'; import { Component, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { AppsState } from '@app/shared'; import {
AppDto,
AppsState,
ResourceOwner,
Types,
UpdateAppForm
} from '@app/shared';
@Component({ @Component({
selector: 'sqx-more-page', selector: 'sqx-more-page',
styleUrls: ['./more-page.component.scss'], styleUrls: ['./more-page.component.scss'],
templateUrl: './more-page.component.html' templateUrl: './more-page.component.html'
}) })
export class MorePageComponent { export class MorePageComponent extends ResourceOwner implements OnInit {
public app: AppDto;
public isEditable: boolean;
public isImageEditable: boolean;
public isDeletable: boolean;
public uploading = false;
public uploadProgress = 10;
public updateForm = new UpdateAppForm(this.formBuilder);
constructor( constructor(
public readonly appsState: AppsState, public readonly appsState: AppsState,
private readonly formBuilder: FormBuilder,
private readonly router: Router private readonly router: Router
) { ) {
super();
}
public ngOnInit() {
this.own(
this.appsState.selectedApp
.subscribe(app => {
if (app) {
this.app = app;
this.isDeletable = app.canDelete;
this.isEditable = app.canUpdateGeneral;
this.isImageEditable = app.canUpdateImage;
this.updateForm.load(app);
this.updateForm.setEnabled(this.isEditable);
}
}));
}
public save() {
if (!this.isEditable) {
return;
}
const value = this.updateForm.submit();
if (value) {
this.appsState.update(this.app, value)
.subscribe(user => {
this.updateForm.submitCompleted({ newValue: user });
}, error => {
this.updateForm.submitFailed(error);
});
}
}
public uploadImage(file: File[]) {
if (!this.isImageEditable) {
return;
}
this.uploading = true;
this.uploadProgress = 0;
this.appsState.uploadImage(this.app, file[0])
.subscribe(value => {
if (Types.isNumber(value)) {
this.uploadProgress = value;
}
}, () => {
this.uploading = false;
}, () => {
this.uploading = false;
});
}
public removeImage() {
if (!this.isImageEditable) {
return;
}
this.appsState.removeImage(this.app);
} }
public archiveApp() { public archiveApp() {
this.appsState.delete(this.appsState.selectedAppState!) if (!this.isDeletable) {
return;
}
this.appsState.delete(this.app)
.subscribe(() => { .subscribe(() => {
this.router.navigate(['/app']); this.router.navigate(['/app']);
}); });

2
src/Squidex/app/features/settings/pages/patterns/patterns-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | Patterns | Settings" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Patterns | Settings" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel desiredWidth="63rem" [showSidebar]="true"> <sqx-panel desiredWidth="63rem" [showSidebar]="true">
<ng-container title> <ng-container title>

2
src/Squidex/app/features/settings/pages/plans/plans-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | Plans | Settings" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Plans | Settings" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel desiredWidth="64rem" [showSidebar]="true" [scrollX]="true"> <sqx-panel desiredWidth="64rem" [showSidebar]="true" [scrollX]="true">
<ng-container title> <ng-container title>

2
src/Squidex/app/features/settings/pages/roles/roles-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | Roles | Settings" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Roles | Settings" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel desiredWidth="50rem" [showSidebar]="true"> <sqx-panel desiredWidth="50rem" [showSidebar]="true">
<ng-container title> <ng-container title>

2
src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html

@ -1,4 +1,4 @@
<sqx-title message="{app} | Workflows | Settings" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Workflows | Settings" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel desiredWidth="60rem" [showSidebar]="true" [isLazyLoaded]="false"> <sqx-panel desiredWidth="60rem" [showSidebar]="true" [isLazyLoaded]="false">
<ng-container title> <ng-container title>

12
src/Squidex/app/features/settings/settings-area.component.html

@ -1,8 +1,10 @@
<sqx-title message="{app} | Settings" parameter1="app" [value1]="appsState.appName"></sqx-title> <sqx-title message="{app} | Settings" parameter1="app" [value1]="appsState.appDisplayName"></sqx-title>
<sqx-panel theme="dark" desiredWidth="16rem"> <sqx-panel theme="dark" desiredWidth="16rem">
<ng-container title> <ng-container title>
Settings <a class="header-link" routerLink="./">
Settings
</a>
</ng-container> </ng-container>
<ng-container content> <ng-container content>
@ -55,12 +57,6 @@
<i class="icon-angle-right"></i> <i class="icon-angle-right"></i>
</a> </a>
</li> </li>
<li class="nav-item" *ngIf="selectedApp.canDelete">
<a class="nav-link" routerLink="more" routerLinkActive="active">
More
<i class="icon-angle-right"></i>
</a>
</li>
</ul> </ul>
</ng-container> </ng-container>
</sqx-panel> </sqx-panel>

10
src/Squidex/app/features/settings/settings-area.component.scss

@ -7,6 +7,16 @@
padding-bottom: .6rem; padding-bottom: .6rem;
} }
.header-link {
& {
color: inherit;
}
&:hover {
text-decoration: none;
}
}
.icon-angle-right { .icon-angle-right {
@include absolute(14px, 2rem, auto, auto); @include absolute(14px, 2rem, auto, auto);
} }

46
src/Squidex/app/framework/angular/avatar.component.ts

@ -0,0 +1,46 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, Input, OnChanges } from '@angular/core';
import { picasso } from '@app/framework/internal';
@Component({
selector: 'sqx-avatar',
template: `
<img
[style.width]="sizeInPx"
[style.height]="sizeInPx"
[attr.src]="imageSource | sqxSafeUrl"
/>
`
})
export class AvatarComponent implements OnChanges {
public imageSource: string;
public sizeInPx: string;
@Input()
public identifier: string;
@Input()
public image: string;
@Input()
public size = 50;
public ngOnChanges() {
this.imageSource = this.image || this.createSvg();
this.sizeInPx = `${this.size}px`;
}
private createSvg() {
const svg = picasso(this.identifier);
return `data:image/svg+xml;utf8,${svg}`;
}
}

8
src/Squidex/app/framework/angular/forms/control-errors.component.ts

@ -75,15 +75,17 @@ export class ControlErrorsComponent extends StatefulComponent<State> implements
} }
} }
let control: AbstractControl | null; let control: AbstractControl | null = null;
if (Types.isString(this.for)) { if (Types.isString(this.for)) {
control = this.formGroupDirective.form.controls[this.for]; if (this.formGroupDirective && this.formGroupDirective.form) {
control = this.formGroupDirective.form.controls[this.for];
}
} else { } else {
control = this.for; control = this.for;
} }
if (this.control !== control) { if (this.control !== control && control) {
this.unsubscribeAll(); this.unsubscribeAll();
this.unsetCustomMarkAsTouchedFunction(); this.unsetCustomMarkAsTouchedFunction();

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

9
src/Squidex/app/framework/angular/forms/form-hint.component.ts

@ -5,14 +5,17 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { ChangeDetectionStrategy, Component } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({ @Component({
selector: 'sqx-form-hint', selector: 'sqx-form-hint',
template: ` template: `
<small class="text-muted form-text"> <small class="text-muted form-text {{class}}">
<ng-content></ng-content> <ng-content></ng-content>
</small>`, </small>`,
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class FormHintComponent {} export class FormHintComponent {
@Input()
public class: string;
}

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

16
src/Squidex/app/framework/angular/http/http-extensions.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { HttpClient, HttpErrorResponse, HttpHeaders, HttpResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse, HttpEvent, HttpHeaders, HttpRequest, HttpResponse } from '@angular/common/http';
import { Observable, throwError } from 'rxjs'; import { Observable, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators'; import { catchError, map } from 'rxjs/operators';
@ -17,6 +17,12 @@ import {
} from '@app/framework/internal'; } from '@app/framework/internal';
export module HTTP { export module HTTP {
export function upload<T = any>(http: HttpClient, method: string, url: string, file: File, version?: Version): Observable<HttpEvent<T>> {
const req = new HttpRequest(method, url, getFormData(file), { headers: createHeaders(version), reportProgress: true });
return http.request<T>(req);
}
export function getVersioned<T = any>(http: HttpClient, url: string, version?: Version): Observable<Versioned<HttpResponse<T>>> { export function getVersioned<T = any>(http: HttpClient, url: string, version?: Version): Observable<Versioned<HttpResponse<T>>> {
const headers = createHeaders(version); const headers = createHeaders(version);
@ -53,6 +59,14 @@ export module HTTP {
return handleVersion(http.request<T>(method, url, { observe: 'response', headers, body })); return handleVersion(http.request<T>(method, url, { observe: 'response', headers, body }));
} }
function getFormData(file: File) {
const formData = new FormData();
formData.append('file', file);
return formData;
}
function createHeaders(version?: Version): HttpHeaders { function createHeaders(version?: Version): HttpHeaders {
if (version && version.value && version.value.length > 0) { if (version && version.value && version.value.length > 0) {
return new HttpHeaders().set('If-Match', version.value); return new HttpHeaders().set('If-Match', version.value);

15
src/Squidex/app/framework/angular/safe-html.pipe.ts

@ -21,4 +21,19 @@ export class SafeHtmlPipe implements PipeTransform {
public transform(html: string): SafeHtml { public transform(html: string): SafeHtml {
return this.domSanitizer.bypassSecurityTrustHtml(html); return this.domSanitizer.bypassSecurityTrustHtml(html);
} }
}
@Pipe({
name: 'sqxSafeUrl',
pure: true
})
export class SafeUrlPipe implements PipeTransform {
constructor(
public readonly domSanitizer: DomSanitizer
) {
}
public transform(url: string): SafeHtml {
return this.domSanitizer.bypassSecurityTrustUrl(url);
}
} }

14
src/Squidex/app/framework/angular/status-icon.component.html

@ -0,0 +1,14 @@
<ng-container [ngSwitch]="status">
<div *ngSwitchCase="'Failed'" class="status status-failed {{size}}" [title]="statusText">
<i class="icon-exclamation"></i>
</div>
<div *ngSwitchCase="'Success'" class="status status-success {{size}}" [title]="statusText">
<i class="icon-checkmark"></i>
</div>
<div *ngSwitchCase="'Completed'" class="status status-success {{size}}" [title]="statusText">
<i class="icon-checkmark"></i>
</div>
<div *ngSwitchDefault class="status status-pending spin {{size}}" [title]="statusText">
<i class="icon-hour-glass"></i>
</div>
</ng-container>

41
src/Squidex/app/framework/angular/status-icon.component.scss

@ -0,0 +1,41 @@
@import '_mixins';
@import '_vars';
$circle-size-sm: 1.6rem;
$circle-size-lg: 2.8rem;
.status {
& {
text-align: center;
background: $color-border;
color: $color-dark-foreground;
vertical-align: middle;
}
&.sm {
@include circle($circle-size-sm);
line-height: $circle-size-sm + .1rem;
font-size: .5 * $circle-size-sm;
font-weight: normal;
}
&.lg {
@include circle($circle-size-lg);
line-height: $circle-size-lg + .1rem;
font-size: .5 * $circle-size-lg;
font-weight: normal;
}
&-pending {
color: inherit;
}
&-failed {
background: $color-theme-error;
}
&-success {
background: $color-theme-green;
}
}

25
src/Squidex/app/framework/angular/status-icon.component.ts

@ -0,0 +1,25 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
selector: 'sqx-status-icon',
styleUrls: ['./status-icon.component.scss'],
templateUrl: './status-icon.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class StatusIconComponent {
@Input()
public status: 'Started' | 'Failed' | 'Success' | 'Completed' | 'Failed' | 'Pending';
@Input()
public statusText: string;
@Input()
public size: 'lg' | 'sm' = 'lg';
}

8
src/Squidex/app/framework/declarations.ts

@ -53,20 +53,22 @@ export * from './angular/pipes/numbers.pipes';
export * from './angular/routers/can-deactivate.guard'; export * from './angular/routers/can-deactivate.guard';
export * from './angular/routers/parent-link.directive'; export * from './angular/routers/parent-link.directive';
export * from './angular/avatar.component';
export * from './angular/code.component'; export * from './angular/code.component';
export * from './angular/external-link.directive'; export * from './angular/external-link.directive';
export * from './angular/hover-background.directive';
export * from './angular/highlight.pipe'; export * from './angular/highlight.pipe';
export * from './angular/hover-background.directive';
export * from './angular/ignore-scrollbar.directive'; export * from './angular/ignore-scrollbar.directive';
export * from './angular/image-source.directive'; export * from './angular/image-source.directive';
export * from './angular/panel.component';
export * from './angular/panel-container.directive';
export * from './angular/pager.component'; export * from './angular/pager.component';
export * from './angular/panel-container.directive';
export * from './angular/panel.component';
export * from './angular/popup-link.directive'; export * from './angular/popup-link.directive';
export * from './angular/safe-html.pipe'; export * from './angular/safe-html.pipe';
export * from './angular/scroll-active.directive'; export * from './angular/scroll-active.directive';
export * from './angular/shortcut.component'; export * from './angular/shortcut.component';
export * from './angular/sorted.directive'; export * from './angular/sorted.directive';
export * from './angular/status-icon.component';
export * from './angular/stop-click.directive'; export * from './angular/stop-click.directive';
export * from './angular/sync-scrolling.directive'; export * from './angular/sync-scrolling.directive';
export * from './angular/template-wrapper.directive'; export * from './angular/template-wrapper.directive';

1
src/Squidex/app/framework/internal.ts

@ -31,6 +31,7 @@ export * from './utils/math-helper';
export * from './utils/modal-positioner'; export * from './utils/modal-positioner';
export * from './utils/modal-view'; export * from './utils/modal-view';
export * from './utils/pager'; export * from './utils/pager';
export * from './utils/picasso';
export * from './utils/rxjs-extensions'; export * from './utils/rxjs-extensions';
export * from './utils/string-helper'; export * from './utils/string-helper';
export * from './utils/types'; export * from './utils/types';

9
src/Squidex/app/framework/module.ts

@ -14,6 +14,7 @@ import { ColorPickerModule } from 'ngx-color-picker';
import { import {
AnalyticsService, AnalyticsService,
AutocompleteComponent, AutocompleteComponent,
AvatarComponent,
CachingInterceptor, CachingInterceptor,
CanDeactivateGuard, CanDeactivateGuard,
CheckboxGroupComponent, CheckboxGroupComponent,
@ -75,6 +76,7 @@ import {
ResourceLoaderService, ResourceLoaderService,
RootViewComponent, RootViewComponent,
SafeHtmlPipe, SafeHtmlPipe,
SafeUrlPipe,
ScrollActiveDirective, ScrollActiveDirective,
ShortcutComponent, ShortcutComponent,
ShortcutService, ShortcutService,
@ -82,6 +84,7 @@ import {
ShortTimePipe, ShortTimePipe,
SortedDirective, SortedDirective,
StarsComponent, StarsComponent,
StatusIconComponent,
StopClickDirective, StopClickDirective,
SyncScollingDirective, SyncScollingDirective,
TagEditorComponent, TagEditorComponent,
@ -102,6 +105,7 @@ import {
], ],
declarations: [ declarations: [
AutocompleteComponent, AutocompleteComponent,
AvatarComponent,
CheckboxGroupComponent, CheckboxGroupComponent,
ColorPickerComponent, ColorPickerComponent,
ConfirmClickDirective, ConfirmClickDirective,
@ -153,12 +157,14 @@ import {
ProgressBarComponent, ProgressBarComponent,
RootViewComponent, RootViewComponent,
SafeHtmlPipe, SafeHtmlPipe,
SafeUrlPipe,
ScrollActiveDirective, ScrollActiveDirective,
ShortcutComponent, ShortcutComponent,
ShortDatePipe, ShortDatePipe,
ShortTimePipe, ShortTimePipe,
SortedDirective, SortedDirective,
StarsComponent, StarsComponent,
StatusIconComponent,
StopClickDirective, StopClickDirective,
SyncScollingDirective, SyncScollingDirective,
TagEditorComponent, TagEditorComponent,
@ -170,6 +176,7 @@ import {
], ],
exports: [ exports: [
AutocompleteComponent, AutocompleteComponent,
AvatarComponent,
CheckboxGroupComponent, CheckboxGroupComponent,
CodeEditorComponent, CodeEditorComponent,
CommonModule, CommonModule,
@ -224,12 +231,14 @@ import {
ReactiveFormsModule, ReactiveFormsModule,
RootViewComponent, RootViewComponent,
SafeHtmlPipe, SafeHtmlPipe,
SafeUrlPipe,
ScrollActiveDirective, ScrollActiveDirective,
ShortcutComponent, ShortcutComponent,
ShortDatePipe, ShortDatePipe,
ShortTimePipe, ShortTimePipe,
SortedDirective, SortedDirective,
StarsComponent, StarsComponent,
StatusIconComponent,
StopClickDirective, StopClickDirective,
SyncScollingDirective, SyncScollingDirective,
TagEditorComponent, TagEditorComponent,

10
src/Squidex/app/framework/utils/hateos.ts

@ -17,7 +17,7 @@ export type ResourceLink = { href: string; method: ResourceMethod; metadata?: st
export type Metadata = { [rel: string]: string }; export type Metadata = { [rel: string]: string };
export function hasAnyLink(value: Resource | ResourceLinks, ...rels: string[]) { export function getLinkUrl(value: Resource | ResourceLinks, ...rels: string[]) {
if (!value) { if (!value) {
return false; return false;
} }
@ -28,11 +28,15 @@ export function hasAnyLink(value: Resource | ResourceLinks, ...rels: string[])
const link = links[rel]; const link = links[rel];
if (link && link.method && link.href) { if (link && link.method && link.href) {
return true; return link.href;
} }
} }
return false; return undefined;
}
export function hasAnyLink(value: Resource | ResourceLinks, ...rels: string[]) {
return !!getLinkUrl(value, ...rels);
} }
export type ResourceMethod = export type ResourceMethod =

71
src/Squidex/app/framework/utils/picasso.ts

@ -0,0 +1,71 @@
import MersenneTwister from 'mersenne-twister';
const ALL_COLORS = [
'rgb(226,27,12)',
'rgb(192,19,78)',
'rgb(125,31,141)',
'rgb(82,46,146)',
'rgb(50,65,145)',
'rgb(11,122,209)',
'rgb(2,135,195)',
'rgb(0,150,170)',
'rgb(0,120,109)',
'rgb(61,140,64)',
'rgb(112,162,54)',
'rgb(174,188,33)',
'rgb(210,157,0)',
'rgb(204,122,0)',
'rgb(231,55,0)'
];
const LAYERS = 3;
const RADIUSES = [20, 25, 30, 35, 40, 45, 50];
const X_CENTERS = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
const Y_CENTERS = [30, 40, 50, 60, 70];
function hash(str: string) {
if (str.length === 0) {
return 0;
}
let result = 0;
for (let i = 0; i < str.length; i++) {
result = result * 31 + str.charCodeAt(i);
result = result % (2 ** 32);
}
return result;
}
export function picasso(content: string) {
const seed = hash(content);
const rand = new MersenneTwister(seed);
const colors = [...ALL_COLORS];
const generateColor = () => {
const idx = Math.floor(colors.length * rand.random());
return colors.splice(idx, 1)[0];
};
const background = `<rect fill="${generateColor()}" width="100" height="100"/>`;
let shape = '';
for (let i = 0; i < LAYERS; i++) {
const rd = RADIUSES[Math.floor(RADIUSES.length * rand.random())];
const cx = X_CENTERS[Math.floor(X_CENTERS.length * rand.random())];
const cy = Y_CENTERS[Math.floor(Y_CENTERS.length * rand.random())];
const fill = generateColor();
shape += `<circle r="${rd}" cx="${cx}" cy="${cy}" fill="${fill}" opacity="0."/>`;
}
return `<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">${background}${shape}</svg>`;
}

124
src/Squidex/app/shared/services/apps.service.spec.ts

@ -14,11 +14,15 @@ import {
AppDto, AppDto,
AppsService, AppsService,
DateTime, DateTime,
ErrorDto,
Resource, Resource,
ResourceLinks ResourceLinks,
Version
} from '@app/shared/internal'; } from '@app/shared/internal';
describe('AppsService', () => { describe('AppsService', () => {
const version = new Version('1');
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
@ -79,6 +83,110 @@ describe('AppsService', () => {
expect(app!).toEqual(createApp(12)); expect(app!).toEqual(createApp(12));
})); }));
it('should make put request to update app',
inject([AppsService, HttpTestingController], (appsService: AppsService, httpMock: HttpTestingController) => {
const resource: Resource = {
_links: {
update: { method: 'PUT', href: '/api/apps/my-app' }
}
};
let app: AppDto;
appsService.putApp(resource, { }, version).subscribe(result => {
app = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(appResponse(12));
expect(app!).toEqual(createApp(12));
}));
it('should make post request to upload app image',
inject([AppsService, HttpTestingController], (appsService: AppsService, httpMock: HttpTestingController) => {
const resource: Resource = {
_links: {
['image/upload']: { method: 'POST', href: '/api/apps/my-app/image' }
}
};
let app: AppDto;
appsService.postAppImage(resource, null!, version).subscribe(result => {
app = <AppDto>result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/image');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(appResponse(12));
expect(app!).toEqual(createApp(12));
}));
it('should return proper error when uploading app image failed with 413',
inject([AppsService, HttpTestingController], (appsService: AppsService, httpMock: HttpTestingController) => {
const resource: Resource = {
_links: {
['image/upload']: { method: 'POST', href: '/api/apps/my-app/image' }
}
};
let app: AppDto;
let error: ErrorDto;
appsService.postAppImage(resource, null!, version).subscribe(result => {
app = <AppDto>result;
}, e => {
error = e;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/image');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush({}, { status: 413, statusText: 'Payload too large' });
expect(app!).toBeUndefined();
expect(error!).toEqual(new ErrorDto(413, 'App image is too big.'));
}));
it('should make delete request to remove app image',
inject([AppsService, HttpTestingController], (appsService: AppsService, httpMock: HttpTestingController) => {
const resource: Resource = {
_links: {
['image/delete']: { method: 'DELETE', href: '/api/apps/my-app/image' }
}
};
let app: AppDto;
appsService.deleteAppImage(resource, version).subscribe(result => {
app = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/image');
expect(req.request.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(appResponse(12));
expect(app!).toEqual(createApp(12));
}));
it('should make delete request to archive app', it('should make delete request to archive app',
inject([AppsService, HttpTestingController], (appsService: AppsService, httpMock: HttpTestingController) => { inject([AppsService, HttpTestingController], (appsService: AppsService, httpMock: HttpTestingController) => {
@ -101,7 +209,9 @@ describe('AppsService', () => {
function appResponse(id: number, suffix = '') { function appResponse(id: number, suffix = '') {
return { return {
id: `id${id}`, id: `id${id}`,
name: `name${id}${suffix}`, name: `my-name${id}${suffix}`,
label: `my-label${id}${suffix}`,
description: `my-description${id}${suffix}`,
permissions: ['Owner'], permissions: ['Owner'],
created: `${id % 1000 + 2000}-12-12T10:10:00`, created: `${id % 1000 + 2000}-12-12T10:10:00`,
lastModified: `${id % 1000 + 2000}-11-11T10:10:00`, lastModified: `${id % 1000 + 2000}-11-11T10:10:00`,
@ -109,6 +219,7 @@ describe('AppsService', () => {
canAccessContent: id % 2 === 0, canAccessContent: id % 2 === 0,
planName: 'Free', planName: 'Free',
planUpgrade: 'Basic', planUpgrade: 'Basic',
version: id,
_links: { _links: {
schemas: { method: 'GET', href: '/schemas' } schemas: { method: 'GET', href: '/schemas' }
} }
@ -122,12 +233,15 @@ export function createApp(id: number, suffix = '') {
}; };
return new AppDto(links, return new AppDto(links,
`id${id}`, `name${id}${suffix}`, `id${id}`,
`my-name${id}${suffix}`,
`my-label${id}${suffix}`,
`my-description${id}${suffix}`,
['Owner'], ['Owner'],
DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`),
DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`), DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`),
id % 2 === 0, id % 2 === 0,
id % 2 === 0, id % 2 === 0,
'Free', 'Free', 'Basic',
'Basic'); new Version(`${id}`));
} }

107
src/Squidex/app/shared/services/apps.service.ts

@ -5,19 +5,25 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { HttpClient } from '@angular/common/http'; import { HttpClient, HttpErrorResponse, HttpEventType, HttpResponse } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable } from 'rxjs'; import { Observable, throwError } from 'rxjs';
import { map, tap } from 'rxjs/operators'; import { catchError, filter, map, tap } from 'rxjs/operators';
import { import {
AnalyticsService, AnalyticsService,
ApiUrlConfig, ApiUrlConfig,
DateTime, DateTime,
ErrorDto,
getLinkUrl,
hasAnyLink, hasAnyLink,
HTTP,
pretifyError, pretifyError,
Resource, Resource,
ResourceLinks ResourceLinks,
StringHelper,
Types,
Version
} from '@app/framework'; } from '@app/framework';
export class AppDto { export class AppDto {
@ -36,18 +42,28 @@ export class AppDto {
public readonly canReadRules: boolean; public readonly canReadRules: boolean;
public readonly canReadSchemas: boolean; public readonly canReadSchemas: boolean;
public readonly canReadWorkflows: boolean; public readonly canReadWorkflows: boolean;
public readonly canUpdateGeneral: boolean;
public readonly canUpdateImage: boolean;
public readonly canUploadAssets: boolean; public readonly canUploadAssets: boolean;
public readonly image: string;
public get displayName() {
return StringHelper.firstNonEmpty(this.label, this.name);
}
constructor(links: ResourceLinks, constructor(links: ResourceLinks,
public readonly id: string, public readonly id: string,
public readonly name: string, public readonly name: string,
public readonly label: string | undefined,
public readonly description: string | undefined,
public readonly permissions: string[], public readonly permissions: string[],
public readonly created: DateTime, public readonly created: DateTime,
public readonly lastModified: DateTime, public readonly lastModified: DateTime,
public readonly canAccessApi: boolean, public readonly canAccessApi: boolean,
public readonly canAccessContent: boolean, public readonly canAccessContent: boolean,
public readonly planName?: string, public readonly planName: string | undefined,
public readonly planUpgrade?: string public readonly planUpgrade: string | undefined,
public readonly version: Version
) { ) {
this._links = links; this._links = links;
@ -64,7 +80,11 @@ export class AppDto {
this.canReadRules = hasAnyLink(links, 'rules'); this.canReadRules = hasAnyLink(links, 'rules');
this.canReadSchemas = hasAnyLink(links, 'schemas'); this.canReadSchemas = hasAnyLink(links, 'schemas');
this.canReadWorkflows = hasAnyLink(links, 'workflows'); this.canReadWorkflows = hasAnyLink(links, 'workflows');
this.canUpdateGeneral = hasAnyLink(links, 'update');
this.canUpdateImage = hasAnyLink(links, 'image/upload');
this.canUploadAssets = hasAnyLink(links, 'assets/create'); this.canUploadAssets = hasAnyLink(links, 'assets/create');
this.image = getLinkUrl(links, 'image');
} }
} }
@ -73,6 +93,11 @@ export interface CreateAppDto {
readonly template?: string; readonly template?: string;
} }
export interface UpdateAppDto {
readonly label?: string;
readonly description?: string;
}
@Injectable() @Injectable()
export class AppsService { export class AppsService {
constructor( constructor(
@ -107,6 +132,71 @@ export class AppsService {
pretifyError('Failed to create app. Please reload.')); pretifyError('Failed to create app. Please reload.'));
} }
public putApp(resource: Resource, dto: UpdateAppDto, version: Version): Observable<AppDto> {
const link = resource._links['update'];
const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe(
map(({ payload }) => {
return parseApp(payload.body);
}),
tap(() => {
this.analytics.trackEvent('App', 'Updated');
}),
pretifyError('Failed to update app. Please reload.'));
}
public postAppImage(resource: Resource, file: File, version: Version): Observable<number | AppDto> {
const link = resource._links['image/upload'];
const url = this.apiUrl.buildUrl(link.href);
return HTTP.upload(this.http, link.method, url, file, version).pipe(
filter(event =>
event.type === HttpEventType.UploadProgress ||
event.type === HttpEventType.Response),
map(event => {
if (event.type === HttpEventType.UploadProgress) {
const percentDone = event.total ? Math.round(100 * event.loaded / event.total) : 0;
return percentDone;
} else if (Types.is(event, HttpResponse)) {
return parseApp(event.body);
} else {
throw 'Invalid';
}
}),
catchError(error => {
if (Types.is(error, HttpErrorResponse) && error.status === 413) {
return throwError(new ErrorDto(413, 'App image is too big.'));
} else {
return throwError(error);
}
}),
tap(value => {
if (!Types.isNumber(value)) {
this.analytics.trackEvent('AppImage', 'Uploaded');
}
}),
pretifyError('Failed to upload image. Please reload.'));
}
public deleteAppImage(resource: Resource, version: Version): Observable<any> {
const link = resource._links['image/delete'];
const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version).pipe(
map(({ payload }) => {
return parseApp(payload.body);
}),
tap(() => {
this.analytics.trackEvent('AppImage', 'Removed');
}),
pretifyError('Failed to remove app image. Please reload.'));
}
public deleteApp(resource: Resource): Observable<any> { public deleteApp(resource: Resource): Observable<any> {
const link = resource._links['delete']; const link = resource._links['delete'];
@ -124,11 +214,14 @@ function parseApp(response: any) {
return new AppDto(response._links, return new AppDto(response._links,
response.id, response.id,
response.name, response.name,
response.label,
response.description,
response.permissions, response.permissions,
DateTime.parseISO_UTC(response.created), DateTime.parseISO_UTC(response.created),
DateTime.parseISO_UTC(response.lastModified), DateTime.parseISO_UTC(response.lastModified),
response.canAccessApi, response.canAccessApi,
response.canAccessContent, response.canAccessContent,
response.planName, response.planName,
response.planUpgrade); response.planUpgrade,
new Version(response.version.toString()));
} }

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

@ -159,7 +159,7 @@ describe('AssetsService', () => {
let asset: AssetDto; let asset: AssetDto;
assetsService.uploadFile('my-app', null!).subscribe(result => { assetsService.postAssetFile('my-app', null!).subscribe(result => {
asset = <AssetDto>result; asset = <AssetDto>result;
}); });
@ -179,7 +179,7 @@ describe('AssetsService', () => {
let asset: AssetDto; let asset: AssetDto;
let error: ErrorDto; let error: ErrorDto;
assetsService.uploadFile('my-app', null!).subscribe(result => { assetsService.postAssetFile('my-app', null!).subscribe(result => {
asset = <AssetDto>result; asset = <AssetDto>result;
}, e => { }, e => {
error = e; error = e;
@ -207,7 +207,7 @@ describe('AssetsService', () => {
let asset: AssetDto; let asset: AssetDto;
assetsService.replaceFile('my-app', resource, null!, version).subscribe(result => { assetsService.putAssetFile('my-app', resource, null!, version).subscribe(result => {
asset = <AssetDto>result; asset = <AssetDto>result;
}); });
@ -216,16 +216,12 @@ describe('AssetsService', () => {
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toEqual(version.value); expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush(assetResponse(123), { req.flush(assetResponse(123));
headers: {
etag: '1'
}
});
expect(asset!).toEqual(createAsset(123)); expect(asset!).toEqual(createAsset(123));
})); }));
it('should return proper error when replace failed with 413', it('should return proper error when replacing asset content failed with 413',
inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => { inject([AssetsService, HttpTestingController], (assetsService: AssetsService, httpMock: HttpTestingController) => {
const resource: Resource = { const resource: Resource = {
@ -237,7 +233,7 @@ describe('AssetsService', () => {
let asset: AssetDto; let asset: AssetDto;
let error: ErrorDto; let error: ErrorDto;
assetsService.replaceFile('my-app', resource, null!, version).subscribe(result => { assetsService.putAssetFile('my-app', resource, null!, version).subscribe(result => {
asset = <AssetDto>result; asset = <AssetDto>result;
}, e => { }, e => {
error = e; error = e;
@ -276,11 +272,7 @@ describe('AssetsService', () => {
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toEqual(version.value); expect(req.request.headers.get('If-Match')).toEqual(version.value);
req.flush(assetResponse(123), { req.flush(assetResponse(123));
headers: {
etag: '1'
}
});
expect(asset!).toEqual(createAsset(123)); expect(asset!).toEqual(createAsset(123));
})); }));
@ -308,9 +300,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 +336,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',

30
src/Squidex/app/shared/services/assets.service.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { HttpClient, HttpErrorResponse, HttpEventType, HttpHeaders, HttpRequest, HttpResponse } from '@angular/common/http'; import { HttpClient, HttpErrorResponse, HttpEventType, HttpResponse } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs'; import { Observable, throwError } from 'rxjs';
import { catchError, filter, map, tap } from 'rxjs/operators'; import { catchError, filter, map, tap } from 'rxjs/operators';
@ -157,12 +157,10 @@ export class AssetsService {
pretifyError('Failed to load assets. Please reload.')); pretifyError('Failed to load assets. Please reload.'));
} }
public uploadFile(appName: string, file: File): Observable<number | AssetDto> { public postAssetFile(appName: string, file: File): Observable<number | AssetDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets`); const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets`);
const req = new HttpRequest('POST', url, getFormData(file), { reportProgress: true }); return HTTP.upload(this.http, 'POST', url, file).pipe(
return this.http.request(req).pipe(
filter(event => filter(event =>
event.type === HttpEventType.UploadProgress || event.type === HttpEventType.UploadProgress ||
event.type === HttpEventType.Response), event.type === HttpEventType.Response),
@ -204,14 +202,12 @@ export class AssetsService {
pretifyError('Failed to load assets. Please reload.')); pretifyError('Failed to load assets. Please reload.'));
} }
public replaceFile(appName: string, asset: Resource, file: File, version: Version): Observable<number | AssetDto> { public putAssetFile(appName: string, resource: Resource, file: File, version: Version): Observable<number | AssetDto> {
const link = asset._links['upload']; const link = resource._links['upload'];
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
const req = new HttpRequest(link.method, url, getFormData(file), { headers: new HttpHeaders().set('If-Match', version.value), reportProgress: true }); return HTTP.upload(this.http, link.method, url, file, version).pipe(
return this.http.request(req).pipe(
filter(event => filter(event =>
event.type === HttpEventType.UploadProgress || event.type === HttpEventType.UploadProgress ||
event.type === HttpEventType.Response), event.type === HttpEventType.Response),
@ -235,14 +231,14 @@ export class AssetsService {
}), }),
tap(value => { tap(value => {
if (!Types.isNumber(value)) { if (!Types.isNumber(value)) {
this.analytics.trackEvent('Analytics', 'Replaced', appName); this.analytics.trackEvent('Asset', 'Replaced', appName);
} }
}), }),
pretifyError('Failed to replace asset. Please reload.')); pretifyError('Failed to replace asset. Please reload.'));
} }
public putAsset(appName: string, asset: Resource, dto: AnnotateAssetDto, version: Version): Observable<AssetDto> { public putAsset(appName: string, resource: Resource, dto: AnnotateAssetDto, version: Version): Observable<AssetDto> {
const link = asset._links['update']; const link = resource._links['update'];
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
@ -269,14 +265,6 @@ export class AssetsService {
} }
} }
function getFormData(file: File) {
const formData = new FormData();
formData.append('file', file);
return formData;
}
function parseAsset(response: any) { function parseAsset(response: any) {
return new AssetDto(response._links, response._meta, return new AssetDto(response._links, response._meta,
response.id, response.id,

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,
{}, {},

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

Loading…
Cancel
Save