Browse Source

Merge pull request #356 from Squidex/test

A lot of refactorings and an upload manager.
pull/361/head
Sebastian Stehle 7 years ago
committed by GitHub
parent
commit
2f766d737b
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      src/Squidex.Domain.Apps.Core.Operations/ValidateContent/IAssetInfo.cs
  2. 4
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs
  3. 33
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  4. 2
      src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs
  5. 80
      src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs
  6. 21
      src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs
  7. 40
      src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs
  8. 22
      src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs
  9. 5
      src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs
  10. 2
      src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs
  11. 2
      src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs
  12. 3
      src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs
  13. 6
      src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs
  14. 5
      src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs
  15. 14
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs
  16. 2
      src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs
  17. 2
      src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs
  18. 2
      src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs
  19. 2
      src/Squidex.Domain.Apps.Events/Assets/AssetCreated.cs
  20. 2
      src/Squidex.Domain.Apps.Events/Assets/AssetUpdated.cs
  21. 96
      src/Squidex.Infrastructure/Assets/HasherStream.cs
  22. 10
      src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs
  23. 2
      src/Squidex.Infrastructure/Commands/EntityCreatedResult{T}.cs
  24. 8
      src/Squidex.Infrastructure/RandomHash.cs
  25. 12
      src/Squidex/.vscode/settings.json
  26. 42
      src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs
  27. 14
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetCreatedDto.cs
  28. 6
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs
  29. 6
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetReplacedDto.cs
  30. 2
      src/Squidex/Squidex.csproj
  31. 12
      src/Squidex/app-config/webpack.config.js
  32. 1
      src/Squidex/app-config/webpack.run.base.js
  33. 4
      src/Squidex/app-config/webpack.test.coverage.js
  34. 6
      src/Squidex/app/features/administration/declarations.ts
  35. 3
      src/Squidex/app/features/administration/guards/unset-user.guard.spec.ts
  36. 2
      src/Squidex/app/features/administration/guards/unset-user.guard.ts
  37. 6
      src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts
  38. 2
      src/Squidex/app/features/administration/guards/user-must-exist.guard.ts
  39. 13
      src/Squidex/app/features/administration/internal.ts
  40. 21
      src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts
  41. 2
      src/Squidex/app/features/administration/pages/restore/restore-page.component.html
  42. 27
      src/Squidex/app/features/administration/pages/restore/restore-page.component.ts
  43. 10
      src/Squidex/app/features/administration/pages/users/user-page.component.ts
  44. 18
      src/Squidex/app/features/administration/pages/users/users-page.component.ts
  45. 25
      src/Squidex/app/features/administration/services/event-consumers.service.ts
  46. 6
      src/Squidex/app/features/administration/services/users.service.spec.ts
  47. 79
      src/Squidex/app/features/administration/services/users.service.ts
  48. 109
      src/Squidex/app/features/administration/state/event-consumers.state.spec.ts
  49. 49
      src/Squidex/app/features/administration/state/event-consumers.state.ts
  50. 46
      src/Squidex/app/features/administration/state/users.forms.ts
  51. 267
      src/Squidex/app/features/administration/state/users.state.spec.ts
  52. 163
      src/Squidex/app/features/administration/state/users.state.ts
  53. 2
      src/Squidex/app/features/apps/pages/apps-page.component.html
  54. 5
      src/Squidex/app/features/apps/pages/apps-page.component.ts
  55. 2
      src/Squidex/app/features/apps/pages/news-dialog.component.html
  56. 4
      src/Squidex/app/features/apps/pages/news-dialog.component.ts
  57. 9
      src/Squidex/app/features/assets/pages/assets-filters-page.component.ts
  58. 2
      src/Squidex/app/features/assets/pages/assets-page.component.html
  59. 15
      src/Squidex/app/features/assets/pages/assets-page.component.ts
  60. 3
      src/Squidex/app/features/content/pages/content/content-field.component.ts
  61. 5
      src/Squidex/app/features/content/pages/content/content-history-page.component.ts
  62. 3
      src/Squidex/app/features/content/pages/content/content-page.component.html
  63. 7
      src/Squidex/app/features/content/pages/content/content-page.component.ts
  64. 2
      src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html
  65. 3
      src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts
  66. 18
      src/Squidex/app/features/content/pages/contents/contents-page.component.ts
  67. 3
      src/Squidex/app/features/content/pages/schemas/schemas-page.component.ts
  68. 2
      src/Squidex/app/features/content/shared/content-item.component.html
  69. 4
      src/Squidex/app/features/content/shared/content-item.component.ts
  70. 4
      src/Squidex/app/features/content/shared/content-status.component.html
  71. 8
      src/Squidex/app/features/content/shared/content-status.component.scss
  72. 3
      src/Squidex/app/features/content/shared/content-status.component.ts
  73. 11
      src/Squidex/app/features/content/shared/contents-selector.component.ts
  74. 2
      src/Squidex/app/features/content/shared/references-editor.component.html
  75. 4
      src/Squidex/app/features/content/shared/references-editor.component.ts
  76. 13
      src/Squidex/app/features/rules/pages/events/rule-events-page.component.ts
  77. 7
      src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts
  78. 13
      src/Squidex/app/features/rules/pages/rules/rules-page.component.ts
  79. 2
      src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.html
  80. 4
      src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts
  81. 9
      src/Squidex/app/features/schemas/pages/schema/field-wizard.component.ts
  82. 22
      src/Squidex/app/features/schemas/pages/schema/field.component.ts
  83. 6
      src/Squidex/app/features/schemas/pages/schema/forms/field-form-validation.component.ts
  84. 3
      src/Squidex/app/features/schemas/pages/schema/schema-page.component.ts
  85. 4
      src/Squidex/app/features/schemas/pages/schema/schema-preview-urls-form.component.ts
  86. 6
      src/Squidex/app/features/schemas/pages/schema/types/string-validation.component.ts
  87. 2
      src/Squidex/app/features/schemas/pages/schemas/schema-form.component.ts
  88. 6
      src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.ts
  89. 8
      src/Squidex/app/features/settings/pages/backups/backups-page.component.ts
  90. 2
      src/Squidex/app/features/settings/pages/clients/client.component.html
  91. 87
      src/Squidex/app/features/settings/pages/clients/client.component.ts
  92. 16
      src/Squidex/app/features/settings/pages/clients/clients-page.component.ts
  93. 25
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts
  94. 8
      src/Squidex/app/features/settings/pages/languages/language.component.ts
  95. 5
      src/Squidex/app/features/settings/pages/languages/languages-page.component.ts
  96. 9
      src/Squidex/app/features/settings/pages/patterns/pattern.component.ts
  97. 9
      src/Squidex/app/features/settings/pages/patterns/patterns-page.component.ts
  98. 7
      src/Squidex/app/features/settings/pages/plans/plans-page.component.ts
  99. 14
      src/Squidex/app/features/settings/pages/roles/role.component.ts
  100. 17
      src/Squidex/app/features/settings/pages/roles/roles-page.component.ts

2
src/Squidex.Domain.Apps.Core.Operations/ValidateContent/IAssetInfo.cs

@ -23,6 +23,8 @@ namespace Squidex.Domain.Apps.Core.ValidateContent
string FileName { get; }
string FileHash { get; }
string Slug { get; }
}
}

4
src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs

@ -38,6 +38,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
[BsonElement]
public string FileName { get; set; }
[BsonIgnoreIfDefault]
[BsonElement]
public string FileHash { get; set; }
[BsonIgnoreIfDefault]
[BsonElement]
public string Slug { get; set; }

33
src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs

@ -46,7 +46,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
.Ascending(x => x.Tags)
.Descending(x => x.LastModified)),
new CreateIndexModel<MongoAssetEntity>(
Index.Ascending(x => x.Slug))
Index
.Ascending(x => x.AppId)
.Ascending(x => x.IsDeleted)
.Ascending(x => x.FileHash)),
new CreateIndexModel<MongoAssetEntity>(
Index
.Ascending(x => x.AppId)
.Ascending(x => x.IsDeleted)
.Ascending(x => x.Slug))
},
ct);
}
@ -102,19 +110,31 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
}
}
public async Task<IAssetEntity> FindAssetAsync(string slug)
public async Task<IAssetEntity> FindAssetBySlugAsync(Guid appId, string slug)
{
using (Profiler.TraceMethod<MongoAssetRepository>())
{
var assetEntity =
await Collection.Find(x => x.Slug == slug)
await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.Slug == slug)
.FirstOrDefaultAsync();
return assetEntity;
}
}
public async Task<IAssetEntity> FindAssetAsync(Guid id)
public async Task<IList<IAssetEntity>> QueryByHashAsync(Guid appId, string hash)
{
using (Profiler.TraceMethod<MongoAssetRepository>())
{
var assetEntities =
await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.FileHash == hash)
.ToListAsync();
return assetEntities.OfType<IAssetEntity>().ToList();
}
}
public async Task<IAssetEntity> FindAssetAsync(Guid id, bool allowDeleted = false)
{
using (Profiler.TraceMethod<MongoAssetRepository>())
{
@ -122,6 +142,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
await Collection.Find(x => x.Id == id)
.FirstOrDefaultAsync();
if (assetEntity?.IsDeleted == true && !allowDeleted)
{
return null;
}
return assetEntity;
}
}

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

@ -14,6 +14,8 @@ using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States;
#pragma warning disable IDE0060 // Remove unused parameter
namespace Squidex.Domain.Apps.Entities.Apps.State
{
[CollectionName("Apps")]

80
src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs

@ -7,6 +7,7 @@
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Orleans;
using Squidex.Domain.Apps.Entities.Assets.Commands;
@ -20,21 +21,25 @@ namespace Squidex.Domain.Apps.Entities.Assets
public sealed class AssetCommandMiddleware : GrainCommandMiddleware<AssetCommand, IAssetGrain>
{
private readonly IAssetStore assetStore;
private readonly IAssetQueryService assetQueryService;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
private readonly IEnumerable<ITagGenerator<CreateAsset>> tagGenerators;
public AssetCommandMiddleware(
IGrainFactory grainFactory,
IAssetQueryService assetQueryService,
IAssetStore assetStore,
IAssetThumbnailGenerator assetThumbnailGenerator,
IEnumerable<ITagGenerator<CreateAsset>> tagGenerators)
: base(grainFactory)
{
Guard.NotNull(assetStore, nameof(assetStore));
Guard.NotNull(assetQueryService, nameof(assetQueryService));
Guard.NotNull(assetThumbnailGenerator, nameof(assetThumbnailGenerator));
Guard.NotNull(tagGenerators, nameof(tagGenerators));
this.assetStore = assetStore;
this.assetQueryService = assetQueryService;
this.assetThumbnailGenerator = assetThumbnailGenerator;
this.tagGenerators = tagGenerators;
@ -53,21 +58,51 @@ namespace Squidex.Domain.Apps.Entities.Assets
createAsset.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(createAsset.File.OpenRead());
foreach (var tagGenerator in tagGenerators)
{
tagGenerator.GenerateTags(createAsset, createAsset.Tags);
}
createAsset.FileHash = await UploadAsync(context, createAsset.File);
var originalTags = new HashSet<string>(createAsset.Tags);
await assetStore.UploadAsync(context.ContextId.ToString(), createAsset.File.OpenRead());
try
{
var result = (AssetSavedResult)await ExecuteCommandAsync(createAsset);
context.Complete(new AssetCreatedResult(createAsset.AssetId, originalTags, result.Version));
var existings = await assetQueryService.QueryByHashAsync(createAsset.AppId.Id, createAsset.FileHash);
AssetCreatedResult result = null;
foreach (var existing in existings)
{
if (IsDuplicate(createAsset, existing))
{
result = new AssetCreatedResult(
existing.Id,
existing.Tags,
existing.Version,
existing.FileVersion,
existing.FileHash,
true);
}
break;
}
if (result == null)
{
foreach (var tagGenerator in tagGenerators)
{
tagGenerator.GenerateTags(createAsset, createAsset.Tags);
}
var commandResult = (AssetSavedResult)await ExecuteCommandAsync(createAsset);
result = new AssetCreatedResult(
createAsset.AssetId,
createAsset.Tags,
commandResult.Version,
commandResult.FileVersion,
commandResult.FileHash,
false);
await assetStore.CopyAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), result.FileVersion, null);
}
await assetStore.CopyAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), result.FileVersion, null);
context.Complete(result);
}
finally
{
@ -81,10 +116,10 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
updateAsset.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(updateAsset.File.OpenRead());
await assetStore.UploadAsync(context.ContextId.ToString(), updateAsset.File.OpenRead());
updateAsset.FileHash = await UploadAsync(context, updateAsset.File);
try
{
var result = await ExecuteCommandAsync(updateAsset) as AssetSavedResult;
var result = (AssetSavedResult)await ExecuteCommandAsync(updateAsset);
context.Complete(result);
@ -103,5 +138,24 @@ namespace Squidex.Domain.Apps.Entities.Assets
break;
}
}
private static bool IsDuplicate(CreateAsset createAsset, IAssetEntity asset)
{
return asset != null && asset.FileName == createAsset.File.FileName && asset.FileSize == createAsset.File.FileSize;
}
private async Task<string> UploadAsync(CommandContext context, AssetFile file)
{
string hash;
using (var hashStream = new HasherStream(file.OpenRead(), HashAlgorithmName.SHA256))
{
await assetStore.UploadAsync(context.ContextId.ToString(), hashStream);
hash = $"{hashStream.GetHashStringAndReset()}{file.FileName}{file.FileSize}".Sha256Base64();
}
return hash;
}
}
}

21
src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs

@ -11,18 +11,25 @@ using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class AssetCreatedResult : EntitySavedResult
public sealed class AssetCreatedResult : EntityCreatedResult<Guid>
{
public Guid Id { get; }
public HashSet<string> Tags { get; }
public AssetCreatedResult(Guid id, HashSet<string> tags, long version)
: base(version)
{
Id = id;
public long FileVersion { get; }
public string FileHash { get; }
public bool IsDuplicate { get; }
public AssetCreatedResult(Guid id, HashSet<string> tags, long version, long fileVersion, string fileHash, bool isDuplicate)
: base(id, version)
{
Tags = tags;
FileVersion = fileVersion;
FileHash = fileHash;
IsDuplicate = isDuplicate;
}
}
}

40
src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs

