diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/IAssetInfo.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/IAssetInfo.cs index 9e7ff611d..9c8923bda 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/IAssetInfo.cs +++ b/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; } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs index 8850ef500..2aa7a672c 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs +++ b/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; } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index e203ac95d..3597e55a3 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/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( - Index.Ascending(x => x.Slug)) + Index + .Ascending(x => x.AppId) + .Ascending(x => x.IsDeleted) + .Ascending(x => x.FileHash)), + new CreateIndexModel( + 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 FindAssetAsync(string slug) + public async Task FindAssetBySlugAsync(Guid appId, string slug) { using (Profiler.TraceMethod()) { 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 FindAssetAsync(Guid id) + public async Task> QueryByHashAsync(Guid appId, string hash) + { + using (Profiler.TraceMethod()) + { + var assetEntities = + await Collection.Find(x => x.IndexedAppId == appId && !x.IsDeleted && x.FileHash == hash) + .ToListAsync(); + + return assetEntities.OfType().ToList(); + } + } + + public async Task FindAssetAsync(Guid id, bool allowDeleted = false) { using (Profiler.TraceMethod()) { @@ -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; } } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs index 569ad468e..a1176be44 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs +++ b/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")] diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs index c1b217806..f5366299b 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs +++ b/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 { private readonly IAssetStore assetStore; + private readonly IAssetQueryService assetQueryService; private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IEnumerable> tagGenerators; public AssetCommandMiddleware( IGrainFactory grainFactory, + IAssetQueryService assetQueryService, IAssetStore assetStore, IAssetThumbnailGenerator assetThumbnailGenerator, IEnumerable> 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(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 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; + } } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs index 8abb01c95..de8da5f23 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs +++ b/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 { - public Guid Id { get; } - public HashSet Tags { get; } - public AssetCreatedResult(Guid id, HashSet tags, long version) - : base(version) - { - Id = id; + public long FileVersion { get; } + + public string FileHash { get; } + + public bool IsDuplicate { get; } + public AssetCreatedResult(Guid id, HashSet tags, long version, long fileVersion, string fileHash, bool isDuplicate) + : base(id, version) + { Tags = tags; + + FileVersion = fileVersion; + FileHash = fileHash; + + IsDuplicate = isDuplicate; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs index ec7b92db2..b16b18ae4 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs +++ b/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> NormalizeTagsAsync(Guid appId, HashSet tags) { + if (tags == null) + { + return null; + } + var normalized = await tagService.NormalizeTagsAsync(appId, TagGroups.Assets, tags, Snapshot.Tags); return new HashSet(normalized.Values); } - public void Create(CreateAsset command) + public void Create(CreateAsset command, HashSet 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 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) diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs index 0bd7a6975..9edfe22fb 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs +++ b/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 FindAssetAsync(QueryContext context, Guid id) + public Task FindAssetAsync(QueryContext context, Guid id) { Guard.NotNull(context, nameof(context)); + return FindAssetAsync(context.App.Id, id); + } + + public async Task 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> 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> QueryAsync(QueryContext context, Q query) { Guard.NotNull(context, nameof(context)); diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs index a07331e8f..a43e109cc 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs +++ b/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; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs index 829cf0ce5..9c49e67bd 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs @@ -22,6 +22,8 @@ namespace Squidex.Domain.Apps.Entities.Assets.Commands public HashSet Tags { get; set; } + public string FileHash { get; set; } + public CreateAsset() { AssetId = Guid.NewGuid(); diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs b/src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs index 1bb193419..1c998ac7a 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs +++ b/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; } } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs b/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs index fa17c0731..f93f0f69b 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs +++ b/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> QueryByHashAsync(Guid appId, string hash); + Task> QueryAsync(QueryContext contex, Q query); Task FindAssetAsync(QueryContext context, Guid id); diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs index 939fc65ab..12de8c72a 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs @@ -15,13 +15,15 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories { public interface IAssetRepository { + Task> QueryByHashAsync(Guid appId, string hash); + Task> QueryAsync(Guid appId, Query query); Task> QueryAsync(Guid appId, HashSet ids); - Task FindAssetAsync(string slug); + Task FindAssetAsync(Guid id, bool allowDeleted = false); - Task FindAssetAsync(Guid id); + Task FindAssetBySlugAsync(Guid appId, string slug); Task RemoveAsync(Guid appId); } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs b/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs index 2ea47f7ab..142b5075f 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs +++ b/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, 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; } diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs index b5d470564..23417373c 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs +++ b/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", diff --git a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs b/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs index ca610a74c..1f2169431 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs +++ b/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, IContentEntity diff --git a/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs b/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs index 48e354328..c6e1bd49d 100644 --- a/src/Squidex.Domain.Apps.Entities/Rules/State/RuleState.cs +++ b/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")] diff --git a/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs b/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs index 922da11a3..9bde80fd1 100644 --- a/src/Squidex.Domain.Apps.Entities/Schemas/State/SchemaState.cs +++ b/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")] diff --git a/src/Squidex.Domain.Apps.Events/Assets/AssetCreated.cs b/src/Squidex.Domain.Apps.Events/Assets/AssetCreated.cs index 568c324e0..5200031cc 100644 --- a/src/Squidex.Domain.Apps.Events/Assets/AssetCreated.cs +++ b/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; } diff --git a/src/Squidex.Domain.Apps.Events/Assets/AssetUpdated.cs b/src/Squidex.Domain.Apps.Events/Assets/AssetUpdated.cs index aca1cb89b..b26c49397 100644 --- a/src/Squidex.Domain.Apps.Events/Assets/AssetUpdated.cs +++ b/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; } diff --git a/src/Squidex.Infrastructure/Assets/HasherStream.cs b/src/Squidex.Infrastructure/Assets/HasherStream.cs new file mode 100644 index 000000000..ea11e8682 --- /dev/null +++ b/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(); + } + } +} diff --git a/src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs b/src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs index c5c2cbce3..10fa7fe84 100644 --- a/src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs +++ b/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 streams = new ConcurrentDictionary(); 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)); diff --git a/src/Squidex.Infrastructure/Commands/EntityCreatedResult{T}.cs b/src/Squidex.Infrastructure/Commands/EntityCreatedResult{T}.cs index 324c8c6a4..2ab583d59 100644 --- a/src/Squidex.Infrastructure/Commands/EntityCreatedResult{T}.cs +++ b/src/Squidex.Infrastructure/Commands/EntityCreatedResult{T}.cs @@ -7,7 +7,7 @@ namespace Squidex.Infrastructure.Commands { - public sealed class EntityCreatedResult : EntitySavedResult + public class EntityCreatedResult : EntitySavedResult { public T IdOrValue { get; } diff --git a/src/Squidex.Infrastructure/RandomHash.cs b/src/Squidex.Infrastructure/RandomHash.cs index 9b395e23a..e64115342 100644 --- a/src/Squidex.Infrastructure/RandomHash.cs +++ b/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); diff --git a/src/Squidex/.vscode/settings.json b/src/Squidex/.vscode/settings.json index 515c80252..26a63fc30 100644 --- a/src/Squidex/.vscode/settings.json +++ b/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 } \ No newline at end of file diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs index c9ff22e9b..b7bd86d8e 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs +++ b/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 GetAssetContent(string id, string more, + public async Task 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); + } + + /// + /// Get the asset content. + /// + /// The name of the app. + /// The id or slug of the asset. + /// Optional suffix that can be used to seo-optimize the link to the image Has not effect. + /// The optional version of the asset. + /// The target width of the asset, if it is an image. + /// The target height of the asset, if it is an image. + /// Optional image quality, it is is an jpeg image. + /// The resize mode when the width and height is defined. + /// + /// 200 => Asset found and content or (resized) image returned. + /// 404 => Asset or app not found. + /// + [HttpGet] + [Route("assets/{app}/{idOrSlug}/{*more}")] + [ProducesResponseType(typeof(FileResult), 200)] + [ApiCosts(0.5)] + public async Task 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(); diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetCreatedDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetCreatedDto.cs index 0b1807312..da76057c0 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetCreatedDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetCreatedDto.cs @@ -76,6 +76,11 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models /// public int? PixelHeight { get; set; } + /// + /// Indicates if the asset has been already uploaded. + /// + public bool IsDuplicate { get; set; } + /// /// The version of the asset. /// @@ -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; } } } diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs index e13b76993..1053302be 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs +++ b/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; } + /// + /// The file hash. + /// + [Required] + public string FileHash { get; set; } + /// /// The slug. /// diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetReplacedDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetReplacedDto.cs index cb6fcb801..e65faf494 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetReplacedDto.cs +++ b/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; } + /// + /// The file hash. + /// + [Required] + public string FileHash { get; set; } + /// /// The size of the file in bytes. /// diff --git a/src/Squidex/Squidex.csproj b/src/Squidex/Squidex.csproj index 79983c5dc..a6db529aa 100644 --- a/src/Squidex/Squidex.csproj +++ b/src/Squidex/Squidex.csproj @@ -153,6 +153,6 @@ - $(NoWarn);CS1591;1591;1573;1572;NU1605 + $(NoWarn);CS1591;1591;1573;1572;NU1605;IDE0060 \ No newline at end of file diff --git a/src/Squidex/app-config/webpack.config.js b/src/Squidex/app-config/webpack.config.js index ddf15f032..5ff10d725 100644 --- a/src/Squidex/app-config/webpack.config.js +++ b/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/) ] diff --git a/src/Squidex/app-config/webpack.run.base.js b/src/Squidex/app-config/webpack.run.base.js index 3fd8ea67c..06012fa60 100644 --- a/src/Squidex/app-config/webpack.run.base.js +++ b/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') }; diff --git a/src/Squidex/app-config/webpack.test.coverage.js b/src/Squidex/app-config/webpack.test.coverage.js index 5e4f8c70b..1989b80d4 100644 --- a/src/Squidex/app-config/webpack.test.coverage.js +++ b/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' }, { diff --git a/src/Squidex/app/features/administration/declarations.ts b/src/Squidex/app/features/administration/declarations.ts index 09279fe2c..5c8cac3a6 100644 --- a/src/Squidex/app/features/administration/declarations.ts +++ b/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'; \ No newline at end of file +export * from './internal'; \ No newline at end of file diff --git a/src/Squidex/app/features/administration/guards/unset-user.guard.spec.ts b/src/Squidex/app/features/administration/guards/unset-user.guard.spec.ts index 15414e732..d4dad973f 100644 --- a/src/Squidex/app/features/administration/guards/unset-user.guard.spec.ts +++ b/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', () => { diff --git a/src/Squidex/app/features/administration/guards/unset-user.guard.ts b/src/Squidex/app/features/administration/guards/unset-user.guard.ts index 1a8d42be2..bec813ee2 100644 --- a/src/Squidex/app/features/administration/guards/unset-user.guard.ts +++ b/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 { diff --git a/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts b/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts index 8fb04cde5..a5c03338f 100644 --- a/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts +++ b/src/Squidex/app/features/administration/guards/user-must-exist.guard.spec.ts @@ -9,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({})); + .returns(() => of({})); let result: boolean; diff --git a/src/Squidex/app/features/administration/guards/user-must-exist.guard.ts b/src/Squidex/app/features/administration/guards/user-must-exist.guard.ts index 63fc2bba1..22b120ff3 100644 --- a/src/Squidex/app/features/administration/guards/user-must-exist.guard.ts +++ b/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 { diff --git a/src/Squidex/app/features/administration/internal.ts b/src/Squidex/app/features/administration/internal.ts new file mode 100644 index 000000000..5a182a4dc --- /dev/null +++ b/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'; \ No newline at end of file diff --git a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts b/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts index a8d339148..f4b0b97fd 100644 --- a/src/Squidex/app/features/administration/pages/event-consumers/event-consumers-page.component.ts +++ b/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) { diff --git a/src/Squidex/app/features/administration/pages/restore/restore-page.component.html b/src/Squidex/app/features/administration/pages/restore/restore-page.component.html index 6e72c95aa..3f6105e1b 100644 --- a/src/Squidex/app/features/administration/pages/restore/restore-page.component.html +++ b/src/Squidex/app/features/administration/pages/restore/restore-page.component.html @@ -6,7 +6,7 @@ -
+
diff --git a/src/Squidex/app/features/administration/pages/restore/restore-page.component.ts b/src/Squidex/app/features/administration/pages/restore/restore-page.component.ts index c0f6304ab..51cfa686e 100644 --- a/src/Squidex/app/features/administration/pages/restore/restore-page.component.ts +++ b/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(() => { diff --git a/src/Squidex/app/features/administration/pages/users/user-page.component.ts b/src/Squidex/app/features/administration/pages/users/user-page.component.ts index e009b6115..0c7e1efc2 100644 --- a/src/Squidex/app/features/administration/pages/users/user-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/user-page.component.ts @@ -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(value) .subscribe(() => { this.back(); }, error => { diff --git a/src/Squidex/app/features/administration/pages/users/users-page.component.ts b/src/Squidex/app/features/administration/pages/users/users-page.component.ts index 8d8c5c052..065b20858 100644 --- a/src/Squidex/app/features/administration/pages/users/users-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/users-page.component.ts @@ -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 }) { diff --git a/src/Squidex/app/features/administration/services/event-consumers.service.ts b/src/Squidex/app/features/administration/services/event-consumers.service.ts index 74de8309a..05b2cd0fd 100644 --- a/src/Squidex/app/features/administration/services/event-consumers.service.ts +++ b/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 { 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 { - return this.clone(value); - } } @Injectable() @@ -44,15 +40,16 @@ export class EventConsumersService { const url = this.apiUrl.buildUrl('/api/event-consumers'); return this.http.get(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.')); } diff --git a/src/Squidex/app/features/administration/services/users.service.spec.ts b/src/Squidex/app/features/administration/services/users.service.spec.ts index 2095c84ef..ee74d079f 100644 --- a/src/Squidex/app/features/administration/services/users.service.spec.ts +++ b/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(); diff --git a/src/Squidex/app/features/administration/services/users.service.ts b/src/Squidex/app/features/administration/services/users.service.ts index 32152110b..a657eee38 100644 --- a/src/Squidex/app/features/administration/services/users.service.ts +++ b/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 {} -export class UserDto extends Model { +export class UserDto extends Model { 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(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(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.')); } diff --git a/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts b/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts index 980ba1e92..4a69ffbeb 100644 --- a/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts +++ b/src/Squidex/app/features/administration/state/event-consumers.state.spec.ts @@ -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(); eventConsumersService = Mock.ofType(); - - 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(); + }); }); }); \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/event-consumers.state.ts b/src/Squidex/app/features/administration/state/event-consumers.state.ts index b333fa44a..f5c5a0d1d 100644 --- a/src/Squidex/app/features/administration/state/event-consumers.state.ts +++ b/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; + eventConsumers: EventConsumersList; // Indicates if event consumers are loaded. isLoaded?: boolean; } +type EventConsumersList = ImmutableArray; + @Injectable() export class EventConsumersState extends State { public eventConsumers = @@ -49,48 +51,45 @@ export class EventConsumersState extends State { } 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 { 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 { + public stop(eventConsumer: EventConsumerDto): Observable { 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 { + public reset(eventConsumer: EventConsumerDto): Observable { 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) { diff --git a/src/Squidex/app/features/administration/state/users.forms.ts b/src/Squidex/app/features/administration/state/users.forms.ts new file mode 100644 index 000000000..ca3effe38 --- /dev/null +++ b/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 { + 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) }; + } +} \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/users.state.spec.ts b/src/Squidex/app/features/administration/state/users.state.spec.ts index 31adfec4d..c2a912ecd 100644 --- a/src/Squidex/app/features/administration/state/users.state.spec.ts +++ b/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(); usersService = Mock.ofType(); - - 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); + }); }); }); \ No newline at end of file diff --git a/src/Squidex/app/features/administration/state/users.state.ts b/src/Squidex/app/features/administration/state/users.state.ts index bb34bc5f8..2fc07e298 100644 --- a/src/Squidex/app/features/administration/state/users.state.ts +++ b/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 { - 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; + users: UsersList; // The pagination information. usersPager: Pager; @@ -110,6 +52,9 @@ interface Snapshot { selectedUser?: SnapshotUser | null; } +export type UsersList = ImmutableArray; +export type UsersResult = { total: number, users: UsersList }; + @Injectable() export class UsersState extends State { public users = @@ -136,25 +81,26 @@ export class UsersState extends State { super({ users: ImmutableArray.empty(), usersPager: new Pager(0) }); } - public select(id: string | null): Observable { + public select(id: string | null): Observable { return this.loadUser(id).pipe( tap(selectedUser => { this.next(s => ({ ...s, selectedUser })); }), - 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 { @@ -170,14 +116,14 @@ export class UsersState extends State { 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 { return { ...s, users, usersPager, selectedUser, isLoaded: true }; }); }), - notify(this.dialogs)); + shareSubscribed(this.dialogs)); } public create(request: CreateUserDto): Observable { 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 { + public update(user: UserDto, request: UpdateUserDto): Observable { 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 { + public lock(user: UserDto): Observable { 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 { + public unlock(user: UserDto): Observable { 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 { + public search(query: string): Observable { this.next(s => ({ ...s, usersPager: new Pager(0), usersQuery: query })); return this.loadInternal(); } - public goNext(): Observable { + public goNext(): Observable { this.next(s => ({ ...s, usersPager: s.usersPager.goNext() })); return this.loadInternal(); } - public goPrev(): Observable { + public goPrev(): Observable { this.next(s => ({ ...s, usersPager: s.usersPager.goPrev() })); return this.loadInternal(); @@ -246,9 +197,13 @@ export class UsersState extends State { private replaceUser(user: UserDto) { return this.next(s => { - const users = s.users.map(u => u.user.id === user.id ? this.createUser(user, u) : 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 { 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 }; } } diff --git a/src/Squidex/app/features/apps/pages/apps-page.component.html b/src/Squidex/app/features/apps/pages/apps-page.component.html index 3acadfa07..abd6252d9 100644 --- a/src/Squidex/app/features/apps/pages/apps-page.component.html +++ b/src/Squidex/app/features/apps/pages/apps-page.component.html @@ -14,7 +14,7 @@

