Browse Source

Feature/app infos (#407)

* Fallback avatar for apps.
* App label and description.
* Api to upload image.
* Schema service unified to use response version instead of header.
pull/410/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
be677b70c3
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  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. 13
      src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveAppImage.cs
  5. 16
      src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateApp.cs
  6. 20
      src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs
  7. 15
      src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs
  8. 6
      src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs
  9. 24
      src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs
  10. 16
      src/Squidex.Domain.Apps.Events/Apps/AppImageRemoved.cs
  11. 18
      src/Squidex.Domain.Apps.Events/Apps/AppImageUploaded.cs
  12. 19
      src/Squidex.Domain.Apps.Events/Apps/AppUpdated.cs
  13. 12
      src/Squidex.Infrastructure/RandomHash.cs
  14. 3
      src/Squidex.Shared/Permissions.cs
  15. 197
      src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
  16. 47
      src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs
  17. 30
      src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppDto.cs
  18. 2
      src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs
  19. 15
      src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs
  20. 15
      src/Squidex/app-config/webpack.config.js
  21. 4
      src/Squidex/app/features/administration/pages/users/user-page.component.ts
  22. 2
      src/Squidex/app/features/api/api-area.component.html
  23. 2
      src/Squidex/app/features/api/pages/graphql/graphql-page.component.html
  24. 33
      src/Squidex/app/features/apps/pages/apps-page.component.html
  25. 19
      src/Squidex/app/features/apps/pages/apps-page.component.scss
  26. 2
      src/Squidex/app/features/assets/pages/assets-page.component.html
  27. 2
      src/Squidex/app/features/content/pages/content/content-page.component.html
  28. 2
      src/Squidex/app/features/content/pages/contents/contents-page.component.html
  29. 2
      src/Squidex/app/features/content/pages/schemas/schemas-page.component.html
  30. 4
      src/Squidex/app/features/dashboard/pages/dashboard-page.component.html
  31. 2
      src/Squidex/app/features/rules/pages/events/rule-events-page.component.html
  32. 2
      src/Squidex/app/features/rules/pages/rules/rules-page.component.html
  33. 2
      src/Squidex/app/features/schemas/pages/schema/schema-page.component.html
  34. 2
      src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html
  35. 5
      src/Squidex/app/features/settings/module.ts
  36. 2
      src/Squidex/app/features/settings/pages/backups/backups-page.component.html
  37. 2
      src/Squidex/app/features/settings/pages/clients/clients-page.component.html
  38. 2
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html
  39. 2
      src/Squidex/app/features/settings/pages/languages/languages-page.component.html
  40. 88
      src/Squidex/app/features/settings/pages/more/more-page.component.html
  41. 71
      src/Squidex/app/features/settings/pages/more/more-page.component.scss
  42. 94
      src/Squidex/app/features/settings/pages/more/more-page.component.ts
  43. 2
      src/Squidex/app/features/settings/pages/patterns/patterns-page.component.html
  44. 2
      src/Squidex/app/features/settings/pages/plans/plans-page.component.html
  45. 2
      src/Squidex/app/features/settings/pages/roles/roles-page.component.html
  46. 2
      src/Squidex/app/features/settings/pages/workflows/workflows-page.component.html
  47. 12
      src/Squidex/app/features/settings/settings-area.component.html
  48. 10
      src/Squidex/app/features/settings/settings-area.component.scss
  49. 46
      src/Squidex/app/framework/angular/avatar.component.ts
  50. 8
      src/Squidex/app/framework/angular/forms/control-errors.component.ts
  51. 16
      src/Squidex/app/framework/angular/http/http-extensions.ts
  52. 15
      src/Squidex/app/framework/angular/safe-html.pipe.ts
  53. 1
      src/Squidex/app/framework/declarations.ts
  54. 1
      src/Squidex/app/framework/internal.ts
  55. 6
      src/Squidex/app/framework/module.ts
  56. 10
      src/Squidex/app/framework/utils/hateos.ts
  57. 71
      src/Squidex/app/framework/utils/picasso.ts
  58. 124
      src/Squidex/app/shared/services/apps.service.spec.ts
  59. 107
      src/Squidex/app/shared/services/apps.service.ts
  60. 22
      src/Squidex/app/shared/services/assets.service.spec.ts
  61. 30
      src/Squidex/app/shared/services/assets.service.ts
  62. 6
      src/Squidex/app/shared/services/rules.service.spec.ts
  63. 142
      src/Squidex/app/shared/services/schemas.service.spec.ts
  64. 72
      src/Squidex/app/shared/services/schemas.service.ts
  65. 9
      src/Squidex/app/shared/state/apps.forms.ts
  66. 80
      src/Squidex/app/shared/state/apps.state.spec.ts
  67. 57
      src/Squidex/app/shared/state/apps.state.ts
  68. 16
      src/Squidex/app/shared/state/asset-uploader.state.spec.ts
  69. 4
      src/Squidex/app/shared/state/asset-uploader.state.ts
  70. 8
      src/Squidex/app/shared/state/rules.state.spec.ts
  71. 42
      src/Squidex/app/shared/state/schemas.state.spec.ts
  72. 4
      src/Squidex/app/shell/pages/internal/apps-menu.component.html
  73. 62
      src/Squidex/package-lock.json
  74. 4
      src/Squidex/package.json
  75. 1
      src/Squidex/tsconfig.json
  76. 47
      tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppImageTests.cs
  77. 42
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppCommandMiddlewareTests.cs
  78. 58
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs

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

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

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

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

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

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

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

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

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

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

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

@ -43,10 +43,7 @@ const routes: Routes = [
component: SettingsAreaComponent, component: SettingsAreaComponent,
children: [ children: [
{ {
path: '' path: '',
},
{
path: 'more',
component: MorePageComponent component: MorePageComponent
}, },
{ {

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

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>

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

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

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

@ -53,6 +53,7 @@ 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/highlight.pipe'; export * from './angular/highlight.pipe';

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

6
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,
@ -103,6 +105,7 @@ import {
], ],
declarations: [ declarations: [
AutocompleteComponent, AutocompleteComponent,
AvatarComponent,
CheckboxGroupComponent, CheckboxGroupComponent,
ColorPickerComponent, ColorPickerComponent,
ConfirmClickDirective, ConfirmClickDirective,
@ -154,6 +157,7 @@ import {
ProgressBarComponent, ProgressBarComponent,
RootViewComponent, RootViewComponent,
SafeHtmlPipe, SafeHtmlPipe,
SafeUrlPipe,
ScrollActiveDirective, ScrollActiveDirective,
ShortcutComponent, ShortcutComponent,
ShortDatePipe, ShortDatePipe,
@ -172,6 +176,7 @@ import {
], ],
exports: [ exports: [
AutocompleteComponent, AutocompleteComponent,
AvatarComponent,
CheckboxGroupComponent, CheckboxGroupComponent,
CodeEditorComponent, CodeEditorComponent,
CommonModule, CommonModule,
@ -226,6 +231,7 @@ import {
ReactiveFormsModule, ReactiveFormsModule,
RootViewComponent, RootViewComponent,
SafeHtmlPipe, SafeHtmlPipe,
SafeUrlPipe,
ScrollActiveDirective, ScrollActiveDirective,
ShortcutComponent, ShortcutComponent,
ShortDatePipe, ShortDatePipe,

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

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

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,

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

@ -160,11 +160,7 @@ describe('RulesService', () => {
expect(req.request.method).toEqual('POST'); expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
req.flush(ruleResponse(12), { req.flush(ruleResponse(12));
headers: {
etag: '1'
}
});
expect(rule!).toEqual(createRule(12)); expect(rule!).toEqual(createRule(12));
})); }));

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

@ -27,7 +27,6 @@ import {
describe('SchemasService', () => { describe('SchemasService', () => {
const version = new Version('1'); const version = new Version('1');
const versionNew = new Version('2');
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@ -102,13 +101,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('GET'); expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make post request to create schema', it('should make post request to create schema',
@ -127,13 +122,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('POST'); expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make put request to update schema', it('should make put request to update schema',
@ -158,13 +149,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make put request to update schema scripts', it('should make put request to update schema scripts',
@ -189,13 +176,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make put request to update category', it('should make put request to update category',
@ -220,13 +203,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make put request to update preview urls', it('should make put request to update preview urls',
@ -251,13 +230,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make post request to add field', it('should make post request to add field',
@ -282,13 +257,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('POST'); expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make put request to publish schema', it('should make put request to publish schema',
@ -311,13 +282,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make put request to unpublish schema', it('should make put request to unpublish schema',
@ -340,13 +307,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make put request to update field', it('should make put request to update field',
@ -371,13 +334,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make put request to update field ordering', it('should make put request to update field ordering',
@ -402,13 +361,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make put request to lock field', it('should make put request to lock field',
@ -431,13 +386,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make put request to enable field', it('should make put request to enable field',
@ -460,13 +411,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make put request to disable field', it('should make put request to disable field',
@ -489,13 +436,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make put request to show field', it('should make put request to show field',
@ -518,13 +461,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make put request to hide field', it('should make put request to hide field',
@ -547,13 +486,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('PUT'); expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make delete request to delete field', it('should make delete request to delete field',
@ -576,13 +511,9 @@ describe('SchemasService', () => {
expect(req.request.method).toEqual('DELETE'); expect(req.request.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toBe(version.value); expect(req.request.headers.get('If-Match')).toBe(version.value);
req.flush(schemaDetailsResponse(12), { req.flush(schemaDetailsResponse(12));
headers: {
etag: '2'
}
});
expect(schema!).toEqual(createSchemaDetails(12, versionNew)); expect(schema!).toEqual(createSchemaDetails(12));
})); }));
it('should make delete request to delete schema', it('should make delete request to delete schema',
@ -637,6 +568,7 @@ describe('SchemasService', () => {
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}`,
version: `${id}`,
properties: { properties: {
label: `label${id}${suffix}`, label: `label${id}${suffix}`,
hints: `hints${id}${suffix}` hints: `hints${id}${suffix}`
@ -821,7 +753,7 @@ export function createSchema(id: number, suffix = '') {
new Version(`${id}`)); new Version(`${id}`));
} }
export function createSchemaDetails(id: number, version: Version, suffix = '') { export function createSchemaDetails(id: number, suffix = '') {
const links: ResourceLinks = { const links: ResourceLinks = {
update: { method: 'PUT', href: `/schemas/${id}` } update: { method: 'PUT', href: `/schemas/${id}` }
}; };
@ -835,7 +767,7 @@ export function createSchemaDetails(id: number, version: Version, suffix = '') {
id % 3 === 0, id % 3 === 0,
DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), `creator${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-12-12T10:10:00`), `creator${id}`,
DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`), `modifier${id}`, DateTime.parseISO_UTC(`${id % 1000 + 2000}-11-11T10:10:00`), `modifier${id}`,
version, new Version(`${id}`),
[ [
new RootFieldDto({}, 11, 'field11', createProperties('Array'), 'language', true, true, true, [ new RootFieldDto({}, 11, 'field11', createProperties('Array'), 'language', true, true, true, [
new NestedFieldDto({}, 101, 'field101', createProperties('String'), 11, true, true, true), new NestedFieldDto({}, 101, 'field101', createProperties('String'), 11, true, true, true),

72
src/Squidex/app/shared/services/schemas.service.ts

@ -312,8 +312,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/schemas/${name}`); const url = this.apiUrl.buildUrl(`api/apps/${appName}/schemas/${name}`);
return HTTP.getVersioned(this.http, url).pipe( return HTTP.getVersioned(this.http, url).pipe(
map(({ version, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, version); return parseSchemaWithDetails(payload.body);
}), }),
pretifyError('Failed to load schema. Please reload.')); pretifyError('Failed to load schema. Please reload.'));
} }
@ -322,8 +322,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/schemas`); const url = this.apiUrl.buildUrl(`api/apps/${appName}/schemas`);
return HTTP.postVersioned(this.http, url, dto).pipe( return HTTP.postVersioned(this.http, url, dto).pipe(
map(({ version, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, version); return parseSchemaWithDetails(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Schema', 'Created', appName); this.analytics.trackEvent('Schema', 'Created', appName);
@ -337,8 +337,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe(
map(({ version: newVersion, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, newVersion); return parseSchemaWithDetails(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Schema', 'ScriptsConfigured', appName); this.analytics.trackEvent('Schema', 'ScriptsConfigured', appName);
@ -352,8 +352,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe(
map(({ version: newVersion, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, newVersion); return parseSchemaWithDetails(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Schema', 'Updated', appName); this.analytics.trackEvent('Schema', 'Updated', appName);
@ -367,8 +367,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe(
map(({ version: newVersion, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, newVersion); return parseSchemaWithDetails(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Schema', 'CategoryChanged', appName); this.analytics.trackEvent('Schema', 'CategoryChanged', appName);
@ -382,8 +382,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe(
map(({ version: newVersion, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, newVersion); return parseSchemaWithDetails(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Schema', 'PreviewUrlsConfigured', appName); this.analytics.trackEvent('Schema', 'PreviewUrlsConfigured', appName);
@ -397,8 +397,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe(
map(({ version: newVersion, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, newVersion); return parseSchemaWithDetails(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Schema', 'Published', appName); this.analytics.trackEvent('Schema', 'Published', appName);
@ -412,8 +412,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe(
map(({ version: newVersion, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, newVersion); return parseSchemaWithDetails(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Schema', 'Unpublished', appName); this.analytics.trackEvent('Schema', 'Unpublished', appName);
@ -427,8 +427,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe(
map(({ version: newVersion, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, newVersion); return parseSchemaWithDetails(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Schema', 'FieldCreated', appName); this.analytics.trackEvent('Schema', 'FieldCreated', appName);
@ -442,8 +442,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, { fieldIds: dto }).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, { fieldIds: dto }).pipe(
map(({ version: newVersion, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, newVersion); return parseSchemaWithDetails(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Schema', 'FieldsReordered', appName); this.analytics.trackEvent('Schema', 'FieldsReordered', appName);
@ -457,8 +457,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, dto).pipe(
map(({ version: newVersion, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, newVersion); return parseSchemaWithDetails(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Schema', 'FieldUpdated', appName); this.analytics.trackEvent('Schema', 'FieldUpdated', appName);
@ -472,8 +472,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe(
map(({ version: newVersion, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, newVersion); return parseSchemaWithDetails(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Schema', 'FieldLocked', appName); this.analytics.trackEvent('Schema', 'FieldLocked', appName);
@ -487,8 +487,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe(
map(({ version: newVersion, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, newVersion); return parseSchemaWithDetails(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Schema', 'FieldEnabled', appName); this.analytics.trackEvent('Schema', 'FieldEnabled', appName);
@ -502,8 +502,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe(
map(({ version: newVersion, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, newVersion); return parseSchemaWithDetails(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Schema', 'FieldDisabled', appName); this.analytics.trackEvent('Schema', 'FieldDisabled', appName);
@ -517,8 +517,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe(
map(({ version: newVersion, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, newVersion); return parseSchemaWithDetails(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Schema', 'FieldShown', appName); this.analytics.trackEvent('Schema', 'FieldShown', appName);
@ -532,8 +532,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe(
map(({ version: newVersion, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, newVersion); return parseSchemaWithDetails(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Schema', 'FieldHidden', appName); this.analytics.trackEvent('Schema', 'FieldHidden', appName);
@ -547,8 +547,8 @@ export class SchemasService {
const url = this.apiUrl.buildUrl(link.href); const url = this.apiUrl.buildUrl(link.href);
return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe( return HTTP.requestVersioned(this.http, link.method, url, version, {}).pipe(
map(({ version: newVersion, payload }) => { map(({ payload }) => {
return parseSchemaWithDetails(payload.body, newVersion); return parseSchemaWithDetails(payload.body);
}), }),
tap(() => { tap(() => {
this.analytics.trackEvent('Schema', 'FieldDeleted', appName); this.analytics.trackEvent('Schema', 'FieldDeleted', appName);
@ -589,7 +589,7 @@ function parseSchemas(response: any) {
return { items, _links, canCreate: hasAnyLink(_links, 'create') }; return { items, _links, canCreate: hasAnyLink(_links, 'create') };
} }
function parseSchemaWithDetails(response: any, version: Version) { function parseSchemaWithDetails(response: any) {
const fields = response.fields.map((item: any) => { const fields = response.fields.map((item: any) => {
const propertiesDto = const propertiesDto =
createProperties( createProperties(
@ -638,7 +638,7 @@ function parseSchemaWithDetails(response: any, version: Version) {
response.isPublished, response.isPublished,
DateTime.parseISO_UTC(response.created), response.createdBy, DateTime.parseISO_UTC(response.created), response.createdBy,
DateTime.parseISO_UTC(response.lastModified), response.lastModifiedBy, DateTime.parseISO_UTC(response.lastModified), response.lastModifiedBy,
version, new Version(response.version.toString()),
fields, fields,
response.scripts || {}, response.scripts || {},
response.previewUrls || {}); response.previewUrls || {});

9
src/Squidex/app/shared/state/apps.forms.ts

@ -30,4 +30,13 @@ export class CreateAppForm extends Form<FormGroup, { name: string }> {
] ]
})); }));
} }
}
export class UpdateAppForm extends Form<FormGroup, { label?: string, description?: string }> {
constructor(formBuilder: FormBuilder) {
super(formBuilder.group({
label: '',
description: ''
}));
}
} }

80
src/Squidex/app/shared/state/apps.state.spec.ts

@ -21,8 +21,6 @@ describe('AppsState', () => {
const app1 = createApp(1); const app1 = createApp(1);
const app2 = createApp(2); const app2 = createApp(2);
const newApp = createApp(3);
let dialogs: IMock<DialogService>; let dialogs: IMock<DialogService>;
let appsService: IMock<AppsService>; let appsService: IMock<AppsService>;
let appsState: AppsState; let appsState: AppsState;
@ -81,49 +79,81 @@ describe('AppsState', () => {
}); });
it('should add app to snapshot when created', () => { it('should add app to snapshot when created', () => {
const request = { ...newApp }; const updated = createApp(3, '_new');
const request = { ...updated };
appsService.setup(x => x.postApp(request)) appsService.setup(x => x.postApp(request))
.returns(() => of(newApp)).verifiable(); .returns(() => of(updated)).verifiable();
appsState.create(request).subscribe(); appsState.create(request).subscribe();
expect(appsState.snapshot.apps.values).toEqual([app1, app2, newApp]); expect(appsState.snapshot.apps.values).toEqual([app1, app2, updated]);
}); });
it('should remove app from snapshot when archived', () => { it('should update app in snapshot when updated', () => {
const request = { ...newApp }; const updated = createApp(2, '_new');
appsService.setup(x => x.postApp(request)) appsService.setup(x => x.putApp(app2, {}, app2.version))
.returns(() => of(newApp)).verifiable(); .returns(() => of(updated)).verifiable();
appsService.setup(x => x.deleteApp(newApp)) appsState.update(app2, {}).subscribe();
.returns(() => of({})).verifiable();
appsState.create(request).subscribe(); expect(appsState.snapshot.apps.values).toEqual([app1, updated]);
});
const appsAfterCreate = appsState.snapshot.apps.values; it('should update selected app in snapshot when updated', () => {
const updated = createApp(1, '_new');
appsState.delete(newApp).subscribe(); appsService.setup(x => x.putApp(app1, {}, app1.version))
.returns(() => of(updated)).verifiable();
const appsAfterDelete = appsState.snapshot.apps.values; appsState.select(app1.name).subscribe();
appsState.update(app1, {}).subscribe();
expect(appsAfterCreate).toEqual([app1, app2, newApp]); expect(appsState.snapshot.apps.values).toEqual([updated, app2]);
expect(appsAfterDelete).toEqual([app1, app2]); expect(appsState.snapshot.selectedApp).toEqual(updated);
}); });
it('should remove selected app from snapshot when archived', () => { it('should update app in snapshot when image uploaded', () => {
const request = { ...newApp }; const updated = createApp(2, '_new');
appsService.setup(x => x.postApp(request)) const file = <File>{};
.returns(() => of(newApp)).verifiable();
appsService.setup(x => x.postAppImage(app2, file, app2.version))
.returns(() => of(50, 60, updated)).verifiable();
appsState.uploadImage(app2, file).subscribe();
expect(appsState.snapshot.apps.values).toEqual([app1, updated]);
});
appsService.setup(x => x.deleteApp(newApp)) it('should update app in snapshot when image removed', () => {
const updated = createApp(2, '_new');
appsService.setup(x => x.deleteAppImage(app2, app2.version))
.returns(() => of(updated)).verifiable();
appsState.removeImage(app2).subscribe();
expect(appsState.snapshot.apps.values).toEqual([app1, updated]);
});
it('should remove app from snapshot when archived', () => {
appsService.setup(x => x.deleteApp(app2))
.returns(() => of({})).verifiable(); .returns(() => of({})).verifiable();
appsState.create(request).subscribe(); appsState.delete(app2).subscribe();
appsState.select(newApp.name).subscribe();
appsState.delete(newApp).subscribe(); expect(appsState.snapshot.apps.values).toEqual([app1]);
});
it('should remove selected app from snapshot when archived', () => {
appsService.setup(x => x.deleteApp(app1))
.returns(() => of({})).verifiable();
appsState.select(app1.name).subscribe();
appsState.delete(app1).subscribe();
expect(appsState.snapshot.selectedApp).toBeNull(); expect(appsState.snapshot.selectedApp).toBeNull();
}); });

57
src/Squidex/app/shared/state/apps.state.ts

@ -13,13 +13,15 @@ import {
DialogService, DialogService,
ImmutableArray, ImmutableArray,
shareSubscribed, shareSubscribed,
State State,
Types
} from '@app/framework'; } from '@app/framework';
import { import {
AppDto, AppDto,
AppsService, AppsService,
CreateAppDto CreateAppDto,
UpdateAppDto
} from './../services/apps.service'; } from './../services/apps.service';
interface Snapshot { interface Snapshot {
@ -30,25 +32,21 @@ interface Snapshot {
selectedApp: AppDto | null; selectedApp: AppDto | null;
} }
function sameApp(lhs: AppDto, rhs?: AppDto): boolean {
return lhs === rhs || (!!lhs && !!rhs && lhs.id === rhs.id);
}
@Injectable() @Injectable()
export class AppsState extends State<Snapshot> { export class AppsState extends State<Snapshot> {
public get appName() { public get appName() {
return this.snapshot.selectedApp ? this.snapshot.selectedApp.name : ''; return this.snapshot.selectedApp ? this.snapshot.selectedApp.name : '';
} }
public get selectedAppState() { public get appDisplayName() {
return this.snapshot.selectedApp; return this.snapshot.selectedApp ? this.snapshot.selectedApp.displayName : '';
} }
public apps = public apps =
this.project(s => s.apps); this.project(s => s.apps);
public selectedApp = public selectedApp =
this.project(s => s.selectedApp, sameApp); this.project(s => s.selectedApp);
public selectedValidApp = public selectedValidApp =
this.selectedApp.pipe(filter(x => !!x), map(x => <AppDto>x), this.selectedApp.pipe(filter(x => !!x), map(x => <AppDto>x),
@ -89,7 +87,7 @@ export class AppsState extends State<Snapshot> {
return this.appsService.postApp(request).pipe( return this.appsService.postApp(request).pipe(
tap(created => { tap(created => {
this.next(s => { this.next(s => {
const apps = s.apps.push(created).sortByStringAsc(x => x.name); const apps = s.apps.push(created).sortByStringAsc(x => x.displayName);
return { ...s, apps }; return { ...s, apps };
}); });
@ -97,6 +95,32 @@ export class AppsState extends State<Snapshot> {
shareSubscribed(this.dialogs, { silent: true })); shareSubscribed(this.dialogs, { silent: true }));
} }
public update(app: AppDto, request: UpdateAppDto): Observable<AppDto> {
return this.appsService.putApp(app, request, app.version).pipe(
tap(updated => {
this.replaceApp(updated, app);
}),
shareSubscribed(this.dialogs, { silent: true }));
}
public removeImage(app: AppDto): Observable<AppDto> {
return this.appsService.deleteAppImage(app, app.version).pipe(
tap(updated => {
this.replaceApp(updated, app);
}),
shareSubscribed(this.dialogs, { silent: true }));
}
public uploadImage(app: AppDto, file: File): Observable<number | AppDto> {
return this.appsService.postAppImage(app, file, app.version).pipe(
tap(updated => {
if (Types.is(updated, AppDto)) {
this.replaceApp(updated, app);
}
}),
shareSubscribed(this.dialogs, { silent: true }));
}
public delete(app: AppDto): Observable<any> { public delete(app: AppDto): Observable<any> {
return this.appsService.deleteApp(app).pipe( return this.appsService.deleteApp(app).pipe(
tap(() => { tap(() => {
@ -105,7 +129,7 @@ export class AppsState extends State<Snapshot> {
const selectedApp = const selectedApp =
s.selectedApp && s.selectedApp &&
s.selectedApp.name === app.name ? s.selectedApp.id === app.id ?
null : null :
s.selectedApp; s.selectedApp;
@ -114,4 +138,15 @@ export class AppsState extends State<Snapshot> {
}), }),
shareSubscribed(this.dialogs)); shareSubscribed(this.dialogs));
} }
private replaceApp(updated: AppDto, app: AppDto) {
this.next(s => {
const apps = s.apps.replaceBy('id', updated);
const selectedApp = s.selectedApp &&
s.selectedApp.id === app.id ?
updated :
s.selectedApp;
return { ...s, apps, selectedApp };
});
}
} }

16
src/Squidex/app/shared/state/asset-uploader.state.spec.ts

@ -48,7 +48,7 @@ describe('AssetUploaderState', () => {
it('should create initial state when uploading file', () => { it('should create initial state when uploading file', () => {
const file: File = <any>{ name: 'my-file' }; const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.uploadFile(app, file)) assetsService.setup(x => x.postAssetFile(app, file))
.returns(() => never()).verifiable(); .returns(() => never()).verifiable();
assetUploader.uploadFile(file).subscribe(); assetUploader.uploadFile(file).subscribe();
@ -62,7 +62,7 @@ describe('AssetUploaderState', () => {
it('should update progress when uploading file makes progress', () => { it('should update progress when uploading file makes progress', () => {
const file: File = <any>{ name: 'my-file' }; const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.uploadFile(app, file)) assetsService.setup(x => x.postAssetFile(app, file))
.returns(() => ofForever(10, 20)).verifiable(); .returns(() => ofForever(10, 20)).verifiable();
assetUploader.uploadFile(file).subscribe(); assetUploader.uploadFile(file).subscribe();
@ -76,7 +76,7 @@ describe('AssetUploaderState', () => {
it('should update status when uploading file failed', () => { it('should update status when uploading file failed', () => {
const file: File = <any>{ name: 'my-file' }; const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.uploadFile(app, file)) assetsService.setup(x => x.postAssetFile(app, file))
.returns(() => throwError('Error')).verifiable(); .returns(() => throwError('Error')).verifiable();
assetUploader.uploadFile(file).pipe(onErrorResumeNext()).subscribe(); assetUploader.uploadFile(file).pipe(onErrorResumeNext()).subscribe();
@ -90,7 +90,7 @@ describe('AssetUploaderState', () => {
it('should update status when uploading file completes', (cb) => { it('should update status when uploading file completes', (cb) => {
const file: File = <any>{ name: 'my-file' }; const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.uploadFile(app, file)) assetsService.setup(x => x.postAssetFile(app, file))
.returns(() => of(10, 20, asset)).verifiable(); .returns(() => of(10, 20, asset)).verifiable();
let uploadedAsset: AssetDto; let uploadedAsset: AssetDto;
@ -113,7 +113,7 @@ describe('AssetUploaderState', () => {
it('should create initial state when uploading asset', () => { it('should create initial state when uploading asset', () => {
const file: File = <any>{ name: 'my-file' }; const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.replaceFile(app, asset, file, asset.version)) assetsService.setup(x => x.putAssetFile(app, asset, file, asset.version))
.returns(() => never()).verifiable(); .returns(() => never()).verifiable();
assetUploader.uploadAsset(asset, file).subscribe(); assetUploader.uploadAsset(asset, file).subscribe();
@ -127,7 +127,7 @@ describe('AssetUploaderState', () => {
it('should update progress when uploading asset makes progress', () => { it('should update progress when uploading asset makes progress', () => {
const file: File = <any>{ name: 'my-file' }; const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.replaceFile(app, asset, file, asset.version)) assetsService.setup(x => x.putAssetFile(app, asset, file, asset.version))
.returns(() => ofForever(10, 20)).verifiable(); .returns(() => ofForever(10, 20)).verifiable();
assetUploader.uploadAsset(asset, file).subscribe(); assetUploader.uploadAsset(asset, file).subscribe();
@ -141,7 +141,7 @@ describe('AssetUploaderState', () => {
it('should update status when uploading asset failed', () => { it('should update status when uploading asset failed', () => {
const file: File = <any>{ name: 'my-file' }; const file: File = <any>{ name: 'my-file' };
assetsService.setup(x => x.replaceFile(app, asset, file, asset.version)) assetsService.setup(x => x.putAssetFile(app, asset, file, asset.version))
.returns(() => throwError('Error')).verifiable(); .returns(() => throwError('Error')).verifiable();
assetUploader.uploadAsset(asset, file).pipe(onErrorResumeNext()).subscribe(); assetUploader.uploadAsset(asset, file).pipe(onErrorResumeNext()).subscribe();
@ -157,7 +157,7 @@ describe('AssetUploaderState', () => {
let updated = createAsset(1, undefined, '_new'); let updated = createAsset(1, undefined, '_new');
assetsService.setup(x => x.replaceFile(app, asset, file, asset.version)) assetsService.setup(x => x.putAssetFile(app, asset, file, asset.version))
.returns(() => of(10, 20, updated)).verifiable(); .returns(() => of(10, 20, updated)).verifiable();
let uploadedAsset: AssetDto; let uploadedAsset: AssetDto;

4
src/Squidex/app/shared/state/asset-uploader.state.ts

@ -72,7 +72,7 @@ export class AssetUploaderState extends State<Snapshot> {
} }
public uploadFile(file: File, target?: AssetsState): Observable<UploadResult> { public uploadFile(file: File, target?: AssetsState): Observable<UploadResult> {
const stream = this.assetsService.uploadFile(this.appName, file); const stream = this.assetsService.postAssetFile(this.appName, file);
return this.upload(stream, MathHelper.guid(), file, asset => { return this.upload(stream, MathHelper.guid(), file, asset => {
if (asset.isDuplicate) { if (asset.isDuplicate) {
@ -86,7 +86,7 @@ export class AssetUploaderState extends State<Snapshot> {
} }
public uploadAsset(asset: AssetDto, file: File): Observable<UploadResult> { public uploadAsset(asset: AssetDto, file: File): Observable<UploadResult> {
const stream = this.assetsService.replaceFile(this.appName, asset, file, asset.version); const stream = this.assetsService.putAssetFile(this.appName, asset, file, asset.version);
return this.upload(stream, asset.id, file); return this.upload(stream, asset.id, file);
} }

8
src/Squidex/app/shared/state/rules.state.spec.ts

@ -97,7 +97,7 @@ describe('RulesState', () => {
it('should update rule when updated action', () => { it('should update rule when updated action', () => {
const newAction = {}; const newAction = {};
const updated = createRule(1, 'new'); const updated = createRule(1, '_new');
rulesService.setup(x => x.putRule(app, rule1, It.isAny(), version)) rulesService.setup(x => x.putRule(app, rule1, It.isAny(), version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -112,7 +112,7 @@ describe('RulesState', () => {
it('should update rule when updated trigger', () => { it('should update rule when updated trigger', () => {
const newTrigger = {}; const newTrigger = {};
const updated = createRule(1, 'new'); const updated = createRule(1, '_new');
rulesService.setup(x => x.putRule(app, rule1, It.isAny(), version)) rulesService.setup(x => x.putRule(app, rule1, It.isAny(), version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -125,7 +125,7 @@ describe('RulesState', () => {
}); });
it('should update rule when enabled', () => { it('should update rule when enabled', () => {
const updated = createRule(1, 'new'); const updated = createRule(1, '_new');
rulesService.setup(x => x.enableRule(app, rule1, version)) rulesService.setup(x => x.enableRule(app, rule1, version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -138,7 +138,7 @@ describe('RulesState', () => {
}); });
it('should update rule when disabled', () => { it('should update rule when disabled', () => {
const updated = createRule(1, 'new'); const updated = createRule(1, '_new');
rulesService.setup(x => x.disableRule(app, rule1, version)) rulesService.setup(x => x.disableRule(app, rule1, version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();

42
src/Squidex/app/shared/state/schemas.state.spec.ts

@ -45,7 +45,7 @@ describe('SchemasState', () => {
_links: {} _links: {}
}; };
const schema = createSchemaDetails(1, version); const schema = createSchemaDetails(1);
let dialogs: IMock<DialogService>; let dialogs: IMock<DialogService>;
let schemasService: IMock<SchemasService>; let schemasService: IMock<SchemasService>;
@ -209,7 +209,7 @@ describe('SchemasState', () => {
}); });
it('should update schema when schema published', () => { it('should update schema when schema published', () => {
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.publishSchema(app, schema1, version)) schemasService.setup(x => x.publishSchema(app, schema1, version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -222,7 +222,7 @@ describe('SchemasState', () => {
}); });
it('should update schema when schema unpublished', () => { it('should update schema when schema unpublished', () => {
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.unpublishSchema(app, schema1, version)) schemasService.setup(x => x.unpublishSchema(app, schema1, version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -237,7 +237,7 @@ describe('SchemasState', () => {
it('should update schema when schema category changed', () => { it('should update schema when schema category changed', () => {
const category = 'my-new-category'; const category = 'my-new-category';
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.putCategory(app, schema1, It.is<UpdateSchemaCategoryDto>(i => i.name === category), version)) schemasService.setup(x => x.putCategory(app, schema1, It.is<UpdateSchemaCategoryDto>(i => i.name === category), version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -258,7 +258,7 @@ describe('SchemasState', () => {
}); });
it('should update schema and selected schema when schema published', () => { it('should update schema and selected schema when schema published', () => {
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.publishSchema(app, schema1, version)) schemasService.setup(x => x.publishSchema(app, schema1, version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -274,7 +274,7 @@ describe('SchemasState', () => {
it('should update schema and selected schema when schema category changed', () => { it('should update schema and selected schema when schema category changed', () => {
const category = 'my-new-category'; const category = 'my-new-category';
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.putCategory(app, schema1, It.is<UpdateSchemaCategoryDto>(i => i.name === category), version)) schemasService.setup(x => x.putCategory(app, schema1, It.is<UpdateSchemaCategoryDto>(i => i.name === category), version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -290,7 +290,7 @@ describe('SchemasState', () => {
it('should update schema and selected schema when schema updated', () => { it('should update schema and selected schema when schema updated', () => {
const request = { label: 'name2_label', hints: 'name2_hints' }; const request = { label: 'name2_label', hints: 'name2_hints' };
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.putSchema(app, schema1, It.isAny(), version)) schemasService.setup(x => x.putSchema(app, schema1, It.isAny(), version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -306,7 +306,7 @@ describe('SchemasState', () => {
it('should update schema and selected schema when scripts configured', () => { it('should update schema and selected schema when scripts configured', () => {
const request = { query: '<query-script>' }; const request = { query: '<query-script>' };
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.putScripts(app, schema1, It.isAny(), version)) schemasService.setup(x => x.putScripts(app, schema1, It.isAny(), version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -322,7 +322,7 @@ describe('SchemasState', () => {
it('should update schema and selected schema when preview urls configured', () => { it('should update schema and selected schema when preview urls configured', () => {
const request = { web: 'url' }; const request = { web: 'url' };
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.putPreviewUrls(app, schema1, It.isAny(), version)) schemasService.setup(x => x.putPreviewUrls(app, schema1, It.isAny(), version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -338,7 +338,7 @@ describe('SchemasState', () => {
it('should add schema to snapshot when created', () => { it('should add schema to snapshot when created', () => {
const request = { name: 'newName' }; const request = { name: 'newName' };
const updated = createSchemaDetails(3, newVersion, '-new'); const updated = createSchemaDetails(3, '_new');
schemasService.setup(x => x.postSchema(app, request)) schemasService.setup(x => x.postSchema(app, request))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -362,7 +362,7 @@ describe('SchemasState', () => {
it('should update schema and selected schema when field added', () => { it('should update schema and selected schema when field added', () => {
const request = { ...schema.fields[0] }; const request = { ...schema.fields[0] };
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.postField(app, schema1, It.isAny(), version)) schemasService.setup(x => x.postField(app, schema1, It.isAny(), version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -383,7 +383,7 @@ describe('SchemasState', () => {
it('should update schema and selected schema when nested field added', () => { it('should update schema and selected schema when nested field added', () => {
const request = { ...schema.fields[0].nested[0] }; const request = { ...schema.fields[0].nested[0] };
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.postField(app, schema.fields[0], It.isAny(), version)) schemasService.setup(x => x.postField(app, schema.fields[0], It.isAny(), version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -402,7 +402,7 @@ describe('SchemasState', () => {
}); });
it('should update schema and selected schema when field removed', () => { it('should update schema and selected schema when field removed', () => {
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.deleteField(app, schema.fields[0], version)) schemasService.setup(x => x.deleteField(app, schema.fields[0], version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -416,7 +416,7 @@ describe('SchemasState', () => {
}); });
it('should update schema and selected schema when fields sorted', () => { it('should update schema and selected schema when fields sorted', () => {
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.putFieldOrdering(app, schema1, [schema.fields[1].fieldId, schema.fields[2].fieldId], version)) schemasService.setup(x => x.putFieldOrdering(app, schema1, [schema.fields[1].fieldId, schema.fields[2].fieldId], version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -430,7 +430,7 @@ describe('SchemasState', () => {
}); });
it('should update schema and selected schema when nested fields sorted', () => { it('should update schema and selected schema when nested fields sorted', () => {
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.putFieldOrdering(app, schema.fields[0], [schema.fields[1].fieldId, schema.fields[2].fieldId], version)) schemasService.setup(x => x.putFieldOrdering(app, schema.fields[0], [schema.fields[1].fieldId, schema.fields[2].fieldId], version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -444,7 +444,7 @@ describe('SchemasState', () => {
}); });
it('should update schema and selected schema when field updated', () => { it('should update schema and selected schema when field updated', () => {
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
const request = { ...schema.fields[0] }; const request = { ...schema.fields[0] };
@ -460,7 +460,7 @@ describe('SchemasState', () => {
}); });
it('should update schema and selected schema when field hidden', () => { it('should update schema and selected schema when field hidden', () => {
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.hideField(app, schema.fields[0], version)) schemasService.setup(x => x.hideField(app, schema.fields[0], version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -474,7 +474,7 @@ describe('SchemasState', () => {
}); });
it('should update schema and selected schema when field disabled', () => { it('should update schema and selected schema when field disabled', () => {
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.disableField(app, schema.fields[0], version)) schemasService.setup(x => x.disableField(app, schema.fields[0], version))
.returns(() => of(updated)); .returns(() => of(updated));
@ -488,7 +488,7 @@ describe('SchemasState', () => {
}); });
it('should update schema and selected schema when field locked', () => { it('should update schema and selected schema when field locked', () => {
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.lockField(app, schema.fields[0], version)) schemasService.setup(x => x.lockField(app, schema.fields[0], version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -502,7 +502,7 @@ describe('SchemasState', () => {
}); });
it('should update schema and selected schema when field shown', () => { it('should update schema and selected schema when field shown', () => {
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.showField(app, schema.fields[0], version)) schemasService.setup(x => x.showField(app, schema.fields[0], version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();
@ -516,7 +516,7 @@ describe('SchemasState', () => {
}); });
it('should update schema and selected schema when field enabled', () => { it('should update schema and selected schema when field enabled', () => {
const updated = createSchemaDetails(1, newVersion, '-new'); const updated = createSchemaDetails(1, '_new');
schemasService.setup(x => x.enableField(app, schema.fields[0], version)) schemasService.setup(x => x.enableField(app, schema.fields[0], version))
.returns(() => of(updated)).verifiable(); .returns(() => of(updated)).verifiable();

4
src/Squidex/app/shell/pages/internal/apps-menu.component.html

@ -2,7 +2,7 @@
<li class="nav-item dropdown"> <li class="nav-item dropdown">
<span class="nav-link dropdown-toggle" id="app-name" (click)="appsMenu.toggle()"> <span class="nav-link dropdown-toggle" id="app-name" (click)="appsMenu.toggle()">
<ng-container *ngIf="appsState.selectedApp | async; let selectedApp; else noApp"> <ng-container *ngIf="appsState.selectedApp | async; let selectedApp; else noApp">
{{selectedApp.name}} {{selectedApp.displayName}}
</ng-container> </ng-container>
<ng-template #noApp> <ng-template #noApp>
@ -22,7 +22,7 @@
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<div class="apps-list"> <div class="apps-list">
<a class="dropdown-item" *ngFor="let app of apps; trackBy: trackByApp" [routerLink]="['/app', app.name]" routerLinkActive="active">{{app.name}}</a> <a class="dropdown-item" *ngFor="let app of apps; trackBy: trackByApp" [routerLink]="['/app', app.name]" routerLinkActive="active">{{app.displayName}}</a>
</div> </div>
</ng-container> </ng-container>

62
src/Squidex/package-lock.json

@ -1337,6 +1337,12 @@
"integrity": "sha512-6kBKf64aVfx93UJrcyEZ+OBM5nGv4RLsI6sR1Ar34bpgvGVRoyTgpxn4ZmtxOM5aDTAaaznYuYUH8bUX3Nk3YA==", "integrity": "sha512-6kBKf64aVfx93UJrcyEZ+OBM5nGv4RLsI6sR1Ar34bpgvGVRoyTgpxn4ZmtxOM5aDTAaaznYuYUH8bUX3Nk3YA==",
"dev": true "dev": true
}, },
"@types/mersenne-twister": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@types/mersenne-twister/-/mersenne-twister-1.1.2.tgz",
"integrity": "sha512-7KMIfSkMpaVExbzJRLUXHMO4hkFWbbspHPREk8I6pBxiNN+3+l6eAEClMCIPIo2KjCkR0rjYfXppr6+wKdTwpA==",
"dev": true
},
"@types/minimatch": { "@types/minimatch": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
@ -8628,6 +8634,11 @@
"integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=", "integrity": "sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=",
"dev": true "dev": true
}, },
"mersenne-twister": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/mersenne-twister/-/mersenne-twister-1.1.0.tgz",
"integrity": "sha1-+RZhjuQ9cXnvz2Qb7EUx65Zwl4o="
},
"methods": { "methods": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
@ -12554,9 +12565,9 @@
} }
}, },
"terser": { "terser": {
"version": "4.1.4", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/terser/-/terser-4.1.4.tgz", "resolved": "https://registry.npmjs.org/terser/-/terser-4.2.1.tgz",
"integrity": "sha512-+ZwXJvdSwbd60jG0Illav0F06GDJF0R4ydZ21Q3wGAFKoBGyJGo34F63vzJHgvYxc1ukOtIjvwEvl9MkjzM6Pg==", "integrity": "sha512-cGbc5utAcX4a9+2GGVX4DsenG6v0x3glnDi5hx8816X1McEAwPlPgRtXPJzSBsbpILxZ8MQMT0KvArLuE0HP5A==",
"dev": true, "dev": true,
"requires": { "requires": {
"commander": "^2.20.0", "commander": "^2.20.0",
@ -13022,51 +13033,6 @@
"source-map": "~0.6.1" "source-map": "~0.6.1"
} }
}, },
"uglifyjs-webpack-plugin": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-2.2.0.tgz",
"integrity": "sha512-mHSkufBmBuJ+KHQhv5H0MXijtsoA1lynJt1lXOaotja8/I0pR4L9oGaPIZw+bQBOFittXZg9OC1sXSGO9D9ZYg==",
"dev": true,
"requires": {
"cacache": "^12.0.2",
"find-cache-dir": "^2.1.0",
"is-wsl": "^1.1.0",
"schema-utils": "^1.0.0",
"serialize-javascript": "^1.7.0",
"source-map": "^0.6.1",
"uglify-js": "^3.6.0",
"webpack-sources": "^1.4.0",
"worker-farm": "^1.7.0"
},
"dependencies": {
"commander": {
"version": "2.20.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz",
"integrity": "sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ==",
"dev": true
},
"uglify-js": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.6.0.tgz",
"integrity": "sha512-W+jrUHJr3DXKhrsS7NUVxn3zqMOFn0hL/Ei6v0anCIMoKC93TjcflTagwIHLW7SfMFfiQuktQyFVCFHGUE0+yg==",
"dev": true,
"requires": {
"commander": "~2.20.0",
"source-map": "~0.6.1"
}
},
"webpack-sources": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.4.3.tgz",
"integrity": "sha512-lgTS3Xhv1lCOKo7SA5TjKXMjpSM4sBjNV5+q2bqesbSPs5FjGmU6jjtBSkX9b4qW87vDIsCIlUPOEhbZrMdjeQ==",
"dev": true,
"requires": {
"source-list-map": "^2.0.0",
"source-map": "~0.6.1"
}
}
}
},
"ultron": { "ultron": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz", "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.1.1.tgz",

4
src/Squidex/package.json

@ -31,6 +31,7 @@
"graphiql": "0.13.2", "graphiql": "0.13.2",
"graphql": "14.4.2", "graphql": "14.4.2",
"marked": "0.7.0", "marked": "0.7.0",
"mersenne-twister": "^1.1.0",
"moment": "2.24.0", "moment": "2.24.0",
"mousetrap": "1.6.3", "mousetrap": "1.6.3",
"ng2-dnd": "5.0.2", "ng2-dnd": "5.0.2",
@ -53,6 +54,7 @@
"@types/core-js": "2.5.2", "@types/core-js": "2.5.2",
"@types/jasmine": "3.4.0", "@types/jasmine": "3.4.0",
"@types/marked": "0.6.5", "@types/marked": "0.6.5",
"@types/mersenne-twister": "^1.1.2",
"@types/mousetrap": "1.6", "@types/mousetrap": "1.6",
"@types/node": "12.7.1", "@types/node": "12.7.1",
"@types/react": "16.9.1", "@types/react": "16.9.1",
@ -89,13 +91,13 @@
"sass-lint": "1.13.1", "sass-lint": "1.13.1",
"sass-loader": "7.2.0", "sass-loader": "7.2.0",
"style-loader": "1.0.0", "style-loader": "1.0.0",
"terser-webpack-plugin": "^1.4.1",
"ts-loader": "6.0.4", "ts-loader": "6.0.4",
"tsconfig-paths-webpack-plugin": "3.2.0", "tsconfig-paths-webpack-plugin": "3.2.0",
"tslint": "5.18.0", "tslint": "5.18.0",
"tslint-webpack-plugin": "2.1.0", "tslint-webpack-plugin": "2.1.0",
"typemoq": "2.1.0", "typemoq": "2.1.0",
"typescript": "3.5.3", "typescript": "3.5.3",
"uglifyjs-webpack-plugin": "2.2.0",
"underscore": "1.9.1", "underscore": "1.9.1",
"webpack": "4.39.1", "webpack": "4.39.1",
"webpack-cli": "3.3.6", "webpack-cli": "3.3.6",

1
src/Squidex/tsconfig.json

@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"allowSyntheticDefaultImports": true,
"baseUrl": ".", "baseUrl": ".",
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"experimentalDecorators": true, "experimentalDecorators": true,

47
tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppImageTests.cs

@ -0,0 +1,47 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using FluentAssertions;
using Squidex.Domain.Apps.Core.Apps;
using Xunit;
namespace Squidex.Domain.Apps.Core.Model.Apps
{
public class AppImageTests
{
[Fact]
public void Should_create_app_image()
{
var imageEtag = "etag";
var imageMime = "image/png";
var appImage = new AppImage(imageMime, imageEtag);
Assert.Equal(imageEtag, appImage.Etag);
Assert.Equal(imageMime, appImage.MimeType);
}
[Fact]
public void Should_create_app_image_with_autogenerated_etag()
{
var appImage = new AppImage("image/png");
Assert.True(appImage.Etag.Length > 10);
}
[Fact]
public void Should_serialize_and_deserialize()
{
var appImage = new AppImage("image/png");
var serialized = appImage.SerializeAndDeserialize();
serialized.Should().BeEquivalentTo(appImage);
}
}
}

42
tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppCommandMiddlewareTests.cs

@ -6,19 +6,28 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Orleans; using Orleans;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Domain.Apps.Entities.Apps.State; using Squidex.Domain.Apps.Entities.Apps.State;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Xunit; using Xunit;
#pragma warning disable IDE0067 // Dispose objects before losing scope
namespace Squidex.Domain.Apps.Entities.Apps namespace Squidex.Domain.Apps.Entities.Apps
{ {
public class AppCommandMiddlewareTests : HandlerTestBase<AppState> public class AppCommandMiddlewareTests : HandlerTestBase<AppState>
{ {
private readonly IContextProvider contextProvider = A.Fake<IContextProvider>(); private readonly IContextProvider contextProvider = A.Fake<IContextProvider>();
private readonly IAssetStore assetStore = A.Fake<IAssetStore>();
private readonly IAssetThumbnailGenerator assetThumbnailGenerator = A.Fake<IAssetThumbnailGenerator>();
private readonly Guid appId = Guid.NewGuid(); private readonly Guid appId = Guid.NewGuid();
private readonly Context requestContext = Context.Anonymous(); private readonly Context requestContext = Context.Anonymous();
private readonly AppCommandMiddleware sut; private readonly AppCommandMiddleware sut;
@ -37,7 +46,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
A.CallTo(() => contextProvider.Context) A.CallTo(() => contextProvider.Context)
.Returns(requestContext); .Returns(requestContext);
sut = new AppCommandMiddleware(A.Fake<IGrainFactory>(), contextProvider); sut = new AppCommandMiddleware(A.Fake<IGrainFactory>(), assetStore, assetThumbnailGenerator, contextProvider);
} }
[Fact] [Fact]
@ -54,5 +63,36 @@ namespace Squidex.Domain.Apps.Entities.Apps
Assert.Same(result, requestContext.App); Assert.Same(result, requestContext.App);
} }
[Fact]
public async Task Should_upload_image_to_store()
{
var stream = new MemoryStream();
var command = CreateCommand(new UploadAppImage { AppId = appId, File = () => stream });
var context = CreateContextForCommand(command);
A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(stream))
.Returns(new ImageInfo(100, 100));
await sut.HandleAsync(context);
A.CallTo(() => assetStore.UploadAsync(appId.ToString(), stream, true, A<CancellationToken>.Ignored))
.MustHaveHappened();
}
[Fact]
public async Task Should_throw_exception_when_file_to_upload_is_not_an_image()
{
var stream = new MemoryStream();
var command = CreateCommand(new UploadAppImage { AppId = appId, File = () => stream });
var context = CreateContextForCommand(command);
A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(stream))
.Returns(Task.FromResult<ImageInfo>(null));
await Assert.ThrowsAsync<ValidationException>(() => sut.HandleAsync(context));
}
} }
} }

58
tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppGrainTests.cs

@ -100,6 +100,64 @@ namespace Squidex.Domain.Apps.Entities.Apps
); );
} }
[Fact]
public async Task Update_should_create_events_and_update_state()
{
var command = new UpdateApp { Label = "my-label", Description = "my-description" };
await ExecuteCreateAsync();
var result = await sut.ExecuteAsync(CreateCommand(command));
result.ShouldBeEquivalent(sut.Snapshot);
Assert.Equal("my-label", sut.Snapshot.Label);
Assert.Equal("my-description", sut.Snapshot.Description);
LastEvents
.ShouldHaveSameEvents(
CreateEvent(new AppUpdated { Label = "my-label", Description = "my-description" })
);
}
[Fact]
public async Task UploadImage_should_create_events_and_update_state()
{
var command = new UploadAppImage { Image = new AppImage("image/png") };
await ExecuteCreateAsync();
var result = await sut.ExecuteAsync(CreateCommand(command));
result.ShouldBeEquivalent(sut.Snapshot);
Assert.Equal("image/png", sut.Snapshot.Image.MimeType);
LastEvents
.ShouldHaveSameEvents(
CreateEvent(new AppImageUploaded { Image = command.Image })
);
}
[Fact]
public async Task RemoveImage_should_create_events_and_update_state()
{
var command = new RemoveAppImage();
await ExecuteCreateAsync();
var result = await sut.ExecuteAsync(CreateCommand(command));
result.ShouldBeEquivalent(sut.Snapshot);
Assert.Null(sut.Snapshot.Image);
LastEvents
.ShouldHaveSameEvents(
CreateEvent(new AppImageRemoved())
);
}
[Fact] [Fact]
public async Task ChangePlan_should_create_events_and_update_state() public async Task ChangePlan_should_create_events_and_update_state()
{ {

Loading…
Cancel
Save