@ -47,11 +47,11 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
GuardAsset.CanCreate(c);
c.Tags = await NormalizeTagsAsync(c.AppId.Id, c.Tags);
var tagIds = await NormalizeTagsAsync(c.AppId.Id, c.Tags);
Create(c);
Create(c, tagIds);
return new AssetSavedResult(Version, Snapshot.FileVersion);
return new AssetSavedResult(Version, Snapshot.FileVersion, Snapshot.FileHash);
});
case UpdateAsset updateRule:
return UpdateAsync(updateRule, c =>
@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
Update(c);
return new AssetSavedResult(Version, Snapshot.FileVersion);
return new AssetSavedResult(Version, Snapshot.FileVersion, Snapshot.FileHash);
});
case DeleteAsset deleteAsset:
return UpdateAsync(deleteAsset, async c =>
@ -76,12 +76,9 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
GuardAsset.CanAnnotate(c, Snapshot.FileName, Snapshot.Slug);
if (c.Tags != null)
{
c.Tags = await NormalizeTagsAsync(Snapshot.AppId.Id, c.Tags);
}
var tagIds = await NormalizeTagsAsync(Snapshot.AppId.Id, c.Tags);
Annotate(c);
Annotate(c, tagIds);
});
default:
throw new NotSupportedException();
@ -90,32 +87,37 @@ namespace Squidex.Domain.Apps.Entities.Assets
private async Task<HashSet<string>> NormalizeTagsAsync(Guid appId, HashSet<string> tags)
{
if (tags == null)
{
return null;
}
var normalized = await tagService.NormalizeTagsAsync(appId, TagGroups.Assets, tags, Snapshot.Tags);
return new HashSet<string>(normalized.Values);
}
public void Create(CreateAsset command)
public void Create(CreateAsset command, HashSet<string> tagIds)
{
var @event = SimpleMapper.Map(command, new AssetCreated
{
IsImage = command.ImageInfo != null,
FileName = command.File.FileName,
FileSize = command.File.FileSize,
FileVersion = 0,
MimeType = command.File.MimeType,
PixelWidth = command.ImageInfo?.PixelWidth,
PixelHeight = command.ImageInfo?.PixelHeight,
IsImage = command.ImageInfo != null,
Slug = command.File.FileName.ToAssetSlug()
});
@event.Tags = tagIds;
RaiseEvent(@event);
}
public void Update(UpdateAsset command)
{
VerifyNotDeleted();
var @event = SimpleMapper.Map(command, new AssetUpdated
{
FileVersion = Snapshot.FileVersion + 1,
@ -129,14 +131,18 @@ namespace Squidex.Domain.Apps.Entities.Assets
RaiseEvent(@event);
}
public void Delete(DeleteAsset command)
public void Annotate(AnnotateAsset command, HashSet<string> tagIds)
{
RaiseEvent(SimpleMapper.Map(command, new AssetDeleted { DeletedSize = Snapshot.TotalSize }));
var @event = SimpleMapper.Map(command, new AssetAnnotated());
@event.Tags = tagIds;
RaiseEvent(@event);
}
public void Annotate(AnnotateAsset command)
public void Delete(DeleteAsset command)
{
RaiseEvent(SimpleMapper.Map(command, new AssetAnnotated()));
RaiseEvent(SimpleMapper.Map(command, new AssetDeleted { DeletedSize = Snapshot.TotalSize }));
}
private void RaiseEvent(AppEvent @event)

22
src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs

@ -21,7 +21,7 @@ using Squidex.Infrastructure.Queries.OData;
namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class AssetQueryService : IAssetQueryService
public class AssetQueryService : IAssetQueryService
{
private readonly ITagService tagService;
private readonly IAssetRepository assetRepository;
@ -38,20 +38,36 @@ namespace Squidex.Domain.Apps.Entities.Assets
this.tagService = tagService;
}
public async Task<IAssetEntity> FindAssetAsync(QueryContext context, Guid id)
public Task<IAssetEntity> FindAssetAsync(QueryContext context, Guid id)
{
Guard.NotNull(context, nameof(context));
return FindAssetAsync(context.App.Id, id);
}
public async Task<IAssetEntity> FindAssetAsync(Guid appId, Guid id)
{
var asset = await assetRepository.FindAssetAsync(id);
if (asset != null)
{
await DenormalizeTagsAsync(context.App.Id, Enumerable.Repeat(asset, 1));
await DenormalizeTagsAsync(appId, Enumerable.Repeat(asset, 1));
}
return asset;
}
public async Task<IList<IAssetEntity>> QueryByHashAsync(Guid appId, string hash)
{
Guard.NotNull(hash, nameof(hash));
var assets = await assetRepository.QueryByHashAsync(appId, hash);
await DenormalizeTagsAsync(appId, assets);
return assets;
}
public async Task<IResultList<IAssetEntity>> QueryAsync(QueryContext context, Q query)
{
Guard.NotNull(context, nameof(context));

5
src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs

@ -13,10 +13,13 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
public long FileVersion { get; }
public AssetSavedResult(long version, long fileVersion)
public string FileHash { get; }
public AssetSavedResult(long version, long fileVersion, string fileHash)
: base(version)
{
FileVersion = fileVersion;
FileHash = fileHash;
}
}
}

2
src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs

@ -22,6 +22,8 @@ namespace Squidex.Domain.Apps.Entities.Assets.Commands
public HashSet<string> Tags { get; set; }
public string FileHash { get; set; }
public CreateAsset()
{
AssetId = Guid.NewGuid();

2
src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs

@ -14,5 +14,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Commands
public AssetFile File { get; set; }
public ImageInfo ImageInfo { get; set; }
public string FileHash { get; set; }
}
}