You are not collaborating to any app yet

-
+

{{app.name}}

diff --git a/src/Squidex/app/features/apps/pages/apps-page.component.ts b/src/Squidex/app/features/apps/pages/apps-page.component.ts index d5eba2e0a..631592433 100644 --- a/src/Squidex/app/features/apps/pages/apps-page.component.ts +++ b/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; + } } \ No newline at end of file diff --git a/src/Squidex/app/features/apps/pages/news-dialog.component.html b/src/Squidex/app/features/apps/pages/news-dialog.component.html index 3b8e5dc4f..70c0f1625 100644 --- a/src/Squidex/app/features/apps/pages/news-dialog.component.html +++ b/src/Squidex/app/features/apps/pages/news-dialog.component.html @@ -7,7 +7,7 @@

What's new?

-
+

{{feature.name}}

diff --git a/src/Squidex/app/features/apps/pages/news-dialog.component.ts b/src/Squidex/app/features/apps/pages/news-dialog.component.ts index 01d0ce8d9..1871e0594 100644 --- a/src/Squidex/app/features/apps/pages/news-dialog.component.ts +++ b/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; + } } \ No newline at end of file diff --git a/src/Squidex/app/features/assets/pages/assets-filters-page.component.ts b/src/Squidex/app/features/assets/pages/assets-filters-page.component.ts index 69c0852de..1497e5bc6 100644 --- a/src/Squidex/app/features/assets/pages/assets-filters-page.component.ts +++ b/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) { diff --git a/src/Squidex/app/features/assets/pages/assets-page.component.html b/src/Squidex/app/features/assets/pages/assets-page.component.html index 0a3f75e50..d169e27fe 100644 --- a/src/Squidex/app/features/assets/pages/assets-page.component.html +++ b/src/Squidex/app/features/assets/pages/assets-page.component.html @@ -7,7 +7,7 @@
-
+
-
+

Saved queries

- {{query.name}} diff --git a/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts b/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts index 6f7bbd691..4779955c7 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts +++ b/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) { diff --git a/src/Squidex/app/features/content/pages/contents/contents-page.component.ts b/src/Squidex/app/features/content/pages/contents/contents-page.component.ts index 76285718d..4a84c8670 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-page.component.ts +++ b/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) { diff --git a/src/Squidex/app/features/content/pages/schemas/schemas-page.component.ts b/src/Squidex/app/features/content/pages/schemas/schemas-page.component.ts index a2184efee..273951329 100644 --- a/src/Squidex/app/features/content/pages/schemas/schemas-page.component.ts +++ b/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) { diff --git a/src/Squidex/app/features/content/shared/content-item.component.html b/src/Squidex/app/features/content/shared/content-item.component.html index 0ae1e664b..985056255 100644 --- a/src/Squidex/app/features/content/shared/content-item.component.html +++ b/src/Squidex/app/features/content/shared/content-item.component.html @@ -10,7 +10,7 @@ - + diff --git a/src/Squidex/app/features/content/shared/content-item.component.ts b/src/Squidex/app/features/content/shared/content-item.component.ts index f97f9baab..6e9abe81c 100644 --- a/src/Squidex/app/features/content/shared/content-item.component.ts +++ b/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; + } } diff --git a/src/Squidex/app/features/content/shared/content-status.component.html b/src/Squidex/app/features/content/shared/content-status.component.html index b03f43a5b..5f1a17a45 100644 --- a/src/Squidex/app/features/content/shared/content-status.component.html +++ b/src/Squidex/app/features/content/shared/content-status.component.html @@ -1,11 +1,11 @@ - + - + diff --git a/src/Squidex/app/features/content/shared/content-status.component.scss b/src/Squidex/app/features/content/shared/content-status.component.scss index d91da46c4..36696d04b 100644 --- a/src/Squidex/app/features/content/shared/content-status.component.scss +++ b/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; } \ No newline at end of file diff --git a/src/Squidex/app/features/content/shared/content-status.component.ts b/src/Squidex/app/features/content/shared/content-status.component.ts index 85011a079..6a929031f 100644 --- a/src/Squidex/app/features/content/shared/content-status.component.ts +++ b/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')}`; diff --git a/src/Squidex/app/features/content/shared/contents-selector.component.ts b/src/Squidex/app/features/content/shared/contents-selector.component.ts index 50d40796b..0c3a2b254 100644 --- a/src/Squidex/app/features/content/shared/contents-selector.component.ts +++ b/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) { diff --git a/src/Squidex/app/features/content/shared/references-editor.component.html b/src/Squidex/app/features/content/shared/references-editor.component.html index 2ca51947a..312c46e7f 100644 --- a/src/Squidex/app/features/content/shared/references-editor.component.html +++ b/src/Squidex/app/features/content/shared/references-editor.component.html @@ -9,7 +9,7 @@ - + (new FormGroup({})); + public actionForm = new Form(new FormGroup({})); public actionType: string; public action: any = {}; - public triggerForm = new Form(new FormGroup({})); + public triggerForm = new Form(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(() => { diff --git a/src/Squidex/app/features/rules/pages/rules/rules-page.component.ts b/src/Squidex/app/features/rules/pages/rules/rules-page.component.ts index 3c21249db..ff75e7716 100644 --- a/src/Squidex/app/features/rules/pages/rules/rules-page.component.ts +++ b/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); } } diff --git a/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.html b/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.html index 71aeae038..4a9f283e1 100644 --- a/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.html +++ b/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.html @@ -38,7 +38,7 @@
diff --git a/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts b/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts index d9644c4fa..a3e1f140b 100644 --- a/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts +++ b/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts @@ -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; + } } \ No newline at end of file diff --git a/src/Squidex/app/features/schemas/pages/schema/field-wizard.component.ts b/src/Squidex/app/features/schemas/pages/schema/field-wizard.component.ts index d341f4af2..f89b9ebb3 100644 --- a/src/Squidex/app/features/schemas/pages/schema/field-wizard.component.ts +++ b/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(); diff --git a/src/Squidex/app/features/schemas/pages/schema/field.component.ts b/src/Squidex/app/features/schemas/pages/schema/field.component.ts index f3c843584..15bb242ee 100644 --- a/src/Squidex/app/features/schemas/pages/schema/field.component.ts +++ b/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; + public patterns: ImmutableArray; 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 => { diff --git a/src/Squidex/app/features/schemas/pages/schema/forms/field-form-validation.component.ts b/src/Squidex/app/features/schemas/pages/schema/forms/field-form-validation.component.ts index 9917a677d..d02c495a5 100644 --- a/src/Squidex/app/features/schemas/pages/schema/forms/field-form-validation.component.ts +++ b/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; + public patterns: ImmutableArray; } \ No newline at end of file diff --git a/src/Squidex/app/features/schemas/pages/schema/schema-page.component.ts b/src/Squidex/app/features/schemas/pages/schema/schema-page.component.ts index 6affed5fb..abc5b1f46 100644 --- a/src/Squidex/app/features/schemas/pages/schema/schema-page.component.ts +++ b/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 diff --git a/src/Squidex/app/features/schemas/pages/schema/schema-preview-urls-form.component.ts b/src/Squidex/app/features/schemas/pages/schema/schema-preview-urls-form.component.ts index 8522190d5..72a59074e 100644 --- a/src/Squidex/app/features/schemas/pages/schema/schema-preview-urls-form.component.ts +++ b/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(); } } diff --git a/src/Squidex/app/features/schemas/pages/schema/types/string-validation.component.ts b/src/Squidex/app/features/schemas/pages/schema/types/string-validation.component.ts index 77cd4f192..7ecd32f9b 100644 --- a/src/Squidex/app/features/schemas/pages/schema/types/string-validation.component.ts +++ b/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; + public patterns: ImmutableArray; public showDefaultValue: Observable; 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); diff --git a/src/Squidex/app/features/schemas/pages/schemas/schema-form.component.ts b/src/Squidex/app/features/schemas/pages/schemas/schema-form.component.ts index fdac8d2fd..d6f7f53bb 100644 --- a/src/Squidex/app/features/schemas/pages/schemas/schema-form.component.ts +++ b/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; } diff --git a/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.ts b/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.ts index cf0ac2093..7c8aa690f 100644 --- a/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.ts +++ b/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(); } } } diff --git a/src/Squidex/app/features/settings/pages/backups/backups-page.component.ts b/src/Squidex/app/features/settings/pages/backups/backups-page.component.ts index 281c4f9e6..9a0f1b700 100644 --- a/src/Squidex/app/features/settings/pages/backups/backups-page.component.ts +++ b/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) { diff --git a/src/Squidex/app/features/settings/pages/clients/client.component.html b/src/Squidex/app/features/settings/pages/clients/client.component.html index 9ebb54ab5..70ae8e22a 100644 --- a/src/Squidex/app/features/settings/pages/clients/client.component.html +++ b/src/Squidex/app/features/settings/pages/clients/client.component.html @@ -71,7 +71,7 @@
diff --git a/src/Squidex/app/features/settings/pages/clients/client.component.ts b/src/Squidex/app/features/settings/pages/clients/client.component.ts index fd8648645..e9d3d67d4 100644 --- a/src/Squidex/app/features/settings/pages/clients/client.component.ts +++ b/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}")`; +} \ No newline at end of file diff --git a/src/Squidex/app/features/settings/pages/clients/clients-page.component.ts b/src/Squidex/app/features/settings/pages/clients/clients-page.component.ts index 00caae62c..885085116 100644 --- a/src/Squidex/app/features/settings/pages/clients/clients-page.component.ts +++ b/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; } } \ No newline at end of file diff --git a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts index 61d77c7b8..79105759c 100644 --- a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts +++ b/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; } } diff --git a/src/Squidex/app/features/settings/pages/languages/language.component.ts b/src/Squidex/app/features/settings/pages/languages/language.component.ts index 67da727e0..53d4d228a 100644 --- a/src/Squidex/app/features/settings/pages/languages/language.component.ts +++ b/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(() => { diff --git a/src/Squidex/app/features/settings/pages/languages/languages-page.component.ts b/src/Squidex/app/features/settings/pages/languages/languages-page.component.ts index 31634d509..a41d8d505 100644 --- a/src/Squidex/app/features/settings/pages/languages/languages-page.component.ts +++ b/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() { diff --git a/src/Squidex/app/features/settings/pages/patterns/pattern.component.ts b/src/Squidex/app/features/settings/pages/patterns/pattern.component.ts index d37976ef0..2540ffc0e 100644 --- a/src/Squidex/app/features/settings/pages/patterns/pattern.component.ts +++ b/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); }); diff --git a/src/Squidex/app/features/settings/pages/patterns/patterns-page.component.ts b/src/Squidex/app/features/settings/pages/patterns/patterns-page.component.ts index 57db136ae..a99b188e0 100644 --- a/src/Squidex/app/features/settings/pages/patterns/patterns-page.component.ts +++ b/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; } } \ No newline at end of file diff --git a/src/Squidex/app/features/settings/pages/plans/plans-page.component.ts b/src/Squidex/app/features/settings/pages/plans/plans-page.component.ts index 8c1d125e5..ec4c90da8 100644 --- a/src/Squidex/app/features/settings/pages/plans/plans-page.component.ts +++ b/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 }) { diff --git a/src/Squidex/app/features/settings/pages/roles/role.component.ts b/src/Squidex/app/features/settings/pages/roles/role.component.ts index 5c892da21..385fe6cf2 100644 --- a/src/Squidex/app/features/settings/pages/roles/role.component.ts +++ b/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(() => { diff --git a/src/Squidex/app/features/settings/pages/roles/roles-page.component.ts b/src/Squidex/app/features/settings/pages/roles/roles-page.component.ts index 0722c02ba..f70cb83f6 100644 --- a/src/Squidex/app/features/settings/pages/roles/roles-page.component.ts +++ b/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; } } diff --git a/src/Squidex/app/framework/angular/forms/error-formatting.ts b/src/Squidex/app/framework/angular/forms/error-formatting.ts index 1b479eb8e..baf492d51 100644 --- a/src/Squidex/app/framework/angular/forms/error-formatting.ts +++ b/src/Squidex/app/framework/angular/forms/error-formatting.ts @@ -5,7 +5,7 @@ * Copyright (c) Sebastian Stehle. All rights r vbeserved */ -import { Types } from './../../utils/types'; +import { Types } from '@app/framework/internal'; const DEFAULT_ERRORS: { [key: string]: string } = { between: '{field} must be between \'{min}\' and \'{max}\'.', diff --git a/src/Squidex/app/framework/angular/forms/file-drop.directive.ts b/src/Squidex/app/framework/angular/forms/file-drop.directive.ts index 7b572d106..027413c90 100644 --- a/src/Squidex/app/framework/angular/forms/file-drop.directive.ts +++ b/src/Squidex/app/framework/angular/forms/file-drop.directive.ts @@ -9,7 +9,7 @@ import { Directive, ElementRef, EventEmitter, HostListener, Input, Output, Renderer2 } from '@angular/core'; -import { Types } from './../../utils/types'; +import { Types } from '@app/framework/internal'; const ImageTypes = [ 'image/jpeg', diff --git a/src/Squidex/app/framework/angular/forms/forms-helper.ts b/src/Squidex/app/framework/angular/forms/forms-helper.ts index 8597769a2..0f4b23d27 100644 --- a/src/Squidex/app/framework/angular/forms/forms-helper.ts +++ b/src/Squidex/app/framework/angular/forms/forms-helper.ts @@ -9,7 +9,7 @@ import { Observable } from 'rxjs'; import { map, startWith } from 'rxjs/operators'; -import { Types } from '@app/framework/internal'; +import { Types } from './../../utils/types'; export function formControls(form: AbstractControl): AbstractControl[] { if (Types.is(form, FormGroup)) { @@ -22,7 +22,7 @@ export function formControls(form: AbstractControl): AbstractControl[] { } export function invalid$(form: AbstractControl): Observable { - return form.statusChanges.pipe(map(_ => form.invalid), startWith(form.invalid)); + return form.statusChanges.pipe(map(() => form.invalid), startWith(form.invalid)); } export function value$(form: AbstractControl): Observable { diff --git a/src/Squidex/app/framework/angular/forms/progress-bar.component.ts b/src/Squidex/app/framework/angular/forms/progress-bar.component.ts index 4a4d99649..9bf8d0aea 100644 --- a/src/Squidex/app/framework/angular/forms/progress-bar.component.ts +++ b/src/Squidex/app/framework/angular/forms/progress-bar.component.ts @@ -35,6 +35,9 @@ export class ProgressBarComponent implements OnChanges, OnInit { @Input() public showText = true; + @Input() + public animated = true; + @Input() public value = 0; @@ -74,7 +77,11 @@ export class ProgressBarComponent implements OnChanges, OnInit { private updateValue() { const value = this.value; - this.progressBar.animate(value / 100); + if (this.animated) { + this.progressBar.animate(value / 100); + } else { + this.progressBar.set(value / 100); + } if (value > 0 && this.showText) { this.progressBar.setText(Math.round(value) + '%'); diff --git a/src/Squidex/app/framework/angular/forms/tag-editor.component.ts b/src/Squidex/app/framework/angular/forms/tag-editor.component.ts index 4e049dc45..df703254b 100644 --- a/src/Squidex/app/framework/angular/forms/tag-editor.component.ts +++ b/src/Squidex/app/framework/angular/forms/tag-editor.component.ts @@ -5,6 +5,8 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ +// tslint:disable:template-use-track-by-function + import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, forwardRef, Input, OnInit, ViewChild } from '@angular/core'; import { FormControl, NG_VALUE_ACCESSOR } from '@angular/forms'; import { distinctUntilChanged, map, tap } from 'rxjs/operators'; @@ -362,32 +364,36 @@ export class TagEditorComponent extends StatefulControlComponent i public onCopy(event: ClipboardEvent) { if (!this.hasSelection()) { - event.clipboardData.setData('text/plain', this.snapshot.items.filter(x => !!x).join(',')); + if (event.clipboardData) { + event.clipboardData.setData('text/plain', this.snapshot.items.filter(x => !!x).join(',')); + } event.preventDefault(); } } public onPaste(event: ClipboardEvent) { - const value = event.clipboardData.getData('text/plain'); + if (event.clipboardData) { + const value = event.clipboardData.getData('text/plain'); - if (value) { - this.resetForm(); + if (value) { + this.resetForm(); - const values = [...this.snapshot.items]; + const values = [...this.snapshot.items]; - for (let part of value.split(',')) { - const converted = this.converter.convert(part); + for (let part of value.split(',')) { + const converted = this.converter.convert(part); - if (converted) { - values.push(converted); + if (converted) { + values.push(converted); + } } + + this.updateItems(values); } - this.updateItems(values); + event.preventDefault(); } - - event.preventDefault(); } private hasSelection() { diff --git a/src/Squidex/app/framework/angular/forms/validators.spec.ts b/src/Squidex/app/framework/angular/forms/validators.spec.ts index 9cd351171..bb65bcd78 100644 --- a/src/Squidex/app/framework/angular/forms/validators.spec.ts +++ b/src/Squidex/app/framework/angular/forms/validators.spec.ts @@ -7,7 +7,7 @@ import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { DateTime } from './../../utils/date-time'; +import { DateTime } from '@app/framework/internal'; import { ValidatorsEx } from './validators'; diff --git a/src/Squidex/app/framework/angular/http/caching.interceptor.ts b/src/Squidex/app/framework/angular/http/caching.interceptor.ts index 111bc2aad..3058f13cd 100644 --- a/src/Squidex/app/framework/angular/http/caching.interceptor.ts +++ b/src/Squidex/app/framework/angular/http/caching.interceptor.ts @@ -10,7 +10,7 @@ import { Injectable} from '@angular/core'; import { Observable, of, throwError } from 'rxjs'; import { catchError, tap } from 'rxjs/operators'; -import { Types } from '@app/shared/internal'; +import { Types } from '@app/framework/internal'; @Injectable() export class CachingInterceptor implements HttpInterceptor { diff --git a/src/Squidex/app/framework/angular/http/http-extensions.ts b/src/Squidex/app/framework/angular/http/http-extensions.ts index 543213b9a..0745c8227 100644 --- a/src/Squidex/app/framework/angular/http/http-extensions.ts +++ b/src/Squidex/app/framework/angular/http/http-extensions.ts @@ -9,9 +9,12 @@ import { HttpClient, HttpErrorResponse, HttpHeaders, HttpResponse } from '@angul import { Observable, throwError } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; -import { ErrorDto } from './../../utils/error'; -import { Types} from './../../utils/types'; -import { Version, Versioned } from './../../utils/version'; +import { + ErrorDto, + Types, + Version, + Versioned +} from '@app/framework/internal'; export module HTTP { export function getVersioned(http: HttpClient, url: string, version?: Version): Observable>> { @@ -56,7 +59,7 @@ export module HTTP { return httpRequest.pipe(map((response: HttpResponse) => { const etag = response.headers.get('etag') || ''; - return new Versioned(new Version(etag), response); + return { version: new Version(etag), payload: response }; })); } } diff --git a/src/Squidex/app/framework/angular/modals/dialog-renderer.component.ts b/src/Squidex/app/framework/angular/modals/dialog-renderer.component.ts index 873f4ced7..a1ff94145 100644 --- a/src/Squidex/app/framework/angular/modals/dialog-renderer.component.ts +++ b/src/Squidex/app/framework/angular/modals/dialog-renderer.component.ts @@ -14,9 +14,9 @@ import { DialogService, fadeAnimation, Notification, - StatefulComponent + StatefulComponent, + Tooltip } from '@app/framework/internal'; -import { Tooltip } from '@app/shared'; interface State { dialogRequest?: DialogRequest | null; diff --git a/src/Squidex/app/framework/angular/modals/modal-target.directive.ts b/src/Squidex/app/framework/angular/modals/modal-target.directive.ts index 65692dd30..2b1431a8d 100644 --- a/src/Squidex/app/framework/angular/modals/modal-target.directive.ts +++ b/src/Squidex/app/framework/angular/modals/modal-target.directive.ts @@ -8,8 +8,7 @@ import { AfterViewInit, Directive, ElementRef, Input, OnDestroy, Renderer2 } from '@angular/core'; import { timer } from 'rxjs'; -import { ResourceOwner } from '@app/framework/internal'; -import { positionModal } from '@app/shared'; +import { positionModal, ResourceOwner } from '@app/framework/internal'; @Directive({ selector: '[sqxModalTarget]' diff --git a/src/Squidex/app/framework/angular/modals/tooltip.directive.ts b/src/Squidex/app/framework/angular/modals/tooltip.directive.ts index c6c5afc73..6e835cee2 100644 --- a/src/Squidex/app/framework/angular/modals/tooltip.directive.ts +++ b/src/Squidex/app/framework/angular/modals/tooltip.directive.ts @@ -9,8 +9,7 @@ import { Directive, ElementRef, HostListener, Input, Renderer2 } from '@angular/core'; -import { DialogService } from '@app/framework/internal'; -import { Tooltip } from '@app/shared'; +import { DialogService, Tooltip } from '@app/framework/internal'; @Directive({ selector: '[title]' diff --git a/src/Squidex/app/framework/angular/pipes/date-time.pipes.spec.ts b/src/Squidex/app/framework/angular/pipes/date-time.pipes.spec.ts index 00da0249c..d5dc34f54 100644 --- a/src/Squidex/app/framework/angular/pipes/date-time.pipes.spec.ts +++ b/src/Squidex/app/framework/angular/pipes/date-time.pipes.spec.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { DateTime, Duration } from './../../'; +import { DateTime, Duration } from '@app/framework/internal'; import { DatePipe, diff --git a/src/Squidex/app/framework/angular/pipes/money.pipe.spec.ts b/src/Squidex/app/framework/angular/pipes/money.pipe.spec.ts index 80f1b6cc8..5b760fd49 100644 --- a/src/Squidex/app/framework/angular/pipes/money.pipe.spec.ts +++ b/src/Squidex/app/framework/angular/pipes/money.pipe.spec.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { CurrencyConfig, DecimalSeparatorConfig } from './../../'; +import { CurrencyConfig, DecimalSeparatorConfig } from '@app/framework/internal'; import { MoneyPipe } from './money.pipe'; diff --git a/src/Squidex/app/framework/angular/shortcut.component.spec.ts b/src/Squidex/app/framework/angular/shortcut.component.spec.ts index 50009a2cb..a5a28fe32 100644 --- a/src/Squidex/app/framework/angular/shortcut.component.spec.ts +++ b/src/Squidex/app/framework/angular/shortcut.component.spec.ts @@ -7,7 +7,7 @@ import { NgZone } from '@angular/core'; -import { ShortcutService } from './../'; +import { ShortcutService } from '@app/framework/internal'; import { ShortcutComponent } from './shortcut.component'; describe('ShortcutComponent', () => { diff --git a/src/Squidex/app/framework/angular/stateful.component.ts b/src/Squidex/app/framework/angular/stateful.component.ts index b3ba9f3c7..ec261fcfa 100644 --- a/src/Squidex/app/framework/angular/stateful.component.ts +++ b/src/Squidex/app/framework/angular/stateful.component.ts @@ -12,7 +12,7 @@ import { onErrorResumeNext, skip } from 'rxjs/operators'; import { Types } from './../utils/types'; -import { State } from '../state'; +import { State } from './../state'; declare type UnsubscribeFunction = () => void; diff --git a/src/Squidex/app/framework/services/analytics.service.ts b/src/Squidex/app/framework/services/analytics.service.ts index 4b94c2279..9bf3183c1 100644 --- a/src/Squidex/app/framework/services/analytics.service.ts +++ b/src/Squidex/app/framework/services/analytics.service.ts @@ -9,7 +9,7 @@ import { Injectable } from '@angular/core'; import { NavigationEnd, Router } from '@angular/router'; import { filter } from 'rxjs/operators'; -import { AnalyticsIdConfig } from '../configurations'; +import { AnalyticsIdConfig } from './../configurations'; import { Types } from './../utils/types'; import { ResourceLoaderService } from './resource-loader.service'; diff --git a/src/Squidex/app/framework/state.ts b/src/Squidex/app/framework/state.ts index f93c0b7c3..9d15cbb32 100644 --- a/src/Squidex/app/framework/state.ts +++ b/src/Squidex/app/framework/state.ts @@ -10,6 +10,7 @@ import { BehaviorSubject, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { ErrorDto } from './utils/error'; + import { Types } from './utils/types'; import { fullValue} from './angular/forms/forms-helper'; @@ -20,7 +21,7 @@ export interface FormState { error?: string | null; } -export class Form { +export class Form { private readonly state = new State({ submitted: false }); public submitted = @@ -42,25 +43,33 @@ export class Form { this.form.enable(); } - protected reset(value: any) { - this.form.reset(value); + protected setValue(value?: V) { + if (value) { + this.form.reset(this.transformLoad(value)); + } else { + this.form.reset(); + } + } + + protected transformLoad(value: V): any { + return value; } - protected setValue(value: any) { - this.form.reset(value, { emitEvent: true }); + protected transformSubmit(value: any): V { + return value; } - public load(value: any) { - this.state.next(_ => ({ submitted: false, error: null })); + public load(value: V | undefined) { + this.state.next(() => ({ submitted: false, error: null })); this.setValue(value); } - public submit(): any | null { - this.state.next(_ => ({ submitted: true })); + public submit(): V | null { + this.state.next(() => ({ submitted: true })); if (this.form.valid) { - const value = fullValue(this.form); + const value = this.transformSubmit(fullValue(this.form)); this.disable(); @@ -70,20 +79,15 @@ export class Form { } } - public submitCompleted(newValue?: any) { - this.state.next(_ => ({ submitted: false, error: null })); + public submitCompleted(newValue?: V) { + this.state.next(() => ({ submitted: false, error: null })); this.enable(); - - if (newValue) { - this.reset(newValue); - } else { - this.form.markAsPristine(); - } + this.setValue(newValue); } public submitFailed(error?: string | ErrorDto) { - this.state.next(_ => ({ submitted: false, error: this.getError(error) })); + this.state.next(() => ({ submitted: false, error: this.getError(error) })); this.enable(); } @@ -97,9 +101,17 @@ export class Form { } } -export class Model { - protected clone(update: ((v: any) => object) | object, validOnly = false): any { - let values: object; +export function createModel(c: { new(): T; }, values: Partial): T { + return Object.assign(new c(), values); +} + +export class Model { + public with(value: Partial, validOnly = false): T { + return this.clone(value, validOnly); + } + + protected clone(update: ((v: any) => V) | Partial, validOnly = false): V { + let values: Partial; if (Types.isFunction(update)) { values = update(this); } else { @@ -126,6 +138,15 @@ export class Model { } } +export class ResultSet extends Model> { + constructor( + public readonly total: number, + public readonly items: T[] + ) { + super(); + } +} + export class State { private readonly state: BehaviorSubject>; private readonly initialState: Readonly; diff --git a/src/Squidex/app/framework/utils/immutable-array.ts b/src/Squidex/app/framework/utils/immutable-array.ts index 524d9f308..0779e947d 100644 --- a/src/Squidex/app/framework/utils/immutable-array.ts +++ b/src/Squidex/app/framework/utils/immutable-array.ts @@ -230,4 +230,8 @@ export class ImmutableArray implements Iterable { public replaceBy(field: string, newValue: T, replacer?: (o: T, n: T) => T) { return this.replaceAll(x => x[field] === newValue[field], o => replacer ? replacer(o, newValue) : newValue); } + + public removeBy(field: string, value: T) { + return this.removeAll(x => x[field] === value[field]); + } } \ No newline at end of file diff --git a/src/Squidex/app/framework/utils/modal-view.ts b/src/Squidex/app/framework/utils/modal-view.ts index a77cda5f6..28c00bc64 100644 --- a/src/Squidex/app/framework/utils/modal-view.ts +++ b/src/Squidex/app/framework/utils/modal-view.ts @@ -12,12 +12,16 @@ export interface Openable { } export class DialogModel implements Openable { - private readonly isOpen$ = new BehaviorSubject(false); + private readonly isOpen$: BehaviorSubject; public get isOpen(): Observable { return this.isOpen$; } + constructor(isOpen = false) { + this.isOpen$ = new BehaviorSubject(isOpen); + } + public show(): DialogModel { this.isOpen$.next(true); @@ -38,12 +42,16 @@ export class DialogModel implements Openable { } export class ModalModel implements Openable { - private readonly isOpen$ = new BehaviorSubject(false); + private readonly isOpen$: BehaviorSubject; public get isOpen(): Observable { return this.isOpen$; } + constructor(isOpen = false) { + this.isOpen$ = new BehaviorSubject(isOpen); + } + public show(): ModalModel { if (!this.isOpen$.value) { if (openModal && openModal !== this) { diff --git a/src/Squidex/app/framework/utils/rxjs-extensions.ts b/src/Squidex/app/framework/utils/rxjs-extensions.ts index 37211ea59..e52173a9d 100644 --- a/src/Squidex/app/framework/utils/rxjs-extensions.ts +++ b/src/Squidex/app/framework/utils/rxjs-extensions.ts @@ -5,16 +5,61 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Observable, throwError } from 'rxjs'; -import { catchError } from 'rxjs/operators'; +// tslint:disable: only-arrow-functions + +import { empty, Observable } from 'rxjs'; +import { catchError, map, onErrorResumeNext, publishReplay, refCount, switchMap } from 'rxjs/operators'; import { DialogService } from './../services/dialog.service'; -/* tslint:disable:no-shadowed-variable */ +import { + Version, + versioned, + Versioned +} from './version'; + +export function mapVersioned(project: (value: T, version: Version) => R) { + return function mapOperation(source: Observable>) { + return source.pipe(map, Versioned>(({ version, payload }) => { + return versioned(version, project(payload, version)); + })); + }; +} + +type Options = { silent?: boolean }; + +export function shareSubscribed(dialogs: DialogService, options?: Options) { + return shareMapSubscribed(dialogs, x => x, options); +} + +export function shareMapSubscribed(dialogs: DialogService, project: (value: T) => R, options?: Options) { + return function mapOperation(source: Observable) { + const shared = source.pipe(publishReplay(), refCount()); + + shared.pipe( + catchError(error => { + if (!options || !options.silent) { + dialogs.notifyError(error); + } + + return empty(); + })) + .subscribe(); + + return shared.pipe(map(x => project(x))); + }; +} -export const notify = (dialogs: DialogService) => (source: Observable) => - source.pipe(catchError(error => { - dialogs.notifyError(error); +export function switchSafe(project: (source: T) => Observable) { + return function mapOperation(source: Observable) { + return source.pipe(switchMap(project), onErrorResumeNext()); + }; +} - return throwError(error); - })); \ No newline at end of file +export function ofForever(...values: T[]) { + return new Observable(s => { + for (let value of values) { + s.next(value); + } + }); +} \ No newline at end of file diff --git a/src/Squidex/app/framework/utils/version.spec.ts b/src/Squidex/app/framework/utils/version.spec.ts index 20603d4d3..1e69d63b8 100644 --- a/src/Squidex/app/framework/utils/version.spec.ts +++ b/src/Squidex/app/framework/utils/version.spec.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Version, Versioned } from './version'; +import { Version } from './version'; describe('Version', () => { it('should initialize with init value', () => { @@ -20,13 +20,4 @@ describe('Version', () => { expect(new Version('W/2').eq(new Version('2'))).toBeTruthy(); expect(new Version('W/2').eq(new Version('W/2'))).toBeTruthy(); }); -}); - -describe('Versioned', () => { - it('should initialize with version and payload', () => { - const versioned = new Versioned(new Version('1.0'), 123); - - expect(versioned.version.value).toBe('1.0'); - expect(versioned.payload).toBe(123); - }); }); \ No newline at end of file diff --git a/src/Squidex/app/framework/utils/version.ts b/src/Squidex/app/framework/utils/version.ts index e7bf99dd6..1f4888e28 100644 --- a/src/Squidex/app/framework/utils/version.ts +++ b/src/Squidex/app/framework/utils/version.ts @@ -24,10 +24,8 @@ export class Version { } } -export class Versioned { - constructor( - public readonly version: Version, - public readonly payload: T - ) { - } -} \ No newline at end of file +export function versioned(version: Version, payload: T = undefined!): Versioned { + return { version, payload }; +} + +export type Versioned = { readonly version: Version, readonly payload: T }; \ No newline at end of file diff --git a/src/Squidex/app/shared/components/app-form.component.ts b/src/Squidex/app/shared/components/app-form.component.ts index 1b812a6c6..561db49cc 100644 --- a/src/Squidex/app/shared/components/app-form.component.ts +++ b/src/Squidex/app/shared/components/app-form.component.ts @@ -11,7 +11,6 @@ import { FormBuilder } from '@angular/forms'; import { ApiUrlConfig, AppsState, - CreateAppDto, CreateAppForm } from '@app/shared/internal'; @@ -45,7 +44,7 @@ export class AppFormComponent { const value = this.createForm.submit(); if (value) { - const request = new CreateAppDto(value.name, this.template); + const request = { ...value, template: this.template }; this.appsStore.create(request) .subscribe(() => { diff --git a/src/Squidex/app/shared/components/asset-uploader.component.html b/src/Squidex/app/shared/components/asset-uploader.component.html new file mode 100644 index 000000000..c7cf82801 --- /dev/null +++ b/src/Squidex/app/shared/components/asset-uploader.component.html @@ -0,0 +1,44 @@ + + + \ No newline at end of file diff --git a/src/Squidex/app/shared/components/asset-uploader.component.scss b/src/Squidex/app/shared/components/asset-uploader.component.scss new file mode 100644 index 000000000..8a794dfef --- /dev/null +++ b/src/Squidex/app/shared/components/asset-uploader.component.scss @@ -0,0 +1,100 @@ +@import '_vars'; +@import '_mixins'; + +.nav { + & { + padding-right: 2rem; + } + .nav-item { + & { + line-height: 2rem; + } + + .nav-link { + color: $color-dark-foreground; + padding-top: 0; + padding-bottom: 0; + } + } +} + +.icon-upload-3 { + vertical-align: middle; + font-size: 1.4rem; + font-weight: lighter; + padding-right: .5rem; +} + +.dropdown-menu { + @include absolute(2.6rem, 0, auto, auto); + display: block; + min-width: 30rem; + max-width: 60%; + min-height: 4rem; + padding: 1rem; +} + +.uploads { + & { + border: 2px solid transparent; + background: none; + min-height: 2rem; + } + + &-empty { + line-height: 1.8rem; + } +} + +.upload { + & { + line-height: 2rem; + min-height: 2rem; + max-height: 2rem; + margin-bottom: .5rem; + } + + &:last-child { + margin: 0; + } + + &-name { + @include truncate; + padding-right: .5rem; + padding-left: .5rem; + } +} + +$circle-size: 1.6rem; + +.upload-status { + & { + @include circle($circle-size); + display: inline-block; + line-height: $circle-size + .1rem; + text-align: center; + font-size: .4 * $circle-size; + font-weight: normal; + background: $color-border; + color: $color-dark-foreground; + cursor: none; + } + + &-running { + color: inherit; + } + + &-failed { + background: $color-theme-error; + } + + &-success { + background: $color-theme-green; + } +} + +.drag { + & > .uploads { + border-color: $color-theme-blue; + } +} \ No newline at end of file diff --git a/src/Squidex/app/shared/components/asset-uploader.component.ts b/src/Squidex/app/shared/components/asset-uploader.component.ts new file mode 100644 index 000000000..7160ba9e0 --- /dev/null +++ b/src/Squidex/app/shared/components/asset-uploader.component.ts @@ -0,0 +1,53 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { + AppsState, + AssetsState, + AssetUploaderState, + DialogModel, + fadeAnimation, + Upload +} from '@app/shared/internal'; + +@Component({ + selector: 'sqx-asset-uploader', + styleUrls: ['./asset-uploader.component.scss'], + templateUrl: './asset-uploader.component.html', + animations: [ + fadeAnimation + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class AssetUploaderComponent { + public modalMenu = new DialogModel(); + + constructor( + public readonly appsState: AppsState, + public readonly assetUploader: AssetUploaderState, + public readonly assetsState: AssetsState + ) { + } + + public addFiles(files: File[]) { + for (let file of files) { + this.assetUploader.uploadFile(file, this.assetsState); + } + + this.modalMenu.show(); + } + + public stopUpload(upload: Upload) { + this.assetUploader.stopUpload(upload); + } + + public trackByUpload(index: number, upload: Upload) { + return upload.id; + } +} \ No newline at end of file diff --git a/src/Squidex/app/shared/components/asset.component.html b/src/Squidex/app/shared/components/asset.component.html index bb466fb5c..8aa751b69 100644 --- a/src/Squidex/app/shared/components/asset.component.html +++ b/src/Squidex/app/shared/components/asset.component.html @@ -1,4 +1,4 @@ - +
diff --git a/src/Squidex/app/shared/components/asset.component.ts b/src/Squidex/app/shared/components/asset.component.ts index 6550d3bda..b330cc647 100644 --- a/src/Squidex/app/shared/components/asset.component.ts +++ b/src/Squidex/app/shared/components/asset.component.ts @@ -8,18 +8,14 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, HostBinding, Input, OnInit, Output } from '@angular/core'; import { - AppsState, AssetDto, - AssetsService, - AuthService, - DateTime, DialogModel, DialogService, fadeAnimation, StatefulComponent, - Types, - Versioned + Types } from '@app/shared/internal'; +import { AssetUploaderState, UploadCanceled } from './../state/asset-uploader.state'; interface State { progress: number; @@ -83,9 +79,7 @@ export class AssetComponent extends StatefulComponent implements OnInit { public editDialog = new DialogModel(); constructor(changeDetector: ChangeDetectorRef, - private readonly appsState: AppsState, - private readonly assetsService: AssetsService, - private readonly authState: AuthService, + private readonly assetUploader: AssetUploaderState, private readonly dialogs: DialogService ) { super(changeDetector, { @@ -99,15 +93,17 @@ export class AssetComponent extends StatefulComponent implements OnInit { if (initFile) { this.setProgress(1); - this.assetsService.uploadFile(this.appsState.appName, initFile, this.authState.user!.token, DateTime.now()) + this.assetUploader.uploadFile(initFile) .subscribe(dto => { - if (Types.is(dto, AssetDto)) { - this.emitLoad(dto); - } else { + if (Types.isNumber(dto)) { this.setProgress(dto); + } else { + this.emitLoad(dto); } }, error => { - this.dialogs.notifyError(error); + if (!Types.is(error, UploadCanceled)) { + this.dialogs.notifyError(error); + } this.emitLoadError(error); }); @@ -118,16 +114,18 @@ export class AssetComponent extends StatefulComponent implements OnInit { if (files.length === 1) { this.setProgress(1); - this.assetsService.replaceFile(this.appsState.appName, this.asset.id, files[0], this.asset.version) + this.assetUploader.uploadAsset(this.asset, files[0]) .subscribe(dto => { - if (Types.is(dto, Versioned)) { - this.updateAsset(this.asset.update(dto.payload, this.authState.user!.token, dto.version), true); - } else { + if (Types.isNumber(dto)) { this.setProgress(dto); + } else { + this.updateAsset(dto, true); } }, error => { this.dialogs.notifyError(error); + this.setProgress(0); + }, () => { this.setProgress(0); }); } diff --git a/src/Squidex/app/shared/components/assets-list.component.ts b/src/Squidex/app/shared/components/assets-list.component.ts index dd2339e48..4838ef066 100644 --- a/src/Squidex/app/shared/components/assets-list.component.ts +++ b/src/Squidex/app/shared/components/assets-list.component.ts @@ -88,4 +88,3 @@ export class AssetsListComponent { return asset.id; } } - diff --git a/src/Squidex/app/shared/components/assets-selector.component.ts b/src/Squidex/app/shared/components/assets-selector.component.ts index ec75bf464..4956ffd64 100644 --- a/src/Squidex/app/shared/components/assets-selector.component.ts +++ b/src/Squidex/app/shared/components/assets-selector.component.ts @@ -6,11 +6,10 @@ */ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, OnInit, Output } from '@angular/core'; -import { onErrorResumeNext } from 'rxjs/operators'; import { AssetDto, - AssetsDialogState, + AssetsState, fadeAnimation, FilterState, LocalStoreService, @@ -40,7 +39,7 @@ export class AssetsSelectorComponent extends StatefulComponent implements public filter = new FilterState(); constructor(changeDector: ChangeDetectorRef, - public readonly assetsState: AssetsDialogState, + public readonly assetsState: AssetsState, public readonly localStore: LocalStoreService ) { super(changeDector, { @@ -51,15 +50,15 @@ export class AssetsSelectorComponent extends StatefulComponent implements } public ngOnInit() { - this.assetsState.load().pipe(onErrorResumeNext()).subscribe(); + this.assetsState.load(); } 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 emitComplete() { @@ -71,7 +70,7 @@ export class AssetsSelectorComponent extends StatefulComponent implements } public selectTags(tags: string[]) { - this.assetsState.selectTags(tags).pipe(onErrorResumeNext()).subscribe(); + this.assetsState.selectTags(tags); } public selectAsset(asset: AssetDto) { diff --git a/src/Squidex/app/shared/components/comments.component.ts b/src/Squidex/app/shared/components/comments.component.ts index 5cbea6db8..7f02e8476 100644 --- a/src/Squidex/app/shared/components/comments.component.ts +++ b/src/Squidex/app/shared/components/comments.component.ts @@ -56,20 +56,20 @@ export class CommentsComponent extends ResourceOwner implements OnInit { } public delete(comment: CommentDto) { - this.state.delete(comment.id).pipe(onErrorResumeNext()).subscribe(); + this.state.delete(comment); } public update(comment: CommentDto, text: string) { - this.state.update(comment.id, text).pipe(onErrorResumeNext()).subscribe(); + this.state.update(comment, text); } public comment() { const value = this.commentForm.submit(); if (value) { - this.state.create(value.text).pipe(onErrorResumeNext()).subscribe(); + this.state.create(value.text); - this.commentForm.submitCompleted({}); + this.commentForm.submitCompleted(); } } diff --git a/src/Squidex/app/shared/components/history.component.ts b/src/Squidex/app/shared/components/history.component.ts index bb1eb37bf..435196a59 100644 --- a/src/Squidex/app/shared/components/history.component.ts +++ b/src/Squidex/app/shared/components/history.component.ts @@ -8,7 +8,7 @@ import { ChangeDetectionStrategy, 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, @@ -16,7 +16,8 @@ import { HistoryChannelUpdated, HistoryEventDto, HistoryService, - MessageBus + MessageBus, + switchSafe } from '@app/shared/internal'; @Component({ @@ -33,7 +34,7 @@ export class HistoryComponent { 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, diff --git a/src/Squidex/app/shared/components/language-selector.component.html b/src/Squidex/app/shared/components/language-selector.component.html index 995a4302f..d56354bce 100644 --- a/src/Squidex/app/shared/components/language-selector.component.html +++ b/src/Squidex/app/shared/components/language-selector.component.html @@ -1,5 +1,5 @@
-
@@ -9,7 +9,7 @@ {{selectedLanguage.iso2Code}}