3
src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Infrastructure;
@ -13,6 +14,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
{
public interface IAssetQueryService
{
Task<IList<IAssetEntity>> QueryByHashAsync(Guid appId, string hash);
Task<IResultList<IAssetEntity>> QueryAsync(QueryContext contex, Q query);
Task<IAssetEntity> FindAssetAsync(QueryContext context, Guid id);

6
src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs

@ -15,13 +15,15 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories
{
public interface IAssetRepository
{
Task<IList<IAssetEntity>> QueryByHashAsync(Guid appId, string hash);
Task<IResultList<IAssetEntity>> QueryAsync(Guid appId, Query query);
Task<IResultList<IAssetEntity>> QueryAsync(Guid appId, HashSet<Guid> ids);
Task<IAssetEntity> FindAssetAsync(string slug);
Task<IAssetEntity> FindAssetAsync(Guid id, bool allowDeleted = false);
Task<IAssetEntity> FindAssetAsync(Guid id);
Task<IAssetEntity> FindAssetBySlugAsync(Guid appId, string slug);
Task RemoveAsync(Guid appId);
}

5
src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs

@ -16,6 +16,8 @@ using Squidex.Infrastructure.Dispatching;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
#pragma warning disable IDE0060 // Remove unused parameter
namespace Squidex.Domain.Apps.Entities.Assets.State
{
public class AssetState : DomainObjectState<AssetState>, IAssetEntity
@ -26,6 +28,9 @@ namespace Squidex.Domain.Apps.Entities.Assets.State
[DataMember]
public string FileName { get; set; }
[DataMember]
public string FileHash { get; set; }
[DataMember]
public string MimeType { get; set; }

14
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs

@ -101,10 +101,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
AddField(new FieldType
{
Name = "slug",
Name = "fileHash",
ResolvedType = AllTypes.NonNullString,
Resolver = Resolve(x => x.Slug),
Description = "The file name as slug."
Resolver = Resolve(x => x.FileHash),
Description = "The hash of the file. Can be null for old files."
});
AddField(new FieldType
@ -131,6 +131,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Description = "The version of the file."
});
AddField(new FieldType
{
Name = "slug",
ResolvedType = AllTypes.NonNullString,
Resolver = Resolve(x => x.Slug),
Description = "The file name as slug."
});
AddField(new FieldType
{
Name = "isImage",

2
src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs

@ -15,6 +15,8 @@ using Squidex.Infrastructure.Dispatching;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
#pragma warning disable IDE0060 // Remove unused parameter
namespace Squidex.Domain.Apps.Entities.Contents.State
{
public class ContentState : DomainObjectState<ContentState>, IContentEntity

2
src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs

@ -15,6 +15,8 @@ using Squidex.Infrastructure.Dispatching;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.States;
#pragma warning disable IDE0060 // Remove unused parameter
namespace Squidex.Domain.Apps.Entities.Rules.State
{
[CollectionName("Rules")]

2
src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs

@ -16,6 +16,8 @@ using Squidex.Infrastructure.Dispatching;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.States;
#pragma warning disable IDE0060 // Remove unused parameter
namespace Squidex.Domain.Apps.Entities.Schemas.State
{
[CollectionName("Schemas")]

2
src/Squidex.Domain.Apps.Events/Assets/AssetCreated.cs

@ -15,6 +15,8 @@ namespace Squidex.Domain.Apps.Events.Assets
{
public string FileName { get; set; }
public string FileHash { get; set; }
public string MimeType { get; set; }
public string Slug { get; set; }

2
src/Squidex.Domain.Apps.Events/Assets/AssetUpdated.cs

@ -14,6 +14,8 @@ namespace Squidex.Domain.Apps.Events.Assets
{
public string MimeType { get; set; }
public string FileHash { get; set; }
public long FileSize { get; set; }
public long FileVersion { get; set; }

96
src/Squidex.Infrastructure/Assets/HasherStream.cs

@ -0,0 +1,96 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Security.Cryptography;
namespace Squidex.Infrastructure.Assets
{
public sealed class HasherStream : Stream
{
private readonly Stream inner;
private readonly IncrementalHash hasher;
public override bool CanRead
{
get { return inner.CanRead; }
}
public override bool CanSeek
{
get { return false; }
}
public override bool CanWrite
{
get { return false; }
}
public override long Length
{
get { return inner.Length; }
}
public override long Position
{
get { return inner.Position; }
set { throw new NotSupportedException(); }
}
public HasherStream(Stream inner, HashAlgorithmName hashAlgorithmName)
{
Guard.NotNull(inner, nameof(inner));
this.inner = inner;
hasher = IncrementalHash.CreateHash(hashAlgorithmName);
}
public override int Read(byte[] buffer, int offset, int count)
{
var read = inner.Read(buffer, offset, count);
if (read > 0)
{
hasher.AppendData(buffer, offset, read);
}
return read;
}
public byte[] GetHashAndReset()
{
return hasher.GetHashAndReset();
}
public string GetHashStringAndReset()
{
return Convert.ToBase64String(GetHashAndReset());
}
public override void Flush()
{
throw new NotSupportedException();
}
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotSupportedException();
}
public override void SetLength(long value)
{
throw new NotSupportedException();
}
public override void Write(byte[] buffer, int offset, int count)
{
throw new NotSupportedException();
}
}
}

10
src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs

@ -13,7 +13,7 @@ using Squidex.Infrastructure.Tasks;
namespace Squidex.Infrastructure.Assets
{
public sealed class MemoryAssetStore : IAssetStore
public class MemoryAssetStore : IAssetStore
{
private readonly ConcurrentDictionary<string, MemoryStream> streams = new ConcurrentDictionary<string, MemoryStream>();
private readonly AsyncLock readerLock = new AsyncLock();
@ -24,7 +24,7 @@ namespace Squidex.Infrastructure.Assets
return null;
}
public async Task CopyAsync(string sourceFileName, string targetFileName, CancellationToken ct = default)
public virtual async Task CopyAsync(string sourceFileName, string targetFileName, CancellationToken ct = default)
{
Guard.NotNullOrEmpty(sourceFileName, nameof(sourceFileName));
Guard.NotNullOrEmpty(targetFileName, nameof(targetFileName));
@ -40,7 +40,7 @@ namespace Squidex.Infrastructure.Assets
}
}
public async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default)
public virtual async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default)
{
Guard.NotNullOrEmpty(fileName, nameof(fileName));
@ -62,7 +62,7 @@ namespace Squidex.Infrastructure.Assets
}
}
public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default)
public virtual async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default)
{
Guard.NotNullOrEmpty(fileName, nameof(fileName));
@ -99,7 +99,7 @@ namespace Squidex.Infrastructure.Assets
}
}
public Task DeleteAsync(string fileName)
public virtual Task DeleteAsync(string fileName)
{
Guard.NotNullOrEmpty(fileName, nameof(fileName));

2
src/Squidex.Infrastructure/Commands/EntityCreatedResult{T}.cs

@ -7,7 +7,7 @@
namespace Squidex.Infrastructure.Commands
{
public sealed class EntityCreatedResult<T> : EntitySavedResult
public class EntityCreatedResult<T> : EntitySavedResult
{
public T IdOrValue { get; }

8
src/Squidex.Infrastructure/RandomHash.cs

@ -19,11 +19,15 @@ namespace Squidex.Infrastructure
}
public static string Sha256Base64(this string value)
{
return Sha256Base64(Encoding.UTF8.GetBytes(value));
}
public static string Sha256Base64(this byte[] bytes)
{
using (var sha = SHA256.Create())
{
var bytesValue = Encoding.UTF8.GetBytes(value);
var bytesHash = sha.ComputeHash(bytesValue);
var bytesHash = sha.ComputeHash(bytes);
var result = Convert.ToBase64String(bytesHash);

12
src/Squidex/.vscode/settings.json

@ -7,7 +7,6 @@
// Configure glob patterns for excluding files and folders.
"files.exclude": {
"_test-output": true,
"**/node_modules": true,
"**/Assets": true,
"**/artifacts": true,
@ -23,10 +22,15 @@
"**/*.user": true,
"**/*.xproj": true,
"**/*.gitattributes": true,
"appsetttings.Development.json",
"appsetttings.Production.json",
"appsetttings.Development.json": true,
"appsetttings.Production.json": true,
".awcache": true,
".vs:": true,
".vscode:": true
}
},
"coverage-gutters.coverageFileNames": [
"_test-output/coverage/lcov.info"
],
"coverage-gutters.showLineCoverage": true
}

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

@ -62,7 +62,38 @@ namespace Squidex.Areas.Api.Controllers.Assets
[Route("assets/{id}/{*more}")]
[ProducesResponseType(typeof(FileResult), 200)]
[ApiCosts(0.5)]
public async Task<IActionResult> GetAssetContent(string id, string more,
public async Task<IActionResult> GetAssetContent(Guid id, string more,
[FromQuery] long version = EtagVersion.Any,
[FromQuery] int? width = null,
[FromQuery] int? height = null,
[FromQuery] int? quality = null,
[FromQuery] string mode = null)
{
var entity = await assetRepository.FindAssetAsync(id);
return DeliverAsset(entity, version, width, height, quality, mode);
}
/// <summary>
/// Get the asset content.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="idOrSlug">The id or slug of the asset.</param>
/// <param name="more">Optional suffix that can be used to seo-optimize the link to the image Has not effect.</param>
/// <param name="version">The optional version of the asset.</param>
/// <param name="width">The target width of the asset, if it is an image.</param>
/// <param name="height">The target height of the asset, if it is an image.</param>
/// <param name="quality">Optional image quality, it is is an jpeg image.</param>
/// <param name="mode">The resize mode when the width and height is defined.</param>
/// <returns>
/// 200 => Asset found and content or (resized) image returned.
/// 404 => Asset or app not found.
/// </returns>
[HttpGet]
[Route("assets/{app}/{idOrSlug}/{*more}")]
[ProducesResponseType(typeof(FileResult), 200)]
[ApiCosts(0.5)]
public async Task<IActionResult> GetAssetContent(string app, string idOrSlug, string more,
[FromQuery] long version = EtagVersion.Any,
[FromQuery] int? width = null,
[FromQuery] int? height = null,
@ -71,15 +102,20 @@ namespace Squidex.Areas.Api.Controllers.Assets
{
IAssetEntity entity;
if (Guid.TryParse(id, out var guid))
if (Guid.TryParse(idOrSlug, out var guid))
{
entity = await assetRepository.FindAssetAsync(guid);
}
else
{
entity = await assetRepository.FindAssetAsync(id);
entity = await assetRepository.FindAssetBySlugAsync(App.Id, idOrSlug);
}
return DeliverAsset(entity, version, width, height, quality, mode);
}
private IActionResult DeliverAsset(IAssetEntity entity, long version, int? width, int? height, int? quality, string mode)
{
if (entity == null || entity.FileVersion < version || width == 0 || height == 0 || quality == 0)
{
return NotFound();

14
src/Squidex/Areas/Api/Controllers/Assets/Models/AssetCreatedDto.cs

@ -76,6 +76,11 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
/// </summary>
public int? PixelHeight { get; set; }
/// <summary>
/// Indicates if the asset has been already uploaded.
/// </summary>
public bool IsDuplicate { get; set; }
/// <summary>
/// The version of the asset.
/// </summary>
@ -83,22 +88,21 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
public static AssetCreatedDto FromCommand(CreateAsset command, AssetCreatedResult result)
{
var response = new AssetCreatedDto
return new AssetCreatedDto
{
Id = command.AssetId,
Id = result.IdOrValue,
FileName = command.File.FileName,
FileSize = command.File.FileSize,
FileType = command.File.FileName.FileType(),
FileVersion = result.Version,
FileVersion = result.FileVersion,
MimeType = command.File.MimeType,
IsImage = command.ImageInfo != null,
IsDuplicate = result.IsDuplicate,
PixelWidth = command.ImageInfo?.PixelWidth,
PixelHeight = command.ImageInfo?.PixelHeight,
Tags = result.Tags,
Version = result.Version
};
return response;
}
}
}

6
src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs

@ -29,6 +29,12 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
[Required]
public string FileName { get; set; }
/// <summary>
/// The file hash.
/// </summary>
[Required]
public string FileHash { get; set; }
/// <summary>
/// The slug.
/// </summary>

6
src/Squidex/Areas/Api/Controllers/Assets/Models/AssetReplacedDto.cs

@ -19,6 +19,12 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
[Required]
public string MimeType { get; set; }
/// <summary>
/// The file hash.
/// </summary>
[Required]
public string FileHash { get; set; }
/// <summary>
/// The size of the file in bytes.
/// </summary>

2
src/Squidex/Squidex.csproj

@ -153,6 +153,6 @@
</ItemGroup>
<PropertyGroup>
<NoWarn>$(NoWarn);CS1591;1591;1573;1572;NU1605</NoWarn>
<NoWarn>$(NoWarn);CS1591;1591;1573;1572;NU1605;IDE0060</NoWarn>
</PropertyGroup>
</Project>

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

@ -6,7 +6,9 @@ const plugins = {
// https://github.com/webpack-contrib/mini-css-extract-plugin
MiniCssExtractPlugin: require('mini-css-extract-plugin'),
// https://github.com/dividab/tsconfig-paths-webpack-plugin
TsconfigPathsPlugin: require('tsconfig-paths-webpack-plugin')
TsconfigPathsPlugin: require('tsconfig-paths-webpack-plugin'),
// https://github.com/aackerman/circular-dependency-plugin
CircularDependencyPlugin: require('circular-dependency-plugin')
};
const isDevServer = path.basename(require.main.filename) === 'webpack-dev-server.js';
@ -57,7 +59,7 @@ module.exports = {
}, {
test: /\.ts$/,
use: [{
loader: 'awesome-typescript-loader', options: { useCache: true, useBabel: true }
loader: 'awesome-typescript-loader'
}, {
loader: 'angular-router-loader'
}, {
@ -140,6 +142,12 @@ module.exports = {
context: '/'
}
}),
new plugins.CircularDependencyPlugin({
exclude: /([\\\/]node_modules[\\\/])|(ngfactory\.js$)/,
// Add errors to webpack instead of warnings
failOnError: true
}),
new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /en/)
]

1
src/Squidex/app-config/webpack.run.base.js

@ -5,6 +5,7 @@
commonConfig = require('./webpack.config.js');
const plugins = {
// https://github.com/jantimon/html-webpack-plugin
HtmlWebpackPlugin: require('html-webpack-plugin')
};

4
src/Squidex/app-config/webpack.test.coverage.js

@ -16,7 +16,7 @@ module.exports = webpackMerge(testConfig, {
rules: [{
test: /\.ts$/,
use: [{
loader: 'awesome-typescript-loader'
loader: 'ts-loader'
}],
include: [/\.(e2e|spec)\.ts$/],
@ -25,7 +25,7 @@ module.exports = webpackMerge(testConfig, {
use: [{
loader: 'istanbul-instrumenter-loader'
}, {
loader: 'awesome-typescript-loader'
loader: 'ts-loader'
}, {
loader: 'angular-router-loader'
}, {

6
src/Squidex/app/features/administration/declarations.ts

@ -15,8 +15,4 @@ export * from './pages/restore/restore-page.component';
export * from './pages/users/user-page.component';
export * from './pages/users/users-page.component';
export * from './services/event-consumers.service';
export * from './services/users.service';
export * from './state/event-consumers.state';
export * from './state/users.state';
export * from './internal';

3
src/Squidex/app/features/administration/guards/unset-user.guard.spec.ts

@ -8,7 +8,8 @@
import { of } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq';
import { UsersState } from './../state/users.state';
import { UsersState } from '@app/features/administration/internal';
import { UnsetUserGuard } from './unset-user.guard';
describe('UnsetUserGuard', () => {

2
src/Squidex/app/features/administration/guards/unset-user.guard.ts

@ -10,7 +10,7 @@ import { CanActivate } from '@angular/router';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { UsersState } from './../state/users.state';
import { UsersState } from '@app/features/administration/internal';
@Injectable()
export class UnsetUserGuard implements CanActivate {

6
src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts

@ -9,8 +9,8 @@ import { Router } from '@angular/router';
import { of } from 'rxjs';
import { IMock, Mock, Times } from 'typemoq';
import { UserDto } from './../services/users.service';
import { UsersState } from './../state/users.state';
import { SnapshotUser, UsersState } from '@app/features/administration/internal';
import { UserMustExistGuard } from './user-must-exist.guard';
describe('UserMustExistGuard', () => {
@ -32,7 +32,7 @@ describe('UserMustExistGuard', () => {
it('should load user and return true when found', () => {
usersState.setup(x => x.select('123'))
.returns(() => of(<UserDto>{}));
.returns(() => of(<SnapshotUser>{}));
let result: boolean;

2
src/Squidex/app/features/administration/guards/user-must-exist.guard.ts

@ -12,7 +12,7 @@ import { map, tap } from 'rxjs/operators';
import { allParams } from '@app/framework';
import { UsersState } from './../state/users.state';
import { UsersState } from '@app/features/administration/internal';
@Injectable()
export class UserMustExistGuard implements CanActivate {

13
src/Squidex/app/features/administration/internal.ts

@ -0,0 +1,13 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
export * from './services/event-consumers.service';
export * from './services/users.service';
export * from './state/event-consumers.state';
export * from './state/users.forms';
export * from './state/users.state';

21
src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts

@ -11,8 +11,7 @@ import { onErrorResumeNext, switchMap } from 'rxjs/operators';
import { DialogModel, ResourceOwner } from '@app/shared';
import { EventConsumerDto } from './../../services/event-consumers.service';
import { EventConsumersState } from './../../state/event-consumers.state';
import { EventConsumerDto, EventConsumersState } from '@app/features/administration/internal';
@Component({
selector: 'sqx-event-consumers-page',
@ -21,7 +20,7 @@ import { EventConsumersState } from './../../state/event-consumers.state';
})
export class EventConsumersPageComponent extends ResourceOwner implements OnInit {
public eventConsumerErrorDialog = new DialogModel();
public eventConsumerError = '';
public eventConsumerError?: string;
constructor(
public readonly eventConsumersState: EventConsumersState
@ -30,25 +29,25 @@ export class EventConsumersPageComponent extends ResourceOwner implements OnInit
}
public ngOnInit() {
this.eventConsumersState.load().pipe(onErrorResumeNext()).subscribe();
this.eventConsumersState.load();
this.own(timer(5000, 5000).pipe(switchMap(() => this.eventConsumersState.load(true, true)), onErrorResumeNext()));
}
public reload() {
this.eventConsumersState.load(true, false).pipe(onErrorResumeNext()).subscribe();
this.eventConsumersState.load(true, false);
}
public start(es: EventConsumerDto) {
this.eventConsumersState.start(es).pipe(onErrorResumeNext()).subscribe();
public start(eventConsumer: EventConsumerDto) {
this.eventConsumersState.start(eventConsumer);
}
public stop(es: EventConsumerDto) {
this.eventConsumersState.stop(es).pipe(onErrorResumeNext()).subscribe();
public stop(eventConsumer: EventConsumerDto) {
this.eventConsumersState.stop(eventConsumer);
}
public reset(es: EventConsumerDto) {
this.eventConsumersState.reset(es).pipe(onErrorResumeNext()).subscribe();
public reset(eventConsumer: EventConsumerDto) {
this.eventConsumersState.reset(eventConsumer);
}
public trackByEventConsumer(index: number, es: EventConsumerDto) {

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

@ -6,7 +6,7 @@
</ng-container>
<ng-container content>
<div class="card section" *ngIf="restoreJob; let job">
<div class="card section" *ngIf="restoreJob | async; let job">
<div class="card-header">
<div class="row no-gutters">
<div class="col-auto pr-2">

27
src/Squidex/app/features/administration/pages/restore/restore-page.component.ts

@ -5,18 +5,16 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, OnInit } from '@angular/core';
import { Component } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { timer } from 'rxjs';
import { onErrorResumeNext, switchMap } from 'rxjs/operators';
import {
AuthService,
BackupsService,
DialogService,
ResourceOwner,
RestoreDto,
RestoreForm
RestoreForm,
switchSafe
} from '@app/shared';
@Component({
@ -24,34 +22,25 @@ import {
styleUrls: ['./restore-page.component.scss'],
templateUrl: './restore-page.component.html'
})
export class RestorePageComponent extends ResourceOwner implements OnInit {
public restoreJob: RestoreDto | null;
export class RestorePageComponent {
public restoreForm = new RestoreForm(this.formBuilder);
public restoreJob =
timer(0, 2000).pipe(switchSafe(() => this.backupsService.getRestore()));
constructor(
public readonly authState: AuthService,
private readonly backupsService: BackupsService,
private readonly dialogs: DialogService,
private readonly formBuilder: FormBuilder
) {
super();
}
public ngOnInit() {
this.own(
timer(0, 2000).pipe(switchMap(() => this.backupsService.getRestore().pipe(onErrorResumeNext())))
.subscribe(job => {
if (job) {
this.restoreJob = job;
}
}));
}
public restore() {
const value = this.restoreForm.submit();
if (value) {
this.restoreForm.submitCompleted({});
this.restoreForm.submitCompleted();
this.backupsService.postRestore(value)
.subscribe(() => {

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

@ -11,8 +11,12 @@ import { ActivatedRoute, Router } from '@angular/router';
import { ResourceOwner } from '@app/shared';
import { UserDto } from './../../services/users.service';
import { UserForm, UsersState } from './../../state/users.state';
import {
CreateUserDto,
UserDto,
UserForm,
UsersState
} from '@app/features/administration/internal';
@Component({
selector: 'sqx-user-page',
@ -58,7 +62,7 @@ export class UserPageComponent extends ResourceOwner implements OnInit {
this.userForm.submitFailed(error);
});
} else {
this.usersState.create(value)
this.usersState.create(<CreateUserDto>value)
.subscribe(() => {
this.back();
}, error => {

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

@ -7,10 +7,8 @@
import { Component, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import { onErrorResumeNext } from 'rxjs/operators';
import { UserDto } from './../../services/users.service';
import { UsersState } from './../../state/users.state';
import { UserDto, UsersState } from '@app/features/administration/internal';
@Component({
selector: 'sqx-users-page',
@ -26,31 +24,31 @@ export class UsersPageComponent implements OnInit {
}
public ngOnInit() {
this.usersState.load().pipe(onErrorResumeNext()).subscribe();
this.usersState.load();
}
public reload() {
this.usersState.load(true).pipe(onErrorResumeNext()).subscribe();
this.usersState.load(true);
}
public search() {
this.usersState.search(this.usersFilter.value).pipe(onErrorResumeNext()).subscribe();
this.usersState.search(this.usersFilter.value);
}
public goPrev() {
this.usersState.goPrev().pipe(onErrorResumeNext()).subscribe();
this.usersState.goPrev();
}
public goNext() {
this.usersState.goNext().pipe(onErrorResumeNext()).subscribe();
this.usersState.goNext();
}
public lock(user: UserDto) {
this.usersState.lock(user).pipe(onErrorResumeNext()).subscribe();
this.usersState.lock(user);
}
public unlock(user: UserDto) {
this.usersState.unlock(user).pipe(onErrorResumeNext()).subscribe();
this.usersState.unlock(user);
}
public trackByUser(index: number, userInfo: { user: UserDto }) {

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

@ -16,20 +16,16 @@ import {
pretifyError
} from '@app/shared';
export class EventConsumerDto extends Model {
export class EventConsumerDto extends Model<EventConsumerDto> {
constructor(
public readonly name: string,
public readonly isStopped: boolean,
public readonly isResetting: boolean,
public readonly error: string,
public readonly position: string
public readonly isStopped?: boolean,
public readonly isResetting?: boolean,
public readonly error?: string,
public readonly position?: string
) {
super();
}
public with(value: Partial<EventConsumerDto>): EventConsumerDto {
return this.clone(value);
}
}
@Injectable()
@ -44,15 +40,16 @@ export class EventConsumersService {
const url = this.apiUrl.buildUrl('/api/event-consumers');
return this.http.get<any[]>(url).pipe(
map(response => {
return response.map(item => {
return new EventConsumerDto(
map(body => {
const eventConsumers = body.map(item =>
new EventConsumerDto(
item.name,
item.isStopped,
item.isResetting,
item.error,
item.position);
});
item.position));
return eventConsumers;
}),
pretifyError('Failed to load event consumers. Please reload.'));
}

6
src/Squidex/app/features/administration/services/users.service.spec.ts

@ -11,8 +11,6 @@ import { inject, TestBed } from '@angular/core/testing';
import { ApiUrlConfig } from '@app/framework';
import {
CreateUserDto,
UpdateUserDto,
UserDto,
UsersDto,
UsersService
@ -145,7 +143,7 @@ describe('UsersService', () => {
it('should make post request to create user',
inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => {
const dto = new CreateUserDto('mail@squidex.io', 'Squidex User', ['Permission1'], 'password');
const dto = { email: 'mail@squidex.io', displayName: 'Squidex User', permissions: ['Permission1'], password: 'password' };
let user: UserDto;
@ -166,7 +164,7 @@ describe('UsersService', () => {
it('should make put request to update user',
inject([UsersService, HttpTestingController], (userManagementService: UsersService, httpMock: HttpTestingController) => {
const dto = new UpdateUserDto('mail@squidex.io', 'Squidex User', ['Permission1'], 'password');
const dto = { email: 'mail@squidex.io', displayName: 'Squidex User', permissions: ['Permission1'], password: 'password' };
userManagementService.putUser('123', dto).subscribe();

79
src/Squidex/app/features/administration/services/users.service.ts

@ -13,25 +13,19 @@ import { map } from 'rxjs/operators';
import {
ApiUrlConfig,
Model,
pretifyError
pretifyError,
ResultSet
} from '@app/shared';
export class UsersDto extends Model {
constructor(
public readonly total: number,
public readonly items: UserDto[]
) {
super();
}
}
export class UsersDto extends ResultSet<UserDto> {}
export class UserDto extends Model {
export class UserDto extends Model<UserDto> {
constructor(
public readonly id: string,
public readonly email: string,
public readonly displayName: string,
public readonly permissions: string[],
public readonly isLocked: boolean
public readonly permissions: string[] = [],
public readonly isLocked?: boolean
) {
super();
}
@ -41,24 +35,18 @@ export class UserDto extends Model {
}
}
export class CreateUserDto {
constructor(
public readonly email: string,
public readonly displayName: string,
public readonly permissions: string[],
public readonly password: string
) {
}
export interface CreateUserDto {
readonly email: string;
readonly displayName: string;
readonly permissions: string[];
readonly password: string;
}
export class UpdateUserDto {
constructor(
public readonly email: string,
public readonly displayName: string,
public readonly permissions: string[],
public readonly password?: string
) {
}
export interface UpdateUserDto {
readonly email: string;
readonly displayName: string;
readonly permissions: string[];
readonly password?: string;
}
@Injectable()
@ -73,17 +61,16 @@ export class UsersService {
const url = this.apiUrl.buildUrl(`api/user-management?take=${take}&skip=${skip}&query=${query || ''}`);
return this.http.get<{ total: number, items: any[] }>(url).pipe(
map(response => {
const users = response.items.map(item => {
return new UserDto(
map(body => {
const users = body.items.map(item =>
new UserDto(
item.id,
item.email,
item.displayName,
item.permissions,
item.isLocked);
});
item.isLocked));
return new UsersDto(response.total, users);
return new UsersDto(body.total, users);
}),
pretifyError('Failed to load users. Please reload.'));
}
@ -92,13 +79,15 @@ export class UsersService {
const url = this.apiUrl.buildUrl(`api/user-management/${id}`);
return this.http.get<any>(url).pipe(
map(response => {
return new UserDto(
response.id,
response.email,
response.displayName,
response.permissions,
response.isLocked);
map(body => {
const user = new UserDto(
body.id,
body.email,
body.displayName,
body.permissions,
body.isLocked);
return user;
}),
pretifyError('Failed to load user. Please reload.'));
}
@ -107,13 +96,15 @@ export class UsersService {
const url = this.apiUrl.buildUrl('api/user-management');
return this.http.post<any>(url, dto).pipe(
map(response => {
return new UserDto(
response.id,
map(body => {
const user = new UserDto(
body.id,
dto.email,
dto.displayName,
dto.permissions,
false);
return user;
}),
pretifyError('Failed to create user. Please reload.'));
}

109
src/Squidex/app/features/administration/state/event-consumers.state.spec.ts

@ -9,9 +9,9 @@ import { of, throwError } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import { IMock, It, Mock, Times } from 'typemoq';
import { DialogService } from '@app/shared';
import { DialogService } from '@app/framework';
import { EventConsumerDto, EventConsumersService } from './../services/event-consumers.service';
import { EventConsumerDto, EventConsumersService } from '@app/features/administration/internal';
import { EventConsumersState } from './event-consumers.state';
describe('EventConsumersState', () => {
@ -28,83 +28,88 @@ describe('EventConsumersState', () => {
dialogs = Mock.ofType<DialogService>();
eventConsumersService = Mock.ofType<EventConsumersService>();
eventConsumersService.setup(x => x.getEventConsumers())
.returns(() => of(oldConsumers));
eventConsumersState = new EventConsumersState(dialogs.object, eventConsumersService.object);
eventConsumersState.load().subscribe();
});
it('should load event consumers', () => {
expect(eventConsumersState.snapshot.eventConsumers.values).toEqual(oldConsumers);
expect(eventConsumersState.snapshot.isLoaded).toBeTruthy();
expect().nothing();
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never());
afterEach(() => {
eventConsumersService.verifyAll();
});
it('should show notification on load when reload is true', () => {
eventConsumersState.load(true).subscribe();
describe('Loading', () => {
it('should load event consumers', () => {
eventConsumersService.setup(x => x.getEventConsumers())
.returns(() => of(oldConsumers)).verifiable();
expect().nothing();
eventConsumersState.load().subscribe();
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once());
});
expect(eventConsumersState.snapshot.eventConsumers.values).toEqual(oldConsumers);
expect(eventConsumersState.snapshot.isLoaded).toBeTruthy();
it('should show notification on load error when silent is false', () => {
eventConsumersService.setup(x => x.getEventConsumers())
.returns(() => throwError({}));
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never());
});
eventConsumersState.load(true, false).pipe(onErrorResumeNext()).subscribe();
it('should show notification on load when reload is true', () => {
eventConsumersService.setup(x => x.getEventConsumers())
.returns(() => of(oldConsumers)).verifiable();
expect().nothing();
eventConsumersState.load(true).subscribe();
dialogs.verify(x => x.notifyError(It.isAny()), Times.once());
});
expect().nothing();
it('should not show notification on load error when silent is true', () => {
eventConsumersService.setup(x => x.getEventConsumers())
.returns(() => throwError({}));
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once());
});
eventConsumersState.load(true, true).pipe(onErrorResumeNext()).subscribe();
it('should show notification on load error when silent is false', () => {
eventConsumersService.setup(x => x.getEventConsumers())
.returns(() => throwError({})).verifiable();
expect().nothing();
eventConsumersState.load(true, false).pipe(onErrorResumeNext()).subscribe();
dialogs.verify(x => x.notifyError(It.isAny()), Times.never());
expect().nothing();
dialogs.verify(x => x.notifyError(It.isAny()), Times.once());
});
});
it('should unmark as stopped when started', () => {
eventConsumersService.setup(x => x.putStart(oldConsumers[1].name))
.returns(() => of({}));
describe('Updates', () => {
beforeEach(() => {
eventConsumersService.setup(x => x.getEventConsumers())
.returns(() => of(oldConsumers)).verifiable();
eventConsumersState.start(oldConsumers[1]).subscribe();
eventConsumersState.load().subscribe();
});
const es_1 = eventConsumersState.snapshot.eventConsumers.at(1);
it('should unmark as stopped when started', () => {
eventConsumersService.setup(x => x.putStart(oldConsumers[1].name))
.returns(() => of({})).verifiable();
expect(es_1.isStopped).toBeFalsy();
});
eventConsumersState.start(oldConsumers[1]).subscribe();
it('should mark as stopped when stopped', () => {
eventConsumersService.setup(x => x.putStop(oldConsumers[0].name))
.returns(() => of({}));
const es_1 = eventConsumersState.snapshot.eventConsumers.at(1);
eventConsumersState.stop(oldConsumers[0]).subscribe();
expect(es_1.isStopped).toBeFalsy();
});
const es_1 = eventConsumersState.snapshot.eventConsumers.at(0);
it('should mark as stopped when stopped', () => {
eventConsumersService.setup(x => x.putStop(oldConsumers[0].name))
.returns(() => of({})).verifiable();
expect(es_1.isStopped).toBeTruthy();
});
eventConsumersState.stop(oldConsumers[0]).subscribe();
const es_1 = eventConsumersState.snapshot.eventConsumers.at(0);
expect(es_1.isStopped).toBeTruthy();
});
it('should mark as resetting when reset', () => {
eventConsumersService.setup(x => x.putReset(oldConsumers[0].name))
.returns(() => of({}));
it('should mark as resetting when reset', () => {
eventConsumersService.setup(x => x.putReset(oldConsumers[0].name))
.returns(() => of({})).verifiable();
eventConsumersState.reset(oldConsumers[0]).subscribe();
eventConsumersState.reset(oldConsumers[0]).subscribe();
const es_1 = eventConsumersState.snapshot.eventConsumers.at(0);
const es_1 = eventConsumersState.snapshot.eventConsumers.at(0);
expect(es_1.isResetting).toBeTruthy();
expect(es_1.isResetting).toBeTruthy();
});
});
});

49
src/Squidex/app/features/administration/state/event-consumers.state.ts

@ -6,13 +6,13 @@
*/
import { Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { catchError, distinctUntilChanged, map, tap } from 'rxjs/operators';
import { Observable } from 'rxjs';
import { distinctUntilChanged, map, tap } from 'rxjs/operators';
import {
DialogService,
ImmutableArray,
notify,
shareSubscribed,
State
} from '@app/shared';
@ -20,12 +20,14 @@ import { EventConsumerDto, EventConsumersService } from './../services/event-con
interface Snapshot {
// The list of event consumers.
eventConsumers: ImmutableArray<EventConsumerDto>;
eventConsumers: EventConsumersList;
// Indicates if event consumers are loaded.
isLoaded?: boolean;
}
type EventConsumersList = ImmutableArray<EventConsumerDto>;
@Injectable()
export class EventConsumersState extends State<Snapshot> {
public eventConsumers =
@ -49,48 +51,45 @@ export class EventConsumersState extends State<Snapshot> {
}
return this.eventConsumersService.getEventConsumers().pipe(
tap(dtos => {
tap(payload => {
if (isReload && !silent) {
this.dialogs.notifyInfo('Event Consumers reloaded.');
}
this.next(s => {
const eventConsumers = ImmutableArray.of(dtos);
const eventConsumers = ImmutableArray.of(payload);
this.next(s => {
return { ...s, eventConsumers, isLoaded: true };
});
}),
catchError(error => {
if (!silent) {
this.dialogs.notifyError(error);
}
return throwError(error);
}));
shareSubscribed(this.dialogs, { silent }));
}
public start(eventConsumer: EventConsumerDto): Observable<any> {
return this.eventConsumersService.putStart(eventConsumer.name).pipe(
tap(() => {
this.replaceEventConsumer(setStopped(eventConsumer, false));
map(() => setStopped(eventConsumer, false)),
tap(updated => {
this.replaceEventConsumer(updated);
}),
notify(this.dialogs));
shareSubscribed(this.dialogs));
}
public stop(eventConsumer: EventConsumerDto): Observable<any> {
public stop(eventConsumer: EventConsumerDto): Observable<EventConsumerDto> {
return this.eventConsumersService.putStop(eventConsumer.name).pipe(
tap(() => {
this.replaceEventConsumer(setStopped(eventConsumer, true));
map(() => setStopped(eventConsumer, true)),
tap(updated => {
this.replaceEventConsumer(updated);
}),
notify(this.dialogs));
shareSubscribed(this.dialogs));
}
public reset(eventConsumer: EventConsumerDto): Observable<any> {
public reset(eventConsumer: EventConsumerDto): Observable<EventConsumerDto> {
return this.eventConsumersService.putReset(eventConsumer.name).pipe(
tap(() => {
this.replaceEventConsumer(reset(eventConsumer));
map(() => reset(eventConsumer)),
tap(updated => {
this.replaceEventConsumer(updated);
}),
notify(this.dialogs));
shareSubscribed(this.dialogs));
}
private replaceEventConsumer(eventConsumer: EventConsumerDto) {

46
src/Squidex/app/features/administration/state/users.forms.ts

@ -0,0 +1,46 @@
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Form, ValidatorsEx } from '@app/shared';
import { UpdateUserDto } from './../services/users.service';
export class UserForm extends Form<FormGroup, UpdateUserDto> {
constructor(
formBuilder: FormBuilder
) {
super(formBuilder.group({
email: ['',
[
Validators.email,
Validators.required,
Validators.maxLength(100)
]
],
displayName: ['',
[
Validators.required,
Validators.maxLength(100)
]
],
password: ['',
[
Validators.nullValidator
]
],
passwordConfirm: ['',
[
ValidatorsEx.match('password', 'Passwords must be the same.')
]
],
permissions: ['']
}));
}
protected transformLoad(user: UpdateUserDto) {
return { ...user, permissions: user.permissions.join('\n') };
}
protected transformSubmit(value: any): UpdateUserDto {
return { ...value, permissions: value['permissions'].split('\n').filter((x: any) => !!x) };
}
}

267
src/Squidex/app/features/administration/state/users.state.spec.ts

@ -10,15 +10,13 @@ import { IMock, It, Mock, Times } from 'typemoq';
import { AuthService, DialogService } from '@app/shared';
import { UsersState } from './users.state';
import {
CreateUserDto,
UpdateUserDto,
UserDto,
UsersDto,
UsersService
} from './../services/users.service';
} from '@app/features/administration/internal';
import { SnapshotUser, UsersState } from './users.state';
describe('UsersState', () => {
const oldUsers = [
@ -42,184 +40,199 @@ describe('UsersState', () => {
dialogs = Mock.ofType<DialogService>();
usersService = Mock.ofType<UsersService>();
usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(new UsersDto(200, oldUsers)));
usersState = new UsersState(authService.object, dialogs.object, usersService.object);
usersState.load().subscribe();
});
it('should load users', () => {
expect(usersState.snapshot.users.values).toEqual([
{ isCurrentUser: false, user: oldUsers[0] },
{ isCurrentUser: true, user: oldUsers[1] }
]);
expect(usersState.snapshot.usersPager.numberOfItems).toEqual(200);
expect(usersState.snapshot.isLoaded).toBeTruthy();
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never());
afterEach(() => {
usersService.verifyAll();
});
it('should show notification on load when reload is true', () => {
usersState.load(true).subscribe();
describe('Loading', () => {
it('should load users', () => {
usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(new UsersDto(200, oldUsers))).verifiable();
expect().nothing();
usersState.load().subscribe();
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once());
});
expect(usersState.snapshot.users.values).toEqual([
{ isCurrentUser: false, user: oldUsers[0] },
{ isCurrentUser: true, user: oldUsers[1] }
]);
expect(usersState.snapshot.usersPager.numberOfItems).toEqual(200);
expect(usersState.snapshot.isLoaded).toBeTruthy();
it('should replace selected user when reloading', () => {
usersState.select('id1').subscribe();
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.never());
});
const newUsers = [
new UserDto('id1', 'mail1@mail.de_new', 'name1_new', ['Permission1_New'], false),
new UserDto('id2', 'mail2@mail.de_new', 'name2_new', ['Permission2_New'], true)
];
it('should show notification on load when reload is true', () => {
usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(new UsersDto(200, oldUsers))).verifiable();
usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(new UsersDto(200, newUsers)));
usersState.load(true).subscribe();
usersState.load().subscribe();
expect().nothing();
expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: newUsers[0] });
});
dialogs.verify(x => x.notifyInfo(It.isAnyString()), Times.once());
});
it('should return user on select and not load when already loaded', () => {
let selectedUser: UserDto;
it('should replace selected user when reloading', () => {
const newUsers = [
new UserDto('id1', 'mail1@mail.de_new', 'name1_new', ['Permission1_New'], false),
new UserDto('id2', 'mail2@mail.de_new', 'name2_new', ['Permission2_New'], true)
];
usersState.select('id1').subscribe(x => {
selectedUser = x!;
});
usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(new UsersDto(200, oldUsers))).verifiable(Times.exactly(2));
expect(selectedUser!).toEqual(oldUsers[0]);
expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: oldUsers[0] });
usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(new UsersDto(200, newUsers)));
usersService.verify(x => x.getUser(It.isAnyString()), Times.never());
});
usersState.load().subscribe();
usersState.select('id1').subscribe();
usersState.load().subscribe();
expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: newUsers[0] });
});
it('should load next page and prev page when paging', () => {
usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(new UsersDto(200, oldUsers))).verifiable(Times.exactly(2));
it('should return user on select and load when not loaded', () => {
usersService.setup(x => x.getUser('id3'))
.returns(() => of(newUser));
usersService.setup(x => x.getUsers(10, 10, undefined))
.returns(() => of(new UsersDto(200, []))).verifiable();
let selectedUser: UserDto;
usersState.load().subscribe();
usersState.goNext().subscribe();
usersState.goPrev().subscribe();
usersState.select('id3').subscribe(x => {
selectedUser = x!;
expect().nothing();
});
expect(selectedUser!).toEqual(newUser);
expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: newUser });
it('should load with query when searching', () => {
usersService.setup(x => x.getUsers(10, 0, 'my-query'))
.returns(() => of(new UsersDto(0, []))).verifiable();
usersService.verify(x => x.getUser('id3'), Times.once());
usersState.search('my-query').subscribe();
expect(usersState.snapshot.usersQuery).toEqual('my-query');
});
});
it('should return null on select when unselecting user', () => {
let selectedUser: UserDto;
describe('Updates', () => {
beforeEach(() => {
usersService.setup(x => x.getUsers(10, 0, undefined))
.returns(() => of(new UsersDto(200, oldUsers))).verifiable();
usersState.select(null).subscribe(x => {
selectedUser = x!;
usersState.load().subscribe();
});
expect(selectedUser!).toBeNull();
expect(usersState.snapshot.selectedUser).toBeNull();
it('should return user on select and not load when already loaded', () => {
let selectedUser: SnapshotUser;
usersService.verify(x => x.getUser(It.isAnyString()), Times.never());
});
usersState.select('id1').subscribe(x => {
selectedUser = x!;
});
it('should return null on select when user is not found', () => {
usersService.setup(x => x.getUser('unknown'))
.returns(() => throwError({}));
expect(selectedUser!.user).toEqual(oldUsers[0]);
expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: oldUsers[0] });
});
let selectedUser: UserDto;
it('should return user on select and load when not loaded', () => {
usersService.setup(x => x.getUser('id3'))
.returns(() => of(newUser));
usersState.select('unknown').subscribe(x => {
selectedUser = x!;
}).unsubscribe();
let selectedUser: SnapshotUser;
expect(selectedUser!).toBeNull();
expect(usersState.snapshot.selectedUser).toBeNull();
});
usersState.select('id3').subscribe(x => {
selectedUser = x!;
});
it('should mark as locked when locked', () => {
usersService.setup(x => x.lockUser('id1'))
.returns(() => of({}));
expect(selectedUser!.user).toEqual(newUser);
expect(usersState.snapshot.selectedUser).toEqual({ isCurrentUser: false, user: newUser });
});
usersState.select('id1').subscribe();
usersState.lock(oldUsers[0]).subscribe();
it('should return null on select when unselecting user', () => {
let selectedUser: SnapshotUser;
const user_1 = usersState.snapshot.users.at(0);
usersState.select(null).subscribe(x => {
selectedUser = x!;
});
expect(user_1.user.isLocked).toBeTruthy();
expect(user_1).toBe(usersState.snapshot.selectedUser!);
});
expect(selectedUser!).toBeNull();
expect(usersState.snapshot.selectedUser).toBeNull();
});
it('should unmark as locked when unlocked', () => {
usersService.setup(x => x.unlockUser('id2'))
.returns(() => of({}));
it('should return null on select when user is not found', () => {
usersService.setup(x => x.getUser('unknown'))
.returns(() => throwError({})).verifiable();
usersState.select('id2').subscribe();
usersState.unlock(oldUsers[1]).subscribe();
let selectedUser: SnapshotUser;
const user_1 = usersState.snapshot.users.at(1);
usersState.select('unknown').subscribe(x => {
selectedUser = x!;
}).unsubscribe();
expect(user_1.user.isLocked).toBeFalsy();
expect(user_1).toBe(usersState.snapshot.selectedUser!);
});
expect(selectedUser!).toBeNull();
expect(usersState.snapshot.selectedUser).toBeNull();
});
it('should update user properties when updated', () => {
const request = new UpdateUserDto('new@mail.com', 'New', ['Permission1']);
it('should mark as locked when locked', () => {
usersService.setup(x => x.lockUser('id1'))
.returns(() => of({})).verifiable();
usersService.setup(x => x.putUser('id1', request))
.returns(() => of({}));
usersState.select('id1').subscribe();
usersState.lock(oldUsers[0]).subscribe();
usersState.select('id1').subscribe();
usersState.update(oldUsers[0], request).subscribe();
const user_1 = usersState.snapshot.users.at(0);
const user_1 = usersState.snapshot.users.at(0);
expect(user_1.user.isLocked).toBeTruthy();
expect(user_1).toBe(usersState.snapshot.selectedUser!);
});
expect(user_1.user.email).toEqual('new@mail.com');
expect(user_1.user.displayName).toEqual('New');
expect(user_1).toBe(usersState.snapshot.selectedUser!);
});
it('should unmark as locked when unlocked', () => {
usersService.setup(x => x.unlockUser('id2'))
.returns(() => of({})).verifiable();
it('should add user to snapshot when created', () => {
const request = new CreateUserDto(newUser.email, newUser.displayName, newUser.permissions, 'password');
usersState.select('id2').subscribe();
usersState.unlock(oldUsers[1]).subscribe();
usersService.setup(x => x.postUser(request))
.returns(() => of(newUser));
const user_1 = usersState.snapshot.users.at(1);
usersState.create(request).subscribe();
expect(user_1.user.isLocked).toBeFalsy();
expect(user_1).toBe(usersState.snapshot.selectedUser!);
});
expect(usersState.snapshot.users.values).toEqual([
{ isCurrentUser: false, user: newUser },
{ isCurrentUser: false, user: oldUsers[0] },
{ isCurrentUser: true, user: oldUsers[1] }
]);
expect(usersState.snapshot.usersPager.numberOfItems).toBe(201);
});
it('should update user properties when updated', () => {
const request = { email: 'new@mail.com', displayName: 'New', permissions: ['Permission1'] };
it('should load next page and prev page when paging', () => {
usersService.setup(x => x.getUsers(10, 10, undefined))
.returns(() => of(new UsersDto(200, [])));
usersService.setup(x => x.putUser('id1', request))
.returns(() => of({})).verifiable();
usersState.goNext().subscribe();
usersState.goPrev().subscribe();
usersState.select('id1').subscribe();
usersState.update(oldUsers[0], request).subscribe();
expect().nothing();
const user_1 = usersState.snapshot.users.at(0);
usersService.verify(x => x.getUsers(10, 10, undefined), Times.once());
usersService.verify(x => x.getUsers(10, 0, undefined), Times.exactly(2));
});
expect(user_1.user.email).toEqual(request.email);
expect(user_1.user.displayName).toEqual(request.displayName);
expect(user_1.user.permissions).toEqual(request.permissions);
expect(user_1).toBe(usersState.snapshot.selectedUser!);
});
it('should load with query when searching', () => {
usersService.setup(x => x.getUsers(10, 0, 'my-query'))
.returns(() => of(new UsersDto(0, [])));
it('should add user to snapshot when created', () => {
const request = { ...newUser, password: 'password' };
usersState.search('my-query').subscribe();
usersService.setup(x => x.postUser(request))
.returns(() => of(newUser)).verifiable();
expect(usersState.snapshot.usersQuery).toEqual('my-query');
usersState.create(request).subscribe();
usersService.verify(x => x.getUsers(10, 0, 'my-query'), Times.once());
expect(usersState.snapshot.users.values).toEqual([
{ isCurrentUser: false, user: newUser },
{ isCurrentUser: false, user: oldUsers[0] },
{ isCurrentUser: true, user: oldUsers[1] }
]);
expect(usersState.snapshot.usersPager.numberOfItems).toBe(201);
});
});
});

163
src/Squidex/app/features/administration/state/users.state.ts

@ -6,21 +6,18 @@
*/
import { Injectable } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators';
import { catchError, distinctUntilChanged, map, tap } from 'rxjs/operators';
import '@app/framework/utils/rxjs-extensions';
import {
AuthService,
DialogService,
Form,
ImmutableArray,
notify,
Pager,
State,
ValidatorsEx
shareSubscribed,
State
} from '@app/shared';
import {
@ -30,62 +27,7 @@ import {
UsersService
} from './../services/users.service';
export class UserForm extends Form<FormGroup> {
constructor(
formBuilder: FormBuilder
) {
super(formBuilder.group({
email: ['',
[
Validators.email,
Validators.required,
Validators.maxLength(100)
]
],
displayName: ['',
[
Validators.required,
Validators.maxLength(100)
]
],
password: ['',
[
Validators.nullValidator
]
],
passwordConfirm: ['',
[
ValidatorsEx.match('password', 'Passwords must be the same.')
]
],
permissions: ['']
}));
}
public load(user?: UserDto) {
if (user) {
this.form.controls['password'].setValidators(null);
super.load({ ...user, permissions: user.permissions.join('\n') });
} else {
this.form.controls['password'].setValidators(Validators.required);
super.load(undefined);
}
}
public submit() {
const result = super.submit();
if (result) {
result['permissions'] = result['permissions'].split('\n').filter((x: any) => !!x);
}
return result;
}
}
interface SnapshotUser {
export interface SnapshotUser {
// The user.
user: UserDto;
@ -95,7 +37,7 @@ interface SnapshotUser {
interface Snapshot {
// The current users.
users: ImmutableArray<SnapshotUser>;
users: UsersList;
// The pagination information.
usersPager: Pager;
@ -110,6 +52,9 @@ interface Snapshot {
selectedUser?: SnapshotUser | null;
}
export type UsersList = ImmutableArray<SnapshotUser>;
export type UsersResult = { total: number, users: UsersList };
@Injectable()
export class UsersState extends State<Snapshot> {
public users =
@ -136,25 +81,26 @@ export class UsersState extends State<Snapshot> {
super({ users: ImmutableArray.empty(), usersPager: new Pager(0) });
}
public select(id: string | null): Observable<UserDto | null> {
public select(id: string | null): Observable<SnapshotUser | null> {
return this.loadUser(id).pipe(
tap(selectedUser => {
this.next(s => ({ ...s, selectedUser }));
}),
map(x => x && x.user));
shareSubscribed(this.dialogs, { silent: true }));
}
private loadUser(id: string | null) {
return !id ?
of(null) :
of(this.snapshot.users.find(x => x.user.id === id)).pipe(
switchMap(user => {
if (!user) {
return this.usersService.getUser(id).pipe(map(x => this.createUser(x)), catchError(() => of(null)));
} else {
return of(user);
}
}));
if (!id) {
return of(null);
}
const found = this.snapshot.users.find(x => x.user.id === id);
if (found) {
return of(found);
}
return this.usersService.getUser(id).pipe(map(x => this.createUser(x)), catchError(() => of(null)));
}
public load(isReload = false): Observable<any> {
@ -170,14 +116,14 @@ export class UsersState extends State<Snapshot> {
this.snapshot.usersPager.pageSize,
this.snapshot.usersPager.skip,
this.snapshot.usersQuery).pipe(
tap(dtos => {
tap(({ total, items }) => {
if (isReload) {
this.dialogs.notifyInfo('Users reloaded.');
}
this.next(s => {
const users = ImmutableArray.of(dtos.items.map(x => this.createUser(x)));
const usersPager = s.usersPager.setCount(dtos.total);
const usersPager = s.usersPager.setCount(total);
const users = ImmutableArray.of(items.map(x => this.createUser(x)));
let selectedUser = s.selectedUser;
@ -188,57 +134,62 @@ export class UsersState extends State<Snapshot> {
return { ...s, users, usersPager, selectedUser, isLoaded: true };
});
}),
notify(this.dialogs));
shareSubscribed(this.dialogs));
}
public create(request: CreateUserDto): Observable<UserDto> {
return this.usersService.postUser(request).pipe(
tap(dto => {
tap(created => {
this.next(s => {
const users = s.users.pushFront(this.createUser(dto));
const users = s.users.pushFront(this.createUser(created));
const usersPager = s.usersPager.incrementCount();
return { ...s, users, usersPager };
});
}));
}),
shareSubscribed(this.dialogs, { silent: true }));
}
public update(user: UserDto, request: UpdateUserDto): Observable<any> {
public update(user: UserDto, request: UpdateUserDto): Observable<UserDto> {
return this.usersService.putUser(user.id, request).pipe(
tap(() => {
this.replaceUser(update(user, request));
}));
map(() => update(user, request)),
tap(updated => {
this.replaceUser(updated);
}),
shareSubscribed(this.dialogs));
}
public lock(user: UserDto): Observable<any> {
public lock(user: UserDto): Observable<UserDto> {
return this.usersService.lockUser(user.id).pipe(
tap(() => {
this.replaceUser(setLocked(user, true));
map(() => setLocked(user, true)),
tap(updated => {
this.replaceUser(updated);
}),
notify(this.dialogs));
shareSubscribed(this.dialogs));
}
public unlock(user: UserDto): Observable<any> {
public unlock(user: UserDto): Observable<UserDto> {
return this.usersService.unlockUser(user.id).pipe(
tap(() => {
this.replaceUser(setLocked(user, false));
map(() => setLocked(user, false)),
tap(updated => {
this.replaceUser(updated);
}),
notify(this.dialogs));
shareSubscribed(this.dialogs));
}
public search(query: string): Observable<any> {
public search(query: string): Observable<UsersResult> {
this.next(s => ({ ...s, usersPager: new Pager(0), usersQuery: query }));
return this.loadInternal();
}
public goNext(): Observable<any> {
public goNext(): Observable<UsersResult> {
this.next(s => ({ ...s, usersPager: s.usersPager.goNext() }));
return this.loadInternal();
}
public goPrev(): Observable<any> {
public goPrev(): Observable<UsersResult> {
this.next(s => ({ ...s, usersPager: s.usersPager.goPrev() }));
return this.loadInternal();
@ -246,9 +197,13 @@ export class UsersState extends State<Snapshot> {
private replaceUser(user: UserDto) {
return this.next(s => {
const users = s.users.map(u => u.user.id === user.id ? this.createUser(user, u) : u);
const users = s.users.map(u => u.user.id === user.id ? this.createUser(user) : u);
const selectedUser = s.selectedUser && s.selectedUser.user.id === user.id ? users.find(x => x.user.id === user.id) : s.selectedUser;
const selectedUser =
s.selectedUser &&
s.selectedUser.user.id !== user.id ?
s.selectedUser :
users.find(x => x.user.id === user.id);
return { ...s, users, selectedUser };
});
@ -258,14 +213,8 @@ export class UsersState extends State<Snapshot> {
return this.authState.user!.id;
}
private createUser(user: UserDto, current?: SnapshotUser): SnapshotUser {
if (!user) {
return null!;
} else if (current && current.user === user) {
return current;
} else {
return { user, isCurrentUser: user.id === this.userId };
}
private createUser(user: UserDto): SnapshotUser {
return { user, isCurrentUser: user.id === this.userId };
}
}

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

@ -14,7 +14,7 @@
<h3 class="empty-headline">You are not collaborating to any app yet</h3>
</div>
<div class="card card-href card-app float-left" *ngFor="let app of apps" [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">
<h4 class="card-title">{{app.name}}</h4>

5
src/Squidex/app/features/apps/pages/apps-page.component.ts

@ -9,6 +9,7 @@ import { Component, OnInit } from '@angular/core';
import { take } from 'rxjs/operators';
import {
AppDto,
AppsState,
AuthService,
DialogModel,
@ -77,4 +78,8 @@ export class AppsPageComponent implements OnInit {
public stop(event: Event) {
event.stopPropagation();
}
public trackByApp(index: number, app: AppDto) {
return app.id;
}
}

2
src/Squidex/app/features/apps/pages/news-dialog.component.html

@ -7,7 +7,7 @@
<div class="help">
<h1>What's new?</h1>
<div *ngFor="let feature of features">
<div *ngFor="let feature of features; trackBy: trackByFeature">
<h4>{{feature.name}}</h4>
<div [innerHTML]="feature.text | sqxHelpMarkdown"></div>

4
src/Squidex/app/features/apps/pages/news-dialog.component.ts

@ -24,4 +24,8 @@ export class NewsDialogComponent {
public emitClose() {
this.close.emit();
}
public trackByFeature(index: number, feature: FeatureDto) {
return feature;
}
}

9
src/Squidex/app/features/assets/pages/assets-filters-page.component.ts

@ -6,7 +6,6 @@
*/
import { Component } from '@angular/core';
import { onErrorResumeNext } from 'rxjs/operators';
import {
AssetsState,
@ -29,19 +28,19 @@ export class AssetsFiltersPageComponent {
}
public search(query: string) {
this.assetsState.search(query).pipe(onErrorResumeNext()).subscribe();
this.assetsState.search(query);
}
public selectTags(tags: string[]) {
this.assetsState.selectTags(tags).pipe(onErrorResumeNext()).subscribe();
this.assetsState.selectTags(tags);
}
public toggleTag(tag: string) {
this.assetsState.toggleTag(tag).pipe(onErrorResumeNext()).subscribe();
this.assetsState.toggleTag(tag);
}
public resetTags() {
this.assetsState.resetTags().pipe(onErrorResumeNext()).subscribe();
this.assetsState.resetTags();
}
public isSelectedQuery(query: string) {

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

@ -7,7 +7,7 @@
<ng-container menu>
<div class="row no-gutters pl-1">
<div class="col-auto offset-xl-4">
<div class="col-auto offset-xl-2">
<sqx-shortcut keys="ctrl+shift+r" (trigger)="reload()"></sqx-shortcut>
<button type="button" class="btn btn-text-secondary" (click)="reload()" title="Refresh Contents (CTRL + SHIFT + R)">

15
src/Squidex/app/features/assets/pages/assets-page.component.ts

@ -7,7 +7,6 @@
import { Component, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import { onErrorResumeNext } from 'rxjs/operators';
import {
AppsState,
@ -45,7 +44,7 @@ export class AssetsPageComponent extends ResourceOwner implements OnInit {
}
public ngOnInit() {
this.assetsState.load().pipe(onErrorResumeNext()).subscribe();
this.assetsState.load();
this.own(
this.assetsState.assetsQuery
@ -55,27 +54,27 @@ export class AssetsPageComponent extends ResourceOwner implements OnInit {
}
public reload() {
this.assetsState.load(true).pipe(onErrorResumeNext()).subscribe();
this.assetsState.load(true);
}
public search() {
this.assetsState.search(this.filter.apiFilter).pipe(onErrorResumeNext()).subscribe();
this.assetsState.search(this.filter.apiFilter);
}
public selectTags(tags: string[]) {
this.assetsState.selectTags(tags).pipe(onErrorResumeNext()).subscribe();
this.assetsState.selectTags(tags);
}
public toggleTag(tag: string) {
this.assetsState.toggleTag(tag).pipe(onErrorResumeNext()).subscribe();
this.assetsState.toggleTag(tag);
}
public goNext() {
this.assetsState.goNext().pipe(onErrorResumeNext()).subscribe();
this.assetsState.goNext();
}
public goPrev() {
this.assetsState.goPrev().pipe(onErrorResumeNext()).subscribe();
this.assetsState.goPrev();
}
public changeView(isListView: boolean) {

3
src/Squidex/app/features/content/pages/content/content-field.component.ts

@ -19,7 +19,6 @@ import {
LocalStoreService,
RootFieldDto,
SchemaDto,
TranslateDto,
TranslationsService,
Types,
value$
@ -158,7 +157,7 @@ export class ContentFieldComponent implements OnChanges {
const value = control.value;
if (!value) {
const request = new TranslateDto(text, sourceLanguage, targetLanguage);
const request = { text, sourceLanguage, targetLanguage };
this.translations.translate(this.appsState.appName, request)
.subscribe(result => {

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

@ -8,7 +8,7 @@
import { Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { merge, Observable, timer } from 'rxjs';
import { delay, onErrorResumeNext, switchMap } from 'rxjs/operators';
import { delay } from 'rxjs/operators';
import {
allParams,
@ -17,6 +17,7 @@ import {
HistoryEventDto,
HistoryService,
MessageBus,
switchSafe,
Version
} from '@app/shared';
@ -51,7 +52,7 @@ export class ContentHistoryPageComponent {
timer(0, 10000),
this.messageBus.of(HistoryChannelUpdated).pipe(delay(1000))
).pipe(
switchMap(() => this.historyService.getHistory(this.appsState.appName, this.channel).pipe(onErrorResumeNext())));
switchSafe(() => this.historyService.getHistory(this.appsState.appName, this.channel)));
constructor(
private readonly appsState: AppsState,

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

@ -38,7 +38,6 @@
<button type="button" class="btn btn-outline-secondary btn-status" (click)="dropdown.toggle()" [disabled]="schema.isSingleton && !content.isPending"
[class.active]="dropdown.isOpen | async" #optionsButton>
<sqx-content-status
[alignMiddle]="false"
[status]="content.status"
[scheduledTo]="content.scheduleJob?.status"
[scheduledAt]="content.scheduleJob?.dueTime"
@ -112,7 +111,7 @@
Viewing <strong>version {{version.value}}</strong>.
</div>
<div *ngFor="let field of schema.fields">
<div *ngFor="let field of schema.fields; trackBy: trackByField.bind(this)">
<sqx-content-field
[form]="contentForm"
[field]="field"

7
src/Squidex/app/features/content/pages/content/content-page.component.ts

@ -21,6 +21,7 @@ import {
DialogService,
EditContentForm,
fadeAnimation,
FieldDto,
ImmutableArray,
LanguagesState,
MessageBus,
@ -171,7 +172,7 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
}
public discardChanges() {
this.contentsState.discardChanges(this.content).pipe(onErrorResumeNext()).subscribe();
this.contentsState.discardChanges(this.content);
}
public publish() {
@ -241,4 +242,8 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
public showLatest() {
this.loadVersion(null, false);
}
public trackByField(index: number, field: FieldDto) {
return field.fieldId + this.schema.id;
}
}

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

@ -14,7 +14,7 @@
<div class="sidebar-section">
<h3>Saved queries</h3>
<a class="sidebar-item" *ngFor="let query of schemaQueries.queries | async; trackByQuery" (click)="search(query.filter)"
<a class="sidebar-item" *ngFor="let query of schemaQueries.queries | async; trackBy: trackByQuery" (click)="search(query.filter)"
[class.active]="isSelectedQuery(query.filter)">
{{query.name}}

3
src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts

@ -6,7 +6,6 @@
*/
import { Component, OnInit } from '@angular/core';
import { onErrorResumeNext } from 'rxjs/operators';
import {
ContentsState,
@ -43,7 +42,7 @@ export class ContentsFiltersPageComponent extends ResourceOwner implements OnIni
}
public search(query: string) {
this.contentsState.search(query).pipe(onErrorResumeNext()).subscribe();
this.contentsState.search(query);
}
public isSelectedQuery(query: string) {

18
src/Squidex/app/features/content/pages/contents/contents-page.component.ts

@ -77,7 +77,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
this.schema = schema!;
this.schemaQueries = new Queries(this.uiState, `schemas.${this.schema.name}`);
this.contentsState.init().pipe(onErrorResumeNext()).subscribe();
this.contentsState.init();
}));
this.own(
@ -103,15 +103,15 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
}
public reload() {
this.contentsState.load(true).pipe(onErrorResumeNext()).subscribe();
this.contentsState.load(true);
}
public deleteSelected() {
this.contentsState.deleteMany(this.selectItems()).pipe(onErrorResumeNext()).subscribe();
this.contentsState.deleteMany(this.selectItems());
}
public delete(content: ContentDto) {
this.contentsState.deleteMany([content]).pipe(onErrorResumeNext()).subscribe();
this.contentsState.deleteMany([content]);
}
public publish(content: ContentDto) {
@ -147,7 +147,7 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
}
public clone(content: ContentDto) {
this.contentsState.create(content.dataDraft, false).pipe(onErrorResumeNext()).subscribe();
this.contentsState.create(content.dataDraft, false);
}
private changeContentItems(contents: ContentDto[], action: string) {
@ -165,19 +165,19 @@ export class ContentsPageComponent extends ResourceOwner implements OnInit {
}
public goArchive(isArchive: boolean) {
this.contentsState.goArchive(isArchive).pipe(onErrorResumeNext()).subscribe();
this.contentsState.goArchive(isArchive);
}
public goPrev() {
this.contentsState.goPrev().pipe(onErrorResumeNext()).subscribe();
this.contentsState.goPrev();
}
public goNext() {
this.contentsState.goNext().pipe(onErrorResumeNext()).subscribe();
this.contentsState.goNext();
}
public search() {
this.contentsState.search(this.filter.apiFilter).pipe(onErrorResumeNext()).subscribe();
this.contentsState.search(this.filter.apiFilter);
}
public selectLanguage(language: AppLanguageDto) {

3
src/Squidex/app/features/content/pages/schemas/schemas-page.component.ts

@ -7,7 +7,6 @@
import { Component, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import { onErrorResumeNext } from 'rxjs/operators';
import { AppsState, SchemasState } from '@app/shared';
@ -26,7 +25,7 @@ export class SchemasPageComponent implements OnInit {
}
public ngOnInit() {
this.schemasState.load().pipe(onErrorResumeNext()).subscribe();
this.schemasState.load();
}
public trackByCategory(index: number, category: string) {

2
src/Squidex/app/features/content/shared/content-item.component.html

@ -10,7 +10,7 @@
</ng-template>
</td>
<td class="cell-auto" *ngFor="let field of schema.listFields; let i = index" (click)="shouldStop($event, field)">
<td class="cell-auto" *ngFor="let field of schema.listFields; let i = index; trackBy: trackByField.bind(this)" (click)="shouldStop($event, field)">
<ng-container *ngIf="field.isInlineEditable && !isReadOnly; else displayTemplate">
<sqx-content-item-editor [form]="patchForm.form" [field]="field"></sqx-content-item-editor>
</ng-container>

4
src/Squidex/app/features/content/shared/content-item.component.ts

@ -187,5 +187,9 @@ export class ContentItemComponent implements OnChanges {
return undefined;
}
public trackByField(index: number, field: FieldDto) {
return field.fieldId + this.schema.id;
}
}

4
src/Squidex/app/features/content/shared/content-status.component.html

@ -1,11 +1,11 @@
<ng-container *ngIf="scheduledTo; else noSchedule">
<span class="content-status content-status-{{scheduledTo | lowercase}} mr-1" [class.middle]="alignMiddle" title="{{tooltipText}}" titlePosition="top">
<span class="content-status content-status-{{scheduledTo | lowercase}} mr-1" title="{{tooltipText}}" titlePosition="top">
<i class="icon-clock"></i>
</span>
</ng-container>
<ng-template #noSchedule>
<span class="content-status content-status-{{displayStatus | lowercase}} mr-1" [class.middle]="alignMiddle" title="{{tooltipText}}" titlePosition="top">
<span class="content-status content-status-{{displayStatus | lowercase}} mr-1" title="{{tooltipText}}" titlePosition="top">
<i class="icon-circle"></i>
</span>
</ng-template>

8
src/Squidex/app/features/content/shared/content-status.component.scss

@ -2,6 +2,10 @@
@import '_mixins';
.content-status {
& {
vertical-align: middle;
}
&-published {
color: $color-theme-green;
}
@ -31,8 +35,4 @@
color: $color-dark-foreground;
padding: .75rem;
}
}
.middle {
vertical-align: middle;
}

3
src/Squidex/app/features/content/shared/content-status.component.ts

@ -31,9 +31,6 @@ export class ContentStatusComponent {
@Input()
public showLabel = false;
@Input()
public alignMiddle = true;
public get tooltipText() {
if (this.scheduledAt) {
return `Will be set to '${this.scheduledTo}' at ${this.scheduledAt.toStringFormat('LLLL')}`;

11
src/Squidex/app/features/content/shared/contents-selector.component.ts

@ -6,7 +6,6 @@
*/
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { onErrorResumeNext } from 'rxjs/operators';
import {
ContentDto,
@ -54,23 +53,23 @@ export class ContentsSelectorComponent implements OnInit {
public ngOnInit() {
this.contentsState.schema = this.schema;
this.contentsState.load().pipe(onErrorResumeNext()).subscribe();
this.contentsState.load();
}
public reload() {
this.contentsState.load(true).pipe(onErrorResumeNext()).subscribe();
this.contentsState.load(true);
}
public search() {
this.contentsState.search(this.filter.apiFilter).pipe(onErrorResumeNext()).subscribe();
this.contentsState.search(this.filter.apiFilter);
}
public goNext() {
this.contentsState.goNext().pipe(onErrorResumeNext()).subscribe();
this.contentsState.goNext();
}
public goPrev() {
this.contentsState.goPrev().pipe(onErrorResumeNext()).subscribe();
this.contentsState.goPrev();
}
public isItemSelected(content: ContentDto) {

2
src/Squidex/app/features/content/shared/references-editor.component.html

@ -9,7 +9,7 @@
<table class="table table-items table-fixed" [class.disabled]="snapshot.isDisabled" *ngIf="snapshot.schema && snapshot.contentItems && snapshot.contentItems.length > 0"
[sqxSortModel]="snapshot.contentItems.mutableValues"
(sqxSort)="sort($event)">
<tbody *ngFor="let content of snapshot.contentItems">
<tbody *ngFor="let content of snapshot.contentItems; trackBy: trackByContent">
<tr [sqxContent]="content"
[language]="language"
[isReadOnly]="true"

4
src/Squidex/app/features/content/shared/references-editor.component.ts

@ -144,4 +144,8 @@ export class ReferencesEditorComponent extends StatefulControlComponent<State, s
this.callTouched();
this.callChange(ids);
}
public trackByContent(index: number, content: ContentDto) {
return content.id;
}
}

13
src/Squidex/app/features/rules/pages/events/rule-events-page.component.ts

@ -6,7 +6,6 @@
*/
import { Component, OnInit } from '@angular/core';
import { onErrorResumeNext } from 'rxjs/operators';
import {
AppsState,
@ -29,27 +28,27 @@ export class RuleEventsPageComponent implements OnInit {
}
public ngOnInit() {
this.ruleEventsState.load().pipe(onErrorResumeNext()).subscribe();
this.ruleEventsState.load();
}
public reload() {
this.ruleEventsState.load(true).pipe(onErrorResumeNext()).subscribe();
this.ruleEventsState.load(true);
}
public goNext() {
this.ruleEventsState.goNext().pipe(onErrorResumeNext()).subscribe();
this.ruleEventsState.goNext();
}
public goPrev() {
this.ruleEventsState.goPrev().pipe(onErrorResumeNext()).subscribe();
this.ruleEventsState.goPrev();
}
public enqueue(event: RuleEventDto) {
this.ruleEventsState.enqueue(event).pipe(onErrorResumeNext()).subscribe();
this.ruleEventsState.enqueue(event);
}
public cancel(event: RuleEventDto) {
this.ruleEventsState.cancel(event).pipe(onErrorResumeNext()).subscribe();
this.ruleEventsState.cancel(event);
}
public selectEvent(id: string) {

7
src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts

@ -9,7 +9,6 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FormGroup } from '@angular/forms';
import {
CreateRuleDto,
Form,
ImmutableArray,
RuleDto,
@ -28,11 +27,11 @@ export const MODE_EDIT_ACTION = 'EditAction';
templateUrl: './rule-wizard.component.html'
})
export class RuleWizardComponent implements OnInit {
public actionForm = new Form<FormGroup>(new FormGroup({}));
public actionForm = new Form<FormGroup, any>(new FormGroup({}));
public actionType: string;
public action: any = {};
public triggerForm = new Form<FormGroup>(new FormGroup({}));
public triggerForm = new Form<FormGroup, any>(new FormGroup({}));
public triggerType: string;
public trigger: any = {};
@ -118,7 +117,7 @@ export class RuleWizardComponent implements OnInit {
}
private createRule() {
const requestDto = new CreateRuleDto(this.trigger, this.action);
const requestDto = { trigger: this.trigger, action: this.action };
this.rulesState.create(requestDto)
.subscribe(() => {

13
src/Squidex/app/features/rules/pages/rules/rules-page.component.ts

@ -6,7 +6,6 @@
*/
import { Component, OnInit } from '@angular/core';
import { onErrorResumeNext } from 'rxjs/operators';
import {
ALL_TRIGGERS,
@ -42,29 +41,29 @@ export class RulesPageComponent implements OnInit {
}
public ngOnInit() {
this.rulesState.load().pipe(onErrorResumeNext()).subscribe();
this.rulesState.load();
this.rulesService.getActions()
.subscribe(actions => {
this.ruleActions = actions;
});
this.schemasState.load().pipe(onErrorResumeNext()).subscribe();
this.schemasState.load();
}
public reload() {
this.rulesState.load(true).pipe(onErrorResumeNext()).subscribe();
this.rulesState.load(true);
}
public delete(rule: RuleDto) {
this.rulesState.delete(rule).pipe(onErrorResumeNext()).subscribe();
this.rulesState.delete(rule);
}
public toggle(rule: RuleDto) {
if (rule.isEnabled) {
this.rulesState.disable(rule).pipe(onErrorResumeNext()).subscribe();
this.rulesState.disable(rule);
} else {
this.rulesState.enable(rule).pipe(onErrorResumeNext()).subscribe();
this.rulesState.enable(rule);
}
}

2
src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.html

@ -38,7 +38,7 @@
<form class="form-inline" (ngSubmit)="addSchema()">
<div class="form-group mr-1">
<select class="form-control schemas-control" [(ngModel)]="schemaToAdd" name="schema">
<option *ngFor="let schema of schemasToAdd" [ngValue]="schema">{{schema.displayName}}</option>
<option *ngFor="let schema of schemasToAdd; trackBy: trackBySchema" [ngValue]="schema">{{schema.displayName}}</option>
</select>
</div>

4
src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts

@ -103,4 +103,8 @@ export class ContentChangedTriggerComponent implements OnInit {
this.schemasToAdd = this.schemas.filter(schema => !this.triggerSchemas.find(s => s.schema.id === schema.id)).sortByStringAsc(x => x.name);
this.schemaToAdd = this.schemasToAdd.at(0);
}
public trackBySchema(index: number, schema: SchemaDto) {
return schema.id;
}
}

9
src/Squidex/app/features/schemas/pages/schema/field-wizard.component.ts

@ -18,10 +18,11 @@ import {
RootFieldDto,
SchemaDetailsDto,
SchemasState,
Types,
UpdateFieldDto
Types
} from '@app/shared';
const DEFAULT_FIELD = { name: '', partitioning: 'invariant', properties: createProperties('String') };
@Component({
selector: 'sqx-field-wizard',
styleUrls: ['./field-wizard.component.scss'],
@ -74,7 +75,7 @@ export class FieldWizardComponent implements OnInit {
.subscribe(dto => {
this.field = dto;
this.addFieldForm.submitCompleted({ type: fieldTypes[0].type });
this.addFieldForm.submitCompleted({ ...DEFAULT_FIELD });
if (addNew) {
if (Types.isFunction(this.nameInput.nativeElement.focus)) {
@ -103,7 +104,7 @@ export class FieldWizardComponent implements OnInit {
if (value) {
const properties = createProperties(this.field.properties['fieldType'], value);
this.schemasState.updateField(this.schema, this.field as RootFieldDto, new UpdateFieldDto(properties))
this.schemasState.updateField(this.schema, this.field as RootFieldDto, { properties })
.subscribe(() => {
this.editForm.submitCompleted();

22
src/Squidex/app/features/schemas/pages/schema/field.component.ts

@ -7,10 +7,8 @@
import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { onErrorResumeNext } from 'rxjs/operators';
import {
AppPatternDto,
createProperties,
DialogModel,
EditFieldForm,
@ -18,10 +16,10 @@ import {
ImmutableArray,
ModalModel,
NestedFieldDto,
PatternDto,
RootFieldDto,
SchemaDetailsDto,
SchemasState,
UpdateFieldDto
SchemasState
} from '@app/shared';
@Component({
@ -43,7 +41,7 @@ export class FieldComponent implements OnChanges {
public parent: RootFieldDto;
@Input()
public patterns: ImmutableArray<AppPatternDto>;
public patterns: ImmutableArray<PatternDto>;
public dropdown = new ModalModel();
@ -83,23 +81,23 @@ export class FieldComponent implements OnChanges {
}
public deleteField() {
this.schemasState.deleteField(this.schema, this.field).pipe(onErrorResumeNext()).subscribe();
this.schemasState.deleteField(this.schema, this.field);
}
public enableField() {
this.schemasState.enableField(this.schema, this.field).pipe(onErrorResumeNext()).subscribe();
this.schemasState.enableField(this.schema, this.field);
}
public disableField() {
this.schemasState.disableField(this.schema, this.field).pipe(onErrorResumeNext()).subscribe();
this.schemasState.disableField(this.schema, this.field);
}
public showField() {
this.schemasState.showField(this.schema, this.field).pipe(onErrorResumeNext()).subscribe();
this.schemasState.showField(this.schema, this.field);
}
public hideField() {
this.schemasState.hideField(this.schema, this.field).pipe(onErrorResumeNext()).subscribe();
this.schemasState.hideField(this.schema, this.field);
}
public sortFields(fields: NestedFieldDto[]) {
@ -107,7 +105,7 @@ export class FieldComponent implements OnChanges {
}
public lockField() {
this.schemasState.lockField(this.schema, this.field).pipe(onErrorResumeNext()).subscribe();
this.schemasState.lockField(this.schema, this.field);
}
public trackByField(index: number, field: NestedFieldDto) {
@ -120,7 +118,7 @@ export class FieldComponent implements OnChanges {
if (value) {
const properties = createProperties(this.field.properties['fieldType'], value);
this.schemasState.updateField(this.schema, this.field, new UpdateFieldDto(properties))
this.schemasState.updateField(this.schema, this.field, { properties })
.subscribe(() => {
this.editForm.submitCompleted();
}, error => {

6
src/Squidex/app/features/schemas/pages/schema/forms/field-form-validation.component.ts

@ -9,9 +9,9 @@ import { Component, Input } from '@angular/core';
import { FormGroup } from '@angular/forms';
import {
AppPatternDto,
FieldDto,
ImmutableArray
ImmutableArray,
PatternDto
} from '@app/shared';
@Component({
@ -27,5 +27,5 @@ export class FieldFormValidationComponent {
public field: FieldDto;
@Input()
public patterns: ImmutableArray<AppPatternDto>;
public patterns: ImmutableArray<PatternDto>;
}

3
src/Squidex/app/features/schemas/pages/schema/schema-page.component.ts

@ -9,7 +9,6 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { onErrorResumeNext } from 'rxjs/operators';
import {
AppsState,
@ -66,7 +65,7 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit {
}
public ngOnInit() {
this.patternsState.load().pipe(onErrorResumeNext()).subscribe();
this.patternsState.load();
this.own(
this.schemasState.selectedSchema

4
src/Squidex/app/features/schemas/pages/schema/schema-preview-urls-form.component.ts

@ -46,7 +46,7 @@ export class SchemaPreviewUrlsFormComponent implements OnInit {
}
public cancelAdd() {
this.addForm.submitCompleted({});
this.addForm.submitCompleted();
}
public add() {
@ -55,7 +55,7 @@ export class SchemaPreviewUrlsFormComponent implements OnInit {
if (value) {
this.editForm.add(value);
this.addForm.submitCompleted({});
this.cancelAdd();
}
}

6
src/Squidex/app/features/schemas/pages/schema/types/string-validation.component.ts

@ -10,11 +10,11 @@ import { FormControl, FormGroup } from '@angular/forms';
import { Observable } from 'rxjs';
import {
AppPatternDto,
FieldDto,
hasNoValue$,
ImmutableArray,
ModalModel,
PatternDto,
ResourceOwner,
RootFieldDto,
StringFieldPropertiesDto,
@ -37,7 +37,7 @@ export class StringValidationComponent extends ResourceOwner implements OnInit {
public properties: StringFieldPropertiesDto;
@Input()
public patterns: ImmutableArray<AppPatternDto>;
public patterns: ImmutableArray<PatternDto>;
public showDefaultValue: Observable<boolean>;
public showPatternMessage: boolean;
@ -93,7 +93,7 @@ export class StringValidationComponent extends ResourceOwner implements OnInit {
this.setPatternName();
}
public setPattern(pattern: AppPatternDto) {
public setPattern(pattern: PatternDto) {
this.patternName = pattern.name;
this.editForm.controls['pattern'].setValue(pattern.pattern);
this.editForm.controls['patternMessage'].setValue(pattern.message);

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

@ -44,7 +44,7 @@ export class SchemaFormComponent implements OnInit {
}
public ngOnInit() {
this.createForm.load({ import: this.import });
this.createForm.load({ name: '', import: this.import });
this.showImport = !!this.import;
}

6
src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.ts

@ -8,7 +8,7 @@
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormControl } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { map, onErrorResumeNext } from 'rxjs/operators';
import { map } from 'rxjs/operators';
import {
AppsState,
@ -63,7 +63,7 @@ export class SchemasPageComponent extends ResourceOwner implements OnInit {
}
}));
this.schemasState.load().pipe(onErrorResumeNext()).subscribe();
this.schemasState.load();
}
public removeCategory(name: string) {
@ -77,7 +77,7 @@ export class SchemasPageComponent extends ResourceOwner implements OnInit {
try {
this.schemasState.addCategory(value.name);
} finally {
this.addCategoryForm.submitCompleted({});
this.addCategoryForm.submitCompleted();
}
}
}

8
src/Squidex/app/features/settings/pages/backups/backups-page.component.ts

@ -30,7 +30,7 @@ export class BackupsPageComponent extends ResourceOwner implements OnInit {
}
public ngOnInit() {
this.backupsState.load().pipe(onErrorResumeNext()).subscribe();
this.backupsState.load();
this.own(
timer(3000, 3000).pipe(switchMap(() => this.backupsState.load(true, true).pipe(onErrorResumeNext())))
@ -38,15 +38,15 @@ export class BackupsPageComponent extends ResourceOwner implements OnInit {
}
public reload() {
this.backupsState.load(true, false).pipe(onErrorResumeNext()).subscribe();
this.backupsState.load(true, false);
}
public start() {
this.backupsState.start().pipe(onErrorResumeNext()).subscribe();
this.backupsState.start();
}
public delete(backup: BackupDto) {
this.backupsState.delete(backup).pipe(onErrorResumeNext()).subscribe();
this.backupsState.delete(backup);
}
public trackByBackup(index: number, item: BackupDto) {

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

@ -71,7 +71,7 @@
</label>
<div class="col cell-input">
<select class="form-control" [ngModel]="client.role" (ngModelChange)="update($event)">
<option *ngFor="let role of clientRoles" [ngValue]="role.name">{{role.name}}</option>
<option *ngFor="let role of clientRoles; trackBy: trackByRole" [ngValue]="role.name">{{role.name}}</option>
</select>
</div>
<div class="col-auto cell-actions"></div>

87
src/Squidex/app/features/settings/pages/clients/client.component.ts

@ -7,54 +7,22 @@
import { Component, Input, OnChanges } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { onErrorResumeNext } from 'rxjs/operators';
import {
AccessTokenDto,
ApiUrlConfig,
AppClientDto,
AppClientsService,
AppRoleDto,
AppsState,
ClientDto,
ClientsService,
ClientsState,
DialogModel,
DialogService,
RenameClientForm,
UpdateAppClientDto
RoleDto
} from '@app/shared';
const ESCAPE_KEY = 27;
function connectHttpText(apiUrl: ApiUrlConfig, app: string, client: { id: string, secret: string }) {
const url = apiUrl.buildUrl('identity-server/connect/token');
return `$ curl
-X POST '${url}'
-H 'Content-Type: application/x-www-form-urlencoded'
-d 'grant_type=client_credentials&
client_id=${app}:${client.id}&
client_secret=${client.secret}&
scope=squidex-api`;
}
function connectCLIWinText(app: string, client: { id: string, secret: string }) {
return `.\\sq.exe config add ${app} ${app}:${client.id} ${client.secret};.\\sq.exe config use ${app}`;
}
function connectCLINixText(app: string, client: { id: string, secret: string }) {
return `sq config add ${app} ${app}:${client.id} ${client.secret} && sq config use ${app}`;
}
function connectLibrary(apiUrl: ApiUrlConfig, app: string, client: { id: string, secret: string }) {
const url = apiUrl.value;
return `var clientManager = new SquidexClientManager(
"${url}",
"${app}",
"${app}:${client.id}",
"${client.secret}")`;
}
@Component({
selector: 'sqx-client',
styleUrls: ['./client.component.scss'],
@ -62,10 +30,10 @@ function connectLibrary(apiUrl: ApiUrlConfig, app: string, client: { id: string,
})
export class ClientComponent implements OnChanges {
@Input()
public client: AppClientDto;
public client: ClientDto;
@Input()
public clientRoles: AppRoleDto[];
public clientRoles: RoleDto[];
public isRenaming = false;
@ -82,7 +50,7 @@ export class ClientComponent implements OnChanges {
constructor(
public readonly appsState: AppsState,
private readonly apiUrl: ApiUrlConfig,
private readonly appClientsService: AppClientsService,
private readonly clientsService: ClientsService,
private readonly clientsState: ClientsState,
private readonly dialogs: DialogService,
private readonly formBuilder: FormBuilder
@ -101,11 +69,11 @@ export class ClientComponent implements OnChanges {
}
public revoke() {
this.clientsState.revoke(this.client).pipe(onErrorResumeNext()).subscribe();
this.clientsState.revoke(this.client);
}
public update(role: string) {
this.clientsState.update(this.client, new UpdateAppClientDto(undefined, role)).pipe(onErrorResumeNext()).subscribe();
this.clientsState.update(this.client, { role });
}
public toggleRename() {
@ -122,9 +90,7 @@ export class ClientComponent implements OnChanges {
const value = this.renameForm.submit();
if (value) {
const requestDto = new UpdateAppClientDto(value.name);
this.clientsState.update(this.client, requestDto)
this.clientsState.update(this.client, value)
.subscribe(() => {
this.renameForm.submitCompleted();
@ -138,12 +104,45 @@ export class ClientComponent implements OnChanges {
public connect() {
this.connectDialog.show();
this.appClientsService.createToken(this.appsState.appName, this.client)
this.clientsService.createToken(this.appsState.appName, this.client)
.subscribe(dto => {
this.connectToken = dto;
}, error => {
this.dialogs.notifyError(error);
});
}
public trackByRole(index: number, role: RoleDto) {
return role.name;
}
}
function connectHttpText(apiUrl: ApiUrlConfig, app: string, client: { id: string, secret: string }) {
const url = apiUrl.buildUrl('identity-server/connect/token');
return `$ curl
-X POST '${url}'
-H 'Content-Type: application/x-www-form-urlencoded'
-d 'grant_type=client_credentials&
client_id=${app}:${client.id}&
client_secret=${client.secret}&
scope=squidex-api`;
}
function connectCLIWinText(app: string, client: { id: string, secret: string }) {
return `.\\sq.exe config add ${app} ${app}:${client.id} ${client.secret};.\\sq.exe config use ${app}`;
}
function connectCLINixText(app: string, client: { id: string, secret: string }) {
return `sq config add ${app} ${app}:${client.id} ${client.secret} && sq config use ${app}`;
}
function connectLibrary(apiUrl: ApiUrlConfig, app: string, client: { id: string, secret: string }) {
const url = apiUrl.value;
return `var clientManager = new SquidexClientManager(
"${url}",
"${app}",
"${app}:${client.id}",
"${client.secret}")`;
}

16
src/Squidex/app/features/settings/pages/clients/clients-page.component.ts

@ -7,14 +7,12 @@
import { Component, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { onErrorResumeNext } from 'rxjs/operators';
import {
AppClientDto,
AppsState,
AttachClientForm,
ClientDto,
ClientsState,
CreateAppClientDto,
RolesState
} from '@app/shared';
@ -35,22 +33,20 @@ export class ClientsPageComponent implements OnInit {
}
public ngOnInit() {
this.rolesState.load().pipe(onErrorResumeNext()).subscribe();
this.rolesState.load();
this.clientsState.load().pipe(onErrorResumeNext()).subscribe();
this.clientsState.load();
}
public reload() {
this.clientsState.load(true).pipe(onErrorResumeNext()).subscribe();
this.clientsState.load(true);
}
public attachClient() {
const value = this.addClientForm.submit();
if (value) {
const requestDto = new CreateAppClientDto(value.name);
this.clientsState.attach(requestDto)
this.clientsState.attach({ id: value.name })
.subscribe(() => {
this.addClientForm.submitCompleted();
}, error => {
@ -63,7 +59,7 @@ export class ClientsPageComponent implements OnInit {
this.addClientForm.submitCompleted();
}
public trackByClient(index: number, item: AppClientDto) {
public trackByClient(index: number, item: ClientDto) {
return item.id;
}
}

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

@ -8,14 +8,13 @@
import { Component, Injectable, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { Observable } from 'rxjs';
import { onErrorResumeNext, withLatestFrom } from 'rxjs/operators';
import { withLatestFrom } from 'rxjs/operators';
import {
AppContributorDto,
AppsState,
AssignContributorDto,
AssignContributorForm,
AutocompleteSource,
ContributorDto,
ContributorsState,
DialogService,
RolesState,
@ -69,21 +68,21 @@ export class ContributorsPageComponent implements OnInit {
}
public ngOnInit() {
this.rolesState.load().pipe(onErrorResumeNext()).subscribe();
this.rolesState.load();
this.contributorsState.load().pipe(onErrorResumeNext()).subscribe();
this.contributorsState.load();
}
public reload() {
this.contributorsState.load(true).pipe(onErrorResumeNext()).subscribe();
this.contributorsState.load(true);
}
public remove(contributor: AppContributorDto) {
this.contributorsState.revoke(contributor).pipe(onErrorResumeNext()).subscribe();
public remove(contributor: ContributorDto) {
this.contributorsState.revoke(contributor);
}
public changeRole(contributor: AppContributorDto, role: string) {
this.contributorsState.assign(new AssignContributorDto(contributor.contributorId, role)).pipe(onErrorResumeNext()).subscribe();
public changeRole(contributor: ContributorDto, role: string) {
this.contributorsState.assign({ contributorId: contributor.contributorId, role });
}
public assignContributor() {
@ -96,11 +95,11 @@ export class ContributorsPageComponent implements OnInit {
user = user.id;
}
const requestDto = new AssignContributorDto(user, 'Editor', true);
const requestDto = { contributorId: user, role: 'Editor', invite: true };
this.contributorsState.assign(requestDto)
.subscribe(isCreated => {
this.assignContributorForm.submitCompleted({});
this.assignContributorForm.submitCompleted();
if (isCreated) {
this.dialogs.notifyInfo('A new user with the entered email address has been created and assigned as contributor.');
@ -111,7 +110,7 @@ export class ContributorsPageComponent implements OnInit {
}
}
public trackByContributor(index: number, contributorInfo: { contributor: AppContributorDto }) {
public trackByContributor(index: number, contributorInfo: { contributor: ContributorDto }) {
return contributorInfo.contributor.contributorId;
}
}

8
src/Squidex/app/features/settings/pages/languages/language.component.ts

@ -7,15 +7,13 @@
import { Component, Input, OnChanges } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { onErrorResumeNext } from 'rxjs/operators';
import {
AppLanguageDto,
EditLanguageForm,
fadeAnimation,
ImmutableArray,
LanguagesState,
UpdateAppLanguageDto
LanguagesState
} from '@app/shared';
@Component({
@ -57,14 +55,14 @@ export class LanguageComponent implements OnChanges {
}
public remove() {
this.languagesState.remove(this.language).pipe(onErrorResumeNext()).subscribe();
this.languagesState.remove(this.language);
}
public save() {
const value = this.editForm.submit();
if (value) {
const request = new UpdateAppLanguageDto(value.isMaster, value.isOptional, this.fallbackLanguages.map(x => x.iso2Code).values);
const request = { ...value, fallbackLanguages: this.fallbackLanguages.map(x => x.iso2Code).values };
this.languagesState.update(this.language, request)
.subscribe(() => {

5
src/Squidex/app/features/settings/pages/languages/languages-page.component.ts

@ -7,7 +7,6 @@
import { Component, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { onErrorResumeNext } from 'rxjs/operators';
import {
AddLanguageForm,
@ -42,11 +41,11 @@ export class LanguagesPageComponent extends ResourceOwner implements OnInit {
}
}));
this.languagesState.load().pipe(onErrorResumeNext()).subscribe();
this.languagesState.load();
}
public reload() {
this.languagesState.load(true).pipe(onErrorResumeNext()).subscribe();
this.languagesState.load(true);
}
public addLanguage() {

9
src/Squidex/app/features/settings/pages/patterns/pattern.component.ts

@ -7,11 +7,10 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { onErrorResumeNext } from 'rxjs/operators';
import {
AppPatternDto,
EditPatternForm,
PatternDto,
PatternsState
} from '@app/shared';
@ -22,7 +21,7 @@ import {
})
export class PatternComponent implements OnInit {
@Input()
public pattern: AppPatternDto;
public pattern: PatternDto;
public editForm = new EditPatternForm(this.formBuilder);
@ -41,7 +40,7 @@ export class PatternComponent implements OnInit {
}
public delete() {
this.patternsState.delete(this.pattern).pipe(onErrorResumeNext()).subscribe();
this.patternsState.delete(this.pattern);
}
public save() {
@ -58,7 +57,7 @@ export class PatternComponent implements OnInit {
} else {
this.patternsState.create(value)
.subscribe(() => {
this.editForm.submitCompleted({});
this.editForm.submitCompleted();
}, error => {
this.editForm.submitFailed(error);
});

9
src/Squidex/app/features/settings/pages/patterns/patterns-page.component.ts

@ -6,11 +6,10 @@
*/
import { Component, OnInit } from '@angular/core';
import { onErrorResumeNext } from 'rxjs/operators';
import {
AppPatternDto,
AppsState,
PatternDto,
PatternsState
} from '@app/shared';
@ -27,14 +26,14 @@ export class PatternsPageComponent implements OnInit {
}
public ngOnInit() {
this.patternsState.load().pipe(onErrorResumeNext()).subscribe();
this.patternsState.load();
}
public reload() {
this.patternsState.load(true).pipe(onErrorResumeNext()).subscribe();
this.patternsState.load(true);
}
public trackByPattern(index: number, pattern: AppPatternDto) {
public trackByPattern(index: number, pattern: PatternDto) {
return pattern.id;
}
}

7
src/Squidex/app/features/settings/pages/plans/plans-page.component.ts

@ -7,7 +7,6 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { onErrorResumeNext } from 'rxjs/operators';
import {
ApiUrlConfig,
@ -39,15 +38,15 @@ export class PlansPageComponent implements OnInit {
this.overridePlanId = params['planId'];
}).unsubscribe();
this.plansState.load(false, this.overridePlanId).pipe(onErrorResumeNext()).subscribe();
this.plansState.load(false, this.overridePlanId);
}
public reload() {
this.plansState.load(true, this.overridePlanId).pipe(onErrorResumeNext()).subscribe();
this.plansState.load(true, this.overridePlanId);
}
public change(planId: string) {
this.plansState.change(planId).pipe(onErrorResumeNext()).subscribe();
this.plansState.change(planId);
}
public trackByPlan(index: number, planInfo: { plan: PlanDto }) {

14
src/Squidex/app/features/settings/pages/roles/role.component.ts

@ -7,17 +7,15 @@
import { Component, Input, OnChanges, ViewChild } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { onErrorResumeNext } from 'rxjs/operators';
import {
AddPermissionForm,
AppRoleDto,
AutocompleteComponent,
AutocompleteSource,
EditPermissionsForm,
fadeAnimation,
RolesState,
UpdateAppRoleDto
RoleDto,
RolesState
} from '@app/shared';
const DEFAULT_ROLES = [
@ -37,7 +35,7 @@ const DEFAULT_ROLES = [
})
export class RoleComponent implements OnChanges {
@Input()
public role: AppRoleDto;
public role: RoleDto;
@Input()
public allPermissions: AutocompleteSource;
@ -77,7 +75,7 @@ export class RoleComponent implements OnChanges {
}
public remove() {
this.rolesState.delete(this.role).pipe(onErrorResumeNext()).subscribe();
this.rolesState.delete(this.role);
}
public addPermission() {
@ -86,7 +84,7 @@ export class RoleComponent implements OnChanges {
if (value) {
this.editForm.add(value.permission);
this.addPermissionForm.submitCompleted({});
this.addPermissionForm.submitCompleted();
this.addPermissionInput.focus();
}
}
@ -95,7 +93,7 @@ export class RoleComponent implements OnChanges {
const value = this.editForm.submit();
if (value) {
const request = new UpdateAppRoleDto(value);
const request = { permissions: value };
this.rolesState.update(this.role, request)
.subscribe(() => {

17
src/Squidex/app/features/settings/pages/roles/roles-page.component.ts

@ -8,21 +8,20 @@
import { Component, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { Observable, of } from 'rxjs';
import { onErrorResumeNext } from 'rxjs/operators';
import {
AddRoleForm,
AppRoleDto,
AppRolesService,
AppsState,
AutocompleteSource,
RoleDto,
RolesService,
RolesState
} from '@app/shared';
class PermissionsAutocomplete implements AutocompleteSource {
private permissions: string[] = [];
constructor(appsState: AppsState, rolesService: AppRolesService) {
constructor(appsState: AppsState, rolesService: RolesService) {
rolesService.getPermissions(appsState.appName).subscribe(x => this.permissions = x);
}
@ -43,18 +42,18 @@ export class RolesPageComponent implements OnInit {
constructor(
public readonly appsState: AppsState,
public readonly rolesService: AppRolesService,
public readonly rolesService: RolesService,
public readonly rolesState: RolesState,
private readonly formBuilder: FormBuilder
) {
}
public ngOnInit() {
this.rolesState.load().pipe(onErrorResumeNext()).subscribe();
this.rolesState.load();
}
public reload() {
this.rolesState.load(true).pipe(onErrorResumeNext()).subscribe();
this.rolesState.load(true);
}
public cancelAddRole() {
@ -67,14 +66,14 @@ export class RolesPageComponent implements OnInit {
if (value) {
this.rolesState.add(value)
.subscribe(() => {
this.addRoleForm.submitCompleted({});
this.addRoleForm.submitCompleted();
}, error => {
this.addRoleForm.submitFailed(error);
});
}
}
public trackByRole(index: number, role: AppRoleDto) {
public trackByRole(index: number, role: RoleDto) {
return role.name;
}
}

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

Loading…
Cancel
Save