diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs index 67a3dbd54..b8588cf6e 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs @@ -21,18 +21,19 @@ namespace Squidex.Domain.Apps.Core.Comments public string Text { get; } - public Comment(Guid id, Instant time, RefToken user, string text) + public Uri? Url { get; } + + public Comment(Guid id, Instant time, RefToken user, string text, Uri? url = null) { Guard.NotEmpty(id); Guard.NotNull(user); Guard.NotNull(text); Id = id; - - Time = time; Text = text; - + Time = time; User = user; + Url = url; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs index c40935da8..a9fcdcc13 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs @@ -7,19 +7,15 @@ using System; using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Comments.Commands { - public abstract class CommentsCommand : SquidexCommand, IAggregateCommand, IAppCommand + public abstract class CommentsCommand : SquidexCommand, IAppCommand { - public Guid CommentsId { get; set; } + public string CommentsId { get; set; } - public NamedId AppId { get; set; } + public Guid CommentId { get; set; } - Guid IAggregateCommand.AggregateId - { - get { return CommentsId; } - } + public NamedId AppId { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CreateComment.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CreateComment.cs index f47e41bea..b453c5a92 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CreateComment.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CreateComment.cs @@ -11,8 +11,17 @@ namespace Squidex.Domain.Apps.Entities.Comments.Commands { public sealed class CreateComment : CommentsCommand { - public Guid CommentId { get; } = Guid.NewGuid(); + public bool IsMention { get; set; } public string Text { get; set; } + + public string[]? Mentions { get; set; } + + public Uri? Url { get; set; } + + public CreateComment() + { + CommentId = Guid.NewGuid(); + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs index c7824fef0..756f41c0c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs @@ -5,14 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; - namespace Squidex.Domain.Apps.Entities.Comments.Commands { public sealed class DeleteComment : CommentsCommand { - public Guid CommentId { get; set; } - - public string Text { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/UpdateComment.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/UpdateComment.cs index ca34b5995..364c5d5bc 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/UpdateComment.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/UpdateComment.cs @@ -5,14 +5,10 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; - namespace Squidex.Domain.Apps.Entities.Comments.Commands { public sealed class UpdateComment : CommentsCommand { - public Guid CommentId { get; set; } - public string Text { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsCommandMiddleware.cs new file mode 100644 index 000000000..563652a42 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsCommandMiddleware.cs @@ -0,0 +1,113 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Orleans; +using Squidex.Domain.Apps.Entities.Comments.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.Reflection; +using Squidex.Infrastructure.Tasks; +using Squidex.Shared.Users; + +namespace Squidex.Domain.Apps.Entities.Comments +{ + public sealed class CommentsCommandMiddleware : ICommandMiddleware + { + private static readonly Regex MentionRegex = new Regex(@"@(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+\/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*", RegexOptions.Compiled, TimeSpan.FromMilliseconds(100)); + private readonly IGrainFactory grainFactory; + private readonly IUserResolver userResolver; + + public CommentsCommandMiddleware(IGrainFactory grainFactory, IUserResolver userResolver) + { + Guard.NotNull(grainFactory); + Guard.NotNull(userResolver); + + this.grainFactory = grainFactory; + + this.userResolver = userResolver; + } + + public async Task HandleAsync(CommandContext context, Func next) + { + if (context.Command is CommentsCommand commentsCommand) + { + if (commentsCommand is CreateComment createComment && !IsMention(createComment)) + { + await MentionUsersAsync(createComment); + + if (createComment.Mentions != null) + { + foreach (var userId in createComment.Mentions) + { + var notificationCommand = SimpleMapper.Map(createComment, new CreateComment()); + + notificationCommand.AppId = null!; + notificationCommand.Mentions = null; + notificationCommand.CommentsId = userId; + notificationCommand.ExpectedVersion = EtagVersion.Any; + notificationCommand.IsMention = true; + + context.CommandBus.PublishAsync(notificationCommand).Forget(); + } + } + } + + await ExecuteCommandAsync(context, commentsCommand); + } + + await next(); + } + + private async Task ExecuteCommandAsync(CommandContext context, CommentsCommand commentsCommand) + { + var grain = grainFactory.GetGrain(commentsCommand.CommentsId); + + var result = await grain.ExecuteAsync(commentsCommand.AsJ()); + + context.Complete(result.Value); + } + + private static bool IsMention(CreateComment createComment) + { + return createComment.IsMention; + } + + private async Task MentionUsersAsync(CreateComment createComment) + { + if (!string.IsNullOrWhiteSpace(createComment.Text)) + { + var emails = MentionRegex.Matches(createComment.Text).Select(x => x.Value.Substring(1)).ToArray(); + + if (emails.Length > 0) + { + var mentions = new List(); + + foreach (var email in emails) + { + var user = await userResolver.FindByIdOrEmailAsync(email); + + if (user != null) + { + mentions.Add(user.Id); + } + } + + if (mentions.Count > 0) + { + createComment.Mentions = mentions.ToArray(); + } + } + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs index 28de19e0d..587be0a7c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs @@ -7,73 +7,71 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Squidex.Domain.Apps.Entities.Comments.Commands; using Squidex.Domain.Apps.Entities.Comments.Guards; -using Squidex.Domain.Apps.Entities.Comments.State; using Squidex.Domain.Apps.Events.Comments; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Reflection; -using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Comments { - public sealed class CommentsGrain : DomainObjectGrainBase, ICommentsGrain + public sealed class CommentsGrain : GrainOfString, ICommentsGrain { - private readonly IStore store; + private readonly List> uncommittedEvents = new List>(); private readonly List> events = new List>(); - private CommentsState snapshot = new CommentsState { Version = EtagVersion.Empty }; - private IPersistence persistence; + private readonly IEventStore eventStore; + private readonly IEventDataFormatter eventDataFormatter; + private long version = EtagVersion.Empty; + private string streamName; - public override CommentsState Snapshot + private long Version { - get { return snapshot; } + get { return version; } } - public CommentsGrain(IStore store, ISemanticLog log) - : base(log) + public CommentsGrain(IEventStore eventStore, IEventDataFormatter eventDataFormatter) { - Guard.NotNull(store); + Guard.NotNull(eventStore); + Guard.NotNull(eventDataFormatter); - this.store = store; + this.eventStore = eventStore; + this.eventDataFormatter = eventDataFormatter; } - protected override void ApplyEvent(Envelope @event) + protected override async Task OnActivateAsync(string key) { - snapshot = new CommentsState { Version = snapshot.Version + 1 }; + streamName = $"comments-{key}"; - events.Add(@event.To()); - } + var storedEvents = await eventStore.QueryLatestAsync(streamName, 100); - protected override void RestorePreviousSnapshot(CommentsState previousSnapshot, long previousVersion) - { - snapshot = previousSnapshot; - } + foreach (var @event in storedEvents) + { + var parsedEvent = eventDataFormatter.Parse(@event.Data); - protected override Task ReadAsync(Type type, Guid id) - { - persistence = store.WithEventSourcing(GetType(), id, ApplyEvent); + version = @event.EventStreamNumber; - return persistence.ReadAsync(); + events.Add(parsedEvent.To()); + } } - protected override async Task WriteAsync(Envelope[] newEvents, long previousVersion) + public async Task> ExecuteAsync(J command) { - if (newEvents.Length > 0) - { - await persistence.WriteEventsAsync(newEvents); - } + var result = await ExecuteAsync(command.Value); + + return result.AsJ(); } - protected override Task ExecuteAsync(IAggregateCommand command) + private Task ExecuteAsync(CommentsCommand command) { switch (command) { case CreateComment createComment: - return UpsertReturn(createComment, c => + return Upsert(createComment, c => { GuardComments.CanCreate(c); @@ -85,17 +83,21 @@ namespace Squidex.Domain.Apps.Entities.Comments case UpdateComment updateComment: return Upsert(updateComment, c => { - GuardComments.CanUpdate(events, c); + GuardComments.CanUpdate(Key, events, c); Update(c); + + return new EntitySavedResult(Version); }); case DeleteComment deleteComment: return Upsert(deleteComment, c => { - GuardComments.CanDelete(events, c); + GuardComments.CanDelete(Key, events, c); Delete(c); + + return new EntitySavedResult(Version); }); default: @@ -103,6 +105,47 @@ namespace Squidex.Domain.Apps.Entities.Comments } } + private async Task Upsert(TCommand command, Func handler) where TCommand : CommentsCommand + { + Guard.NotNull(command); + Guard.NotNull(handler); + + if (command.ExpectedVersion > EtagVersion.Any && command.ExpectedVersion != Version) + { + throw new DomainObjectVersionException(Key, GetType(), Version, command.ExpectedVersion); + } + + var prevVersion = version; + + try + { + var result = handler(command); + + if (uncommittedEvents.Count > 0) + { + var commitId = Guid.NewGuid(); + + var eventData = uncommittedEvents.Select(x => eventDataFormatter.ToEventData(x, commitId)).ToList(); + + await eventStore.AppendAsync(commitId, streamName, prevVersion, eventData); + } + + events.AddRange(uncommittedEvents); + + return result; + } + catch + { + version = prevVersion; + + throw; + } + finally + { + uncommittedEvents.Clear(); + } + } + public void Create(CreateComment command) { RaiseEvent(SimpleMapper.Map(command, new CommentCreated())); @@ -118,6 +161,18 @@ namespace Squidex.Domain.Apps.Entities.Comments RaiseEvent(SimpleMapper.Map(command, new CommentDeleted())); } + private void RaiseEvent(CommentsEvent @event) + { + uncommittedEvents.Add(Envelope.Create(@event)); + + version++; + } + + public List> GetUncommittedEvents() + { + return uncommittedEvents; + } + public Task GetCommentsAsync(long version = EtagVersion.Any) { return Task.FromResult(CommentsResult.FromEvents(events, Version, (int)version)); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsLoader.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsLoader.cs index 8380499ad..c142c1ac7 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsLoader.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsLoader.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Threading.Tasks; using Orleans; using Squidex.Infrastructure; @@ -21,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Comments this.grainFactory = grainFactory; } - public Task GetCommentsAsync(Guid id, long version = EtagVersion.Any) + public Task GetCommentsAsync(string id, long version = EtagVersion.Any) { var grain = grainFactory.GetGrain(id); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsResult.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsResult.cs index 6e8b4d6f7..b442789c8 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsResult.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsResult.cs @@ -59,7 +59,8 @@ namespace Squidex.Domain.Apps.Entities.Comments created.CommentId, @event.Headers.Timestamp(), @event.Payload.Actor, - created.Text); + created.Text, + created.Url); result.CreatedComments.Add(comment); break; @@ -73,7 +74,8 @@ namespace Squidex.Domain.Apps.Entities.Comments id, @event.Headers.Timestamp(), @event.Payload.Actor, - updated.Text); + updated.Text, + null); if (result.CreatedComments.Any(x => x.Id == id)) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/Guards/GuardComments.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/Guards/GuardComments.cs index b3c039965..0524b6446 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/Guards/GuardComments.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/Guards/GuardComments.cs @@ -30,15 +30,15 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards }); } - public static void CanUpdate(List> events, UpdateComment command) + public static void CanUpdate(string commentsId, List> events, UpdateComment command) { Guard.NotNull(command); var comment = FindComment(events, command.CommentId); - if (!comment.Payload.Actor.Equals(command.Actor)) + if (!string.Equals(commentsId, command.Actor.Identifier) && !comment.Payload.Actor.Equals(command.Actor)) { - throw new DomainException("Comment is created by another actor."); + throw new DomainException("Comment is created by another user."); } Validate.It(() => "Cannot update comment.", e => @@ -50,15 +50,15 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards }); } - public static void CanDelete(List> events, DeleteComment command) + public static void CanDelete(string commentsId, List> events, DeleteComment command) { Guard.NotNull(command); var comment = FindComment(events, command.CommentId); - if (!comment.Payload.Actor.Equals(command.Actor)) + if (!string.Equals(commentsId, command.Actor.Identifier) && !comment.Payload.Actor.Equals(command.Actor)) { - throw new DomainException("Comment is created by another actor."); + throw new DomainException("Comment is created by another user."); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/ICommentsGrain.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/ICommentsGrain.cs index 41ec3bc4f..529eefefd 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/ICommentsGrain.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/ICommentsGrain.cs @@ -6,13 +6,17 @@ // ========================================================================== using System.Threading.Tasks; +using Orleans; +using Squidex.Domain.Apps.Entities.Comments.Commands; using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Orleans; namespace Squidex.Domain.Apps.Entities.Comments { - public interface ICommentsGrain : IDomainObjectGrain + public interface ICommentsGrain : IGrainWithStringKey { + Task> ExecuteAsync(J command); + Task GetCommentsAsync(long version = EtagVersion.Any); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/ICommentsLoader.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/ICommentsLoader.cs index bc85962a5..dc17e0a35 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/ICommentsLoader.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/ICommentsLoader.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Threading.Tasks; using Squidex.Infrastructure; @@ -13,6 +12,6 @@ namespace Squidex.Domain.Apps.Entities.Comments { public interface ICommentsLoader { - Task GetCommentsAsync(Guid id, long version = EtagVersion.Any); + Task GetCommentsAsync(string id, long version = EtagVersion.Any); } } \ No newline at end of file diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/State/CommentsState.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/State/CommentsState.cs deleted file mode 100644 index b9f9eb62e..000000000 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/State/CommentsState.cs +++ /dev/null @@ -1,19 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Squidex.Infrastructure.EventSourcing; - -namespace Squidex.Domain.Apps.Entities.Comments.State -{ - public sealed class CommentsState : DomainObjectState - { - public override CommentsState Apply(Envelope @event) - { - return this; - } - } -} diff --git a/backend/src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs b/backend/src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs index cf65df94c..77bd8bc5e 100644 --- a/backend/src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs +++ b/backend/src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs @@ -13,8 +13,10 @@ namespace Squidex.Domain.Apps.Events.Comments [EventType(nameof(CommentCreated))] public sealed class CommentCreated : CommentsEvent { - public Guid CommentId { get; set; } - public string Text { get; set; } + + public string[]? Mentions { get; set; } + + public Uri? Url { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Events/Comments/CommentDeleted.cs b/backend/src/Squidex.Domain.Apps.Events/Comments/CommentDeleted.cs index 4ea982bf2..a18b984c8 100644 --- a/backend/src/Squidex.Domain.Apps.Events/Comments/CommentDeleted.cs +++ b/backend/src/Squidex.Domain.Apps.Events/Comments/CommentDeleted.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Events.Comments @@ -13,6 +12,5 @@ namespace Squidex.Domain.Apps.Events.Comments [EventType(nameof(CommentDeleted))] public sealed class CommentDeleted : CommentsEvent { - public Guid CommentId { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Events/Comments/CommentUpdated.cs b/backend/src/Squidex.Domain.Apps.Events/Comments/CommentUpdated.cs index bd066335b..fbfc57c63 100644 --- a/backend/src/Squidex.Domain.Apps.Events/Comments/CommentUpdated.cs +++ b/backend/src/Squidex.Domain.Apps.Events/Comments/CommentUpdated.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using Squidex.Infrastructure.EventSourcing; namespace Squidex.Domain.Apps.Events.Comments @@ -13,8 +12,6 @@ namespace Squidex.Domain.Apps.Events.Comments [EventType(nameof(CommentUpdated))] public sealed class CommentUpdated : CommentsEvent { - public Guid CommentId { get; set; } - public string Text { get; set; } } } diff --git a/backend/src/Squidex.Domain.Apps.Events/Comments/CommentsEvent.cs b/backend/src/Squidex.Domain.Apps.Events/Comments/CommentsEvent.cs index 637c8ef7a..74c4a68c0 100644 --- a/backend/src/Squidex.Domain.Apps.Events/Comments/CommentsEvent.cs +++ b/backend/src/Squidex.Domain.Apps.Events/Comments/CommentsEvent.cs @@ -11,6 +11,8 @@ namespace Squidex.Domain.Apps.Events.Comments { public abstract class CommentsEvent : AppEvent { - public Guid CommentsId { get; set; } + public string CommentsId { get; set; } + + public Guid CommentId { get; set; } } } diff --git a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore.cs b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore.cs index d40d3b7d5..c9f549ded 100644 --- a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore.cs +++ b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore.cs @@ -83,7 +83,7 @@ namespace Squidex.Infrastructure.EventSourcing { Paths = new Collection { - "/PartitionId" + "/id" } }, Id = Constants.LeaseCollection diff --git a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Reader.cs b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Reader.cs index c0451bff2..48dd81e7c 100644 --- a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Reader.cs +++ b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Reader.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Azure.Documents; @@ -19,6 +20,8 @@ namespace Squidex.Infrastructure.EventSourcing public partial class CosmosDbEventStore : IEventStore, IInitializable { + private static readonly List EmptyEvents = new List(); + public IEventSubscription CreateSubscription(IEventSubscriber subscriber, string? streamFilter = null, string? position = null) { Guard.NotNull(subscriber); @@ -37,6 +40,54 @@ namespace Squidex.Infrastructure.EventSourcing return TaskHelper.Done; } + public async Task> QueryLatestAsync(string streamName, int count) + { + Guard.NotNullOrEmpty(streamName); + + ThrowIfDisposed(); + + if (count <= 0) + { + return EmptyEvents; + } + + using (Profiler.TraceMethod()) + { + var query = FilterBuilder.ByStreamNameDesc(streamName, count); + + var result = new List(); + + await documentClient.QueryAsync(collectionUri, query, commit => + { + var eventStreamOffset = (int)commit.EventStreamOffset; + + var commitTimestamp = commit.Timestamp; + var commitOffset = 0; + + foreach (var @event in commit.Events) + { + eventStreamOffset++; + + var eventData = @event.ToEventData(); + var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); + + result.Add(new StoredEvent(streamName, eventToken, eventStreamOffset, eventData)); + } + + return TaskHelper.Done; + }); + + IEnumerable ordered = result.OrderBy(x => x.EventStreamNumber); + + if (result.Count > count) + { + ordered = ordered.Skip(result.Count - count); + } + + return ordered.ToList(); + } + } + public async Task> QueryAsync(string streamName, long streamPosition = 0) { Guard.NotNullOrEmpty(streamName); diff --git a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Writer.cs b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Writer.cs index 52e848345..6eeee9fd3 100644 --- a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Writer.cs +++ b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Writer.cs @@ -29,11 +29,16 @@ namespace Squidex.Infrastructure.EventSourcing var query = FilterBuilder.AllIds(streamName); + var deleteOptions = new RequestOptions + { + PartitionKey = new PartitionKey(streamName) + }; + return documentClient.QueryAsync(collectionUri, query, commit => { var documentUri = UriFactory.CreateDocumentUri(databaseId, Constants.Collection, commit.Id.ToString()); - return documentClient.DeleteDocumentAsync(documentUri); + return documentClient.DeleteDocumentAsync(documentUri, deleteOptions); }); } diff --git a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterBuilder.cs b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterBuilder.cs index 301be5b47..9970a50c7 100644 --- a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterBuilder.cs +++ b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterBuilder.cs @@ -70,6 +70,23 @@ namespace Squidex.Infrastructure.EventSourcing return new SqlQuerySpec(query, parameters); } + public static SqlQuerySpec ByStreamNameDesc(string streamName, long count) + { + var query = + $"SELECT TOP {count}* " + + $"FROM {Constants.Collection} e " + + $"WHERE " + + $" e.eventStream = @name " + + $"ORDER BY e.eventStreamOffset DESC"; + + var parameters = new SqlParameterCollection + { + new SqlParameter("@name", streamName) + }; + + return new SqlQuerySpec(query, parameters); + } + public static SqlQuerySpec CreateByProperty(string property, object value, StreamPosition streamPosition) { var filters = new List(); diff --git a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterExtensions.cs b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterExtensions.cs index 77dc23c86..5bf989b47 100644 --- a/backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterExtensions.cs +++ b/backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterExtensions.cs @@ -17,6 +17,11 @@ namespace Squidex.Infrastructure.EventSourcing { internal static class FilterExtensions { + private static readonly FeedOptions CrossPartition = new FeedOptions + { + EnableCrossPartitionQuery = true + }; + public static async Task FirstOrDefaultAsync(this IQueryable queryable, CancellationToken ct = default) { var documentQuery = queryable.AsDocumentQuery(); @@ -36,7 +41,7 @@ namespace Squidex.Infrastructure.EventSourcing public static Task QueryAsync(this DocumentClient documentClient, Uri collectionUri, SqlQuerySpec querySpec, Func handler, CancellationToken ct = default) { - var query = documentClient.CreateDocumentQuery(collectionUri, querySpec); + var query = documentClient.CreateDocumentQuery(collectionUri, querySpec, CrossPartition); return query.QueryAsync(handler, ct); } diff --git a/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs b/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs index f9525cb6a..62886dc61 100644 --- a/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs +++ b/backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs @@ -21,6 +21,7 @@ namespace Squidex.Infrastructure.EventSourcing { private const int WritePageSize = 500; private const int ReadPageSize = 500; + private static readonly List EmptyEvents = new List(); private readonly IEventStoreConnection connection; private readonly IJsonSerializer serializer; private readonly string prefix; @@ -119,6 +120,51 @@ namespace Squidex.Infrastructure.EventSourcing while (!currentSlice.IsEndOfStream && !ct.IsCancellationRequested); } + public async Task> QueryLatestAsync(string streamName, int count) + { + Guard.NotNullOrEmpty(streamName); + + if (count <= 0) + { + return EmptyEvents; + } + + using (Profiler.TraceMethod()) + { + var result = new List(); + + var sliceStart = (long)StreamPosition.End; + + StreamEventsSlice currentSlice; + do + { + currentSlice = await connection.ReadStreamEventsBackwardAsync(GetStreamName(streamName), sliceStart, ReadPageSize, true); + + if (currentSlice.Status == SliceReadStatus.Success) + { + sliceStart = currentSlice.NextEventNumber; + + foreach (var resolved in currentSlice.Events) + { + var storedEvent = Formatter.Read(resolved, prefix, serializer); + + result.Add(storedEvent); + } + } + } + while (!currentSlice.IsEndOfStream); + + IEnumerable ordered = result.OrderBy(x => x.EventStreamNumber); + + if (result.Count > count) + { + ordered = ordered.Skip(result.Count - count); + } + + return ordered.ToList(); + } + } + public async Task> QueryAsync(string streamName, long streamPosition = 0) { Guard.NotNullOrEmpty(streamName); diff --git a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs index b8ed11d0c..b4e630159 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs @@ -50,8 +50,12 @@ namespace Squidex.Infrastructure.EventSourcing { new CreateIndexModel( Index - .Ascending(x => x.Timestamp) - .Ascending(x => x.EventStream)), + .Ascending(x => x.EventStream) + .Ascending(x => x.Timestamp)), + new CreateIndexModel( + Index + .Ascending(x => x.EventStream) + .Descending(x => x.Timestamp)), new CreateIndexModel( Index .Ascending(x => x.EventStream) diff --git a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs index 757c19348..726f4d568 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs +++ b/backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using MongoDB.Driver; @@ -21,6 +22,8 @@ namespace Squidex.Infrastructure.EventSourcing public partial class MongoEventStore : MongoRepositoryBase, IEventStore { + private static readonly List EmptyEvents = new List(); + public Task CreateIndexAsync(string property) { Guard.NotNullOrEmpty(property); @@ -37,6 +40,53 @@ namespace Squidex.Infrastructure.EventSourcing return new PollingSubscription(this, subscriber, streamFilter, position); } + public async Task> QueryLatestAsync(string streamName, int count) + { + Guard.NotNullOrEmpty(streamName); + + if (count <= 0) + { + return EmptyEvents; + } + + using (Profiler.TraceMethod()) + { + var commits = + await Collection.Find( + Filter.Eq(EventStreamField, streamName)) + .Sort(Sort.Descending(TimestampField)).Limit(count).ToListAsync(); + + var result = new List(); + + foreach (var commit in commits) + { + var eventStreamOffset = (int)commit.EventStreamOffset; + + var commitTimestamp = commit.Timestamp; + var commitOffset = 0; + + foreach (var @event in commit.Events) + { + eventStreamOffset++; + + var eventData = @event.ToEventData(); + var eventToken = new StreamPosition(commitTimestamp, commitOffset, commit.Events.Length); + + result.Add(new StoredEvent(streamName, eventToken, eventStreamOffset, eventData)); + } + } + + IEnumerable ordered = result.OrderBy(x => x.EventStreamNumber); + + if (result.Count > count) + { + ordered = ordered.Skip(result.Count - count); + } + + return ordered.ToList(); + } + } + public async Task> QueryAsync(string streamName, long streamPosition = 0) { Guard.NotNullOrEmpty(streamName); diff --git a/backend/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs b/backend/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs index d33b3f470..9d6c13d51 100644 --- a/backend/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs +++ b/backend/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs @@ -160,8 +160,6 @@ namespace Squidex.Infrastructure.Commands if (mode == Mode.Update && Version < 0) { - TryDeactivateOnIdle(); - throw new DomainObjectNotFoundException(id.ToString(), GetType()); } diff --git a/backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs b/backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs index 881eb708c..104000c2e 100644 --- a/backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs +++ b/backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs @@ -16,6 +16,8 @@ namespace Squidex.Infrastructure.EventSourcing { Task CreateIndexAsync(string property); + Task> QueryLatestAsync(string streamName, int count); + Task> QueryAsync(string streamName, long streamPosition = 0); Task QueryAsync(Func callback, string? streamFilter = null, string? position = null, CancellationToken ct = default); diff --git a/backend/src/Squidex.Infrastructure/Orleans/StateFilter.cs b/backend/src/Squidex.Infrastructure/Orleans/StateFilter.cs index f946bc7a8..9b1faa7ef 100644 --- a/backend/src/Squidex.Infrastructure/Orleans/StateFilter.cs +++ b/backend/src/Squidex.Infrastructure/Orleans/StateFilter.cs @@ -7,22 +7,54 @@ using System.Threading.Tasks; using Orleans; -using Orleans.Storage; -using StateInconsistentStateException = Squidex.Infrastructure.States.InconsistentStateException; +using Orleans.Runtime; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Infrastructure.States; namespace Squidex.Infrastructure.Orleans { public sealed class StateFilter : IIncomingGrainCallFilter { + private readonly IGrainRuntime runtime; + + public StateFilter(IGrainRuntime runtime) + { + Guard.NotNull(runtime); + + this.runtime = runtime; + } + public async Task Invoke(IIncomingGrainCallContext context) { try { await context.Invoke(); } - catch (StateInconsistentStateException ex) + catch (DomainObjectNotFoundException) + { + TryDeactivate(context); + + throw; + } + catch (WrongEventVersionException) + { + TryDeactivate(context); + + throw; + } + catch (InconsistentStateException) + { + TryDeactivate(context); + + throw; + } + } + + private void TryDeactivate(IIncomingGrainCallContext context) + { + if (context.Grain is Grain grain) { - throw new InconsistentStateException(ex.Message, ex); + runtime.DeactivateOnIdle(grain); } } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs index 7d5e1ed37..bd06ce91b 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs @@ -48,7 +48,7 @@ namespace Squidex.Areas.Api.Controllers.Apps [HttpGet] [Route("apps/{app}/contributors/")] [ProducesResponseType(typeof(ContributorsDto), 200)] - [ApiPermission(Permissions.AppContributorsRead)] + [ApiPermission(Permissions.AppCommon)] [ApiCosts(0)] public IActionResult GetContributors(string app) { diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs index ba66b1efe..cfdaf7a04 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs @@ -29,6 +29,12 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models [Required] public string ContributorName { get; set; } + /// + /// The email address. + /// + [Required] + public string ContributorEmail { get; set; } + /// /// The role of the contributor. /// @@ -46,6 +52,7 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models if (users.TryGetValue(ContributorId, out var user)) { ContributorName = user.DisplayName()!; + ContributorEmail = user.Email; } else { diff --git a/backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs index 36a5e8472..792f038a5 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs @@ -20,7 +20,7 @@ using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Comments { /// - /// Manages comments for any kind of resource. + /// Manages comments for any kind of app resource. /// [ApiExplorerSettings(GroupName = nameof(Comments))] public sealed class CommentsController : ApiController @@ -51,7 +51,7 @@ namespace Squidex.Areas.Api.Controllers.Comments [ProducesResponseType(typeof(CommentsDto), 200)] [ApiPermission(Permissions.AppCommon)] [ApiCosts(0)] - public async Task GetComments(string app, Guid commentsId, [FromQuery] long version = EtagVersion.Any) + public async Task GetComments(string app, string commentsId, [FromQuery] long version = EtagVersion.Any) { var result = await commentsLoader.GetCommentsAsync(commentsId, version); @@ -78,10 +78,10 @@ namespace Squidex.Areas.Api.Controllers.Comments /// [HttpPost] [Route("apps/{app}/comments/{commentsId}")] - [ProducesResponseType(typeof(EntityCreatedDto), 201)] + [ProducesResponseType(typeof(CommentDto), 201)] [ApiPermission(Permissions.AppCommon)] [ApiCosts(0)] - public async Task PostComment(string app, Guid commentsId, [FromBody] UpsertCommentDto request) + public async Task PostComment(string app, string commentsId, [FromBody] UpsertCommentDto request) { var command = request.ToCreateCommand(commentsId); @@ -108,7 +108,7 @@ namespace Squidex.Areas.Api.Controllers.Comments [Route("apps/{app}/comments/{commentsId}/{commentId}")] [ApiPermission(Permissions.AppCommon)] [ApiCosts(0)] - public async Task PutComment(string app, Guid commentsId, Guid commentId, [FromBody] UpsertCommentDto request) + public async Task PutComment(string app, string commentsId, Guid commentId, [FromBody] UpsertCommentDto request) { await CommandBus.PublishAsync(request.ToUpdateComment(commentsId, commentId)); @@ -129,9 +129,13 @@ namespace Squidex.Areas.Api.Controllers.Comments [Route("apps/{app}/comments/{commentsId}/{commentId}")] [ApiPermission(Permissions.AppCommon)] [ApiCosts(0)] - public async Task DeleteComment(string app, Guid commentsId, Guid commentId) + public async Task DeleteComment(string app, string commentsId, Guid commentId) { - await CommandBus.PublishAsync(new DeleteComment { CommentsId = commentsId, CommentId = commentId }); + await CommandBus.PublishAsync(new DeleteComment + { + CommentsId = commentsId, + CommentId = commentId + }); return NoContent(); } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentDto.cs index 5704f1427..cbf37d75b 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentDto.cs @@ -40,6 +40,11 @@ namespace Squidex.Areas.Api.Controllers.Comments.Models [Required] public string Text { get; set; } + /// + /// The url where the comment is created. + /// + public Uri? Url { get; set; } + public static CommentDto FromComment(Comment comment) { return SimpleMapper.Map(comment, new CommentDto()); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Comments/Models/UpsertCommentDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Comments/Models/UpsertCommentDto.cs index d52667ae6..0a17abffa 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Comments/Models/UpsertCommentDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Comments/Models/UpsertCommentDto.cs @@ -20,12 +20,17 @@ namespace Squidex.Areas.Api.Controllers.Comments.Models [Required] public string Text { get; set; } - public CreateComment ToCreateCommand(Guid commentsId) + /// + /// The url where the comment is created. + /// + public Uri? Url { get; set; } + + public CreateComment ToCreateCommand(string commentsId) { return SimpleMapper.Map(this, new CreateComment { CommentsId = commentsId }); } - public UpdateComment ToUpdateComment(Guid commentsId, Guid commentId) + public UpdateComment ToUpdateComment(string commentsId, Guid commentId) { return SimpleMapper.Map(this, new UpdateComment { CommentsId = commentsId, CommentId = commentId }); } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Comments/Notifications/UserNotificationsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Comments/Notifications/UserNotificationsController.cs new file mode 100644 index 000000000..8b55ee330 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Comments/Notifications/UserNotificationsController.cs @@ -0,0 +1,102 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschränkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Net.Http.Headers; +using Squidex.Areas.Api.Controllers.Comments.Models; +using Squidex.Domain.Apps.Entities.Comments; +using Squidex.Domain.Apps.Entities.Comments.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Security; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Comments.Notifications +{ + /// + /// Manages user notifications. + /// + [ApiExplorerSettings(GroupName = nameof(Notifications))] + public sealed class UserNotificationsController : ApiController + { + private static readonly NamedId NoApp = NamedId.Of(Guid.Empty, "none"); + private readonly ICommentsLoader commentsLoader; + + public UserNotificationsController(ICommandBus commandBus, ICommentsLoader commentsLoader) + : base(commandBus) + { + this.commentsLoader = commentsLoader; + } + + /// + /// Get all notifications. + /// + /// The user id. + /// The current version. + /// + /// When passing in a version you can retrieve all updates since then. + /// + /// + /// 200 => All comments returned. + /// + [HttpGet] + [Route("users/{userId}/notifications")] + [ProducesResponseType(typeof(CommentsDto), 200)] + [ApiPermission] + public async Task GetNotifications(string userId, [FromQuery] long version = EtagVersion.Any) + { + CheckPermissions(userId); + + var result = await commentsLoader.GetCommentsAsync(userId, version); + + var response = Deferred.Response(() => + { + return CommentsDto.FromResult(result); + }); + + Response.Headers[HeaderNames.ETag] = result.Version.ToString(); + + return Ok(response); + } + + /// + /// Deletes the notification. + /// + /// The user id. + /// The id of the comment. + /// + /// 204 => Comment deleted. + /// 404 => Comment not found. + /// + [HttpDelete] + [Route("users/{userId}/notifications/{commentId}")] + [ApiPermission] + public async Task DeleteComment(string userId, Guid commentId) + { + CheckPermissions(userId); + + await CommandBus.PublishAsync(new DeleteComment + { + AppId = NoApp, + CommentsId = userId, + CommentId = commentId + }); + + return NoContent(); + } + + private void CheckPermissions(string userId) + { + if (!string.Equals(userId, User.OpenIdSubject())) + { + throw new DomainForbiddenException("You can only access your notifications."); + } + } + } +} diff --git a/backend/src/Squidex/Config/Domain/CommandsServices.cs b/backend/src/Squidex/Config/Domain/CommandsServices.cs index e93168e9a..c5096117c 100644 --- a/backend/src/Squidex/Config/Domain/CommandsServices.cs +++ b/backend/src/Squidex/Config/Domain/CommandsServices.cs @@ -84,10 +84,10 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); - services.AddSingletonAs>() + services.AddSingletonAs() .As(); - services.AddSingletonAs>() + services.AddSingletonAs>() .As(); services.AddSingletonAs>() diff --git a/backend/src/Squidex/wwwroot/images/dashboard_feedback.svg b/backend/src/Squidex/wwwroot/images/dashboard-feedback.svg similarity index 100% rename from backend/src/Squidex/wwwroot/images/dashboard_feedback.svg rename to backend/src/Squidex/wwwroot/images/dashboard-feedback.svg diff --git a/backend/src/Squidex/wwwroot/images/dashboard_schema.svg b/backend/src/Squidex/wwwroot/images/dashboard_schema.svg deleted file mode 100644 index 3bd7aaff7..000000000 --- a/backend/src/Squidex/wwwroot/images/dashboard_schema.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsCommandMiddlewareTests.cs new file mode 100644 index 000000000..fff0c6c7c --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsCommandMiddlewareTests.cs @@ -0,0 +1,180 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using FakeItEasy; +using Orleans; +using Squidex.Domain.Apps.Entities.Comments.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Orleans; +using Squidex.Infrastructure.Tasks; +using Squidex.Shared.Users; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Comments +{ + public class CommentsCommandMiddlewareTests + { + private readonly IGrainFactory grainFactory = A.Fake(); + private readonly IUserResolver userResolver = A.Fake(); + private readonly ICommandBus commandBus = A.Fake(); + private readonly RefToken actor = new RefToken(RefTokenType.Subject, "me"); + private readonly NamedId appId = NamedId.Of(Guid.NewGuid(), "my-app"); + private readonly Guid commentsId = Guid.NewGuid(); + private readonly Guid commentId = Guid.NewGuid(); + private readonly CommentsCommandMiddleware sut; + + public CommentsCommandMiddlewareTests() + { + A.CallTo(() => userResolver.FindByIdOrEmailAsync(A.Ignored)) + .Returns(Task.FromResult(null)); + + sut = new CommentsCommandMiddleware(grainFactory, userResolver); + } + + [Fact] + public async Task Should_invoke_grain_for_comments_command() + { + var command = CreateCommentsCommand(new CreateComment()); + var context = CreateContextForCommand(command); + + var grain = A.Fake(); + + var result = "Completed"; + + A.CallTo(() => grainFactory.GetGrain(commentsId.ToString(), null)) + .Returns(grain); + + A.CallTo(() => grain.ExecuteAsync(A>.That.Matches(x => x.Value == command))) + .Returns(new J(result)); + + var isNextCalled = false; + + await sut.HandleAsync(context, () => + { + isNextCalled = true; + + return TaskHelper.Done; + }); + + Assert.True(isNextCalled); + + A.CallTo(() => grain.ExecuteAsync(A>.That.Matches(x => x.Value == command))) + .Returns(new J(12)); + } + + [Fact] + public async Task Should_enrich_with_mentioned_user_ids_if_found() + { + SetupUser("id1", "mail1@squidex.io"); + SetupUser("id2", "mail2@squidex.io"); + + var command = CreateCommentsCommand(new CreateComment + { + Text = "Hi @mail1@squidex.io, @mail2@squidex.io and @notfound@squidex.io" + }); + + var context = CreateContextForCommand(command); + + await sut.HandleAsync(context); + + Assert.Equal(command.Mentions, new[] { "id1", "id2" }); + } + + [Fact] + public async Task Should_invoke_commands_for_mentioned_users() + { + SetupUser("id1", "mail1@squidex.io"); + SetupUser("id2", "mail2@squidex.io"); + + var command = CreateCommentsCommand(new CreateComment + { + Text = "Hi @mail1@squidex.io and @mail2@squidex.io" + }); + + var context = CreateContextForCommand(command); + + await sut.HandleAsync(context); + + A.CallTo(() => commandBus.PublishAsync(A.That.Matches(x => IsForUser(x, "id1")))) + .MustHaveHappened(); + + A.CallTo(() => commandBus.PublishAsync(A.That.Matches(x => IsForUser(x, "id2")))) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_not_enrich_with_mentioned_user_ids_if_invalid_mentioned_tags_used() + { + var command = CreateCommentsCommand(new CreateComment + { + Text = "Hi invalid@squidex.io" + }); + + var context = CreateContextForCommand(command); + + await sut.HandleAsync(context); + + A.CallTo(() => userResolver.FindByIdOrEmailAsync(A.Ignored)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_enrich_with_mentioned_user_ids_for_notification() + { + var command = new CreateComment + { + Text = "Hi @invalid@squidex.io", IsMention = true + }; + + var context = CreateContextForCommand(command); + + await sut.HandleAsync(context); + + A.CallTo(() => userResolver.FindByIdOrEmailAsync(A.Ignored)) + .MustNotHaveHappened(); + } + + protected CommandContext CreateContextForCommand(TCommand command) where TCommand : CommentsCommand + { + return new CommandContext(command, commandBus); + } + + private static bool IsForUser(ICommand command, string id) + { + return command is CreateComment createComment && + createComment.CommentsId == id && + createComment.Mentions == null && + createComment.AppId == null && + createComment.ExpectedVersion == EtagVersion.Any && + createComment.IsMention; + } + + private void SetupUser(string id, string email) + { + var user = A.Fake(); + + A.CallTo(() => user.Id).Returns(id); + A.CallTo(() => user.Email).Returns(email); + + A.CallTo(() => userResolver.FindByIdOrEmailAsync(email)) + .Returns(user); + } + + protected T CreateCommentsCommand(T command) where T : CommentsCommand + { + command.Actor = actor; + command.AppId = appId; + command.CommentsId = commentsId.ToString(); + command.CommentId = commentId; + + return command; + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsGrainTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsGrainTests.cs index c42c31eb5..8051b580e 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsGrainTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsGrainTests.cs @@ -11,38 +11,47 @@ using System.Linq; using System.Threading.Tasks; using FakeItEasy; using FluentAssertions; +using NodaTime; using Squidex.Domain.Apps.Core.Comments; using Squidex.Domain.Apps.Entities.Comments.Commands; -using Squidex.Domain.Apps.Entities.Comments.State; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Events.Comments; +using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.EventSourcing; -using Squidex.Infrastructure.Log; using Xunit; namespace Squidex.Domain.Apps.Entities.Comments { - public class CommentsGrainTests : HandlerTestBase + public class CommentsGrainTests { + private readonly IEventStore eventStore = A.Fake(); + private readonly IEventDataFormatter eventDataFormatter = A.Fake(); private readonly Guid commentsId = Guid.NewGuid(); + private readonly Guid commentId = Guid.NewGuid(); + private readonly RefToken actor = new RefToken(RefTokenType.Subject, "me"); private readonly CommentsGrain sut; - protected override Guid Id + private string Id { - get { return commentsId; } + get { return commentsId.ToString(); } } + public IEnumerable> LastEvents { get; private set; } = Enumerable.Empty>(); + public CommentsGrainTests() { - sut = new CommentsGrain(Store, A.Dummy()); + A.CallTo(() => eventStore.AppendAsync(A.Ignored, A.Ignored, A.Ignored, A>.Ignored)) + .Invokes(x => LastEvents = sut.GetUncommittedEvents().Select(x => x.To()).ToList()); + + sut = new CommentsGrain(eventStore, eventDataFormatter); sut.ActivateAsync(Id).Wait(); } [Fact] public async Task Create_should_create_events() { - var command = new CreateComment { Text = "text1" }; + var command = new CreateComment { Text = "text1", Url = new Uri("http://uri") }; var result = await sut.ExecuteAsync(CreateCommentsCommand(command)); @@ -53,24 +62,23 @@ namespace Squidex.Domain.Apps.Entities.Comments { CreatedComments = new List { - new Comment(command.CommentId, LastEvents.ElementAt(0).Headers.Timestamp(), command.Actor, "text1") + new Comment(command.CommentId, GetTime(), command.Actor, "text1", command.Url) }, Version = 0 }); LastEvents .ShouldHaveSameEvents( - CreateCommentsEvent(new CommentCreated { CommentId = command.CommentId, Text = command.Text }) + CreateCommentsEvent(new CommentCreated { Text = command.Text, Url = command.Url }) ); } [Fact] public async Task Update_should_create_events_and_update_state() { - var createCommand = new CreateComment { Text = "text1" }; - var updateCommand = new UpdateComment { Text = "text2", CommentId = createCommand.CommentId }; + await ExecuteCreateAsync(); - await sut.ExecuteAsync(CreateCommentsCommand(createCommand)); + var updateCommand = new UpdateComment { Text = "text2" }; var result = await sut.ExecuteAsync(CreateCommentsCommand(updateCommand)); @@ -80,7 +88,7 @@ namespace Squidex.Domain.Apps.Entities.Comments { CreatedComments = new List { - new Comment(createCommand.CommentId, LastEvents.ElementAt(0).Headers.Timestamp(), createCommand.Actor, "text2") + new Comment(commentId, GetTime(), updateCommand.Actor, "text2") }, Version = 1 }); @@ -89,26 +97,24 @@ namespace Squidex.Domain.Apps.Entities.Comments { UpdatedComments = new List { - new Comment(createCommand.CommentId, LastEvents.ElementAt(0).Headers.Timestamp(), createCommand.Actor, "text2") + new Comment(commentId, GetTime(), updateCommand.Actor, "text2") }, Version = 1 }); LastEvents .ShouldHaveSameEvents( - CreateCommentsEvent(new CommentUpdated { CommentId = createCommand.CommentId, Text = updateCommand.Text }) + CreateCommentsEvent(new CommentUpdated { Text = updateCommand.Text }) ); } [Fact] public async Task Delete_should_create_events_and_update_state() { - var createCommand = new CreateComment { Text = "text1" }; - var updateCommand = new UpdateComment { Text = "text2", CommentId = createCommand.CommentId }; - var deleteCommand = new DeleteComment { CommentId = createCommand.CommentId }; + await ExecuteCreateAsync(); + await ExecuteUpdateAsync(); - await sut.ExecuteAsync(CreateCommentsCommand(createCommand)); - await sut.ExecuteAsync(CreateCommentsCommand(updateCommand)); + var deleteCommand = new DeleteComment(); var result = await sut.ExecuteAsync(CreateCommentsCommand(deleteCommand)); @@ -119,7 +125,7 @@ namespace Squidex.Domain.Apps.Entities.Comments { DeletedComments = new List { - deleteCommand.CommentId + commentId }, Version = 2 }); @@ -127,29 +133,48 @@ namespace Squidex.Domain.Apps.Entities.Comments { DeletedComments = new List { - deleteCommand.CommentId + commentId }, Version = 2 }); LastEvents .ShouldHaveSameEvents( - CreateCommentsEvent(new CommentDeleted { CommentId = createCommand.CommentId }) + CreateCommentsEvent(new CommentDeleted()) ); } + private Task ExecuteCreateAsync() + { + return sut.ExecuteAsync(CreateCommentsCommand(new CreateComment { Text = "text1" })); + } + + private Task ExecuteUpdateAsync() + { + return sut.ExecuteAsync(CreateCommentsCommand(new UpdateComment { Text = "text2" })); + } + protected T CreateCommentsEvent(T @event) where T : CommentsEvent { - @event.CommentsId = commentsId; + @event.Actor = actor; + @event.CommentsId = commentsId.ToString(); + @event.CommentId = commentId; - return CreateEvent(@event); + return @event; } protected T CreateCommentsCommand(T command) where T : CommentsCommand { - command.CommentsId = commentsId; + command.Actor = actor; + command.CommentsId = commentsId.ToString(); + command.CommentId = commentId; - return CreateCommand(command); + return command; + } + + private Instant GetTime() + { + return LastEvents.ElementAt(0).Headers.Timestamp(); } } } \ No newline at end of file diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsLoaderTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsLoaderTests.cs index 82c936170..2ccc49147 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsLoaderTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsLoaderTests.cs @@ -26,7 +26,7 @@ namespace Squidex.Domain.Apps.Entities.Comments [Fact] public async Task Should_get_comments_from_grain() { - var commentsId = Guid.NewGuid(); + var commentsId = Guid.NewGuid().ToString(); var comments = new CommentsResult(); var grain = A.Fake(); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/Guards/GuardCommentsTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/Guards/GuardCommentsTests.cs index b9258c756..7b5d6e45c 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/Guards/GuardCommentsTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/Guards/GuardCommentsTests.cs @@ -19,6 +19,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards { public class GuardCommentsTests { + private readonly string commentsId = Guid.NewGuid().ToString(); private readonly RefToken user1 = new RefToken(RefTokenType.Subject, "1"); private readonly RefToken user2 = new RefToken(RefTokenType.Subject, "2"); @@ -50,7 +51,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To() }; - ValidationAssert.Throws(() => GuardComments.CanUpdate(events, command), + ValidationAssert.Throws(() => GuardComments.CanUpdate(commentsId, events, command), new ValidationError("Text is required.", "Text")); } @@ -65,7 +66,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To() }; - Assert.Throws(() => GuardComments.CanUpdate(events, command)); + Assert.Throws(() => GuardComments.CanUpdate(commentsId, events, command)); } [Fact] @@ -76,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards var events = new List>(); - Assert.Throws(() => GuardComments.CanUpdate(events, command)); + Assert.Throws(() => GuardComments.CanUpdate(commentsId, events, command)); } [Fact] @@ -91,7 +92,21 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards Envelope.Create(new CommentDeleted { CommentId = commentId }).To() }; - Assert.Throws(() => GuardComments.CanUpdate(events, command)); + Assert.Throws(() => GuardComments.CanUpdate(commentsId, events, command)); + } + + [Fact] + public void CanUpdate_should_not_throw_exception_if_comment_is_own_notification() + { + var commentId = Guid.NewGuid(); + var command = new UpdateComment { CommentId = commentId, Actor = user1, Text = "text2" }; + + var events = new List> + { + Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To() + }; + + GuardComments.CanUpdate(user1.Identifier, events, command); } [Fact] @@ -105,7 +120,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To() }; - GuardComments.CanUpdate(events, command); + GuardComments.CanUpdate(commentsId, events, command); } [Fact] @@ -119,7 +134,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To() }; - Assert.Throws(() => GuardComments.CanDelete(events, command)); + Assert.Throws(() => GuardComments.CanDelete(commentsId, events, command)); } [Fact] @@ -130,7 +145,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards var events = new List>(); - Assert.Throws(() => GuardComments.CanDelete(events, command)); + Assert.Throws(() => GuardComments.CanDelete(commentsId, events, command)); } [Fact] @@ -145,7 +160,21 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards Envelope.Create(new CommentDeleted { CommentId = commentId }) }; - Assert.Throws(() => GuardComments.CanDelete(events, command)); + Assert.Throws(() => GuardComments.CanDelete(commentsId, events, command)); + } + + [Fact] + public void CanDelete_should_not_throw_exception_if_comment_is_own_notification() + { + var commentId = Guid.NewGuid(); + var command = new DeleteComment { CommentId = commentId, Actor = user1 }; + + var events = new List> + { + Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To() + }; + + GuardComments.CanDelete(user1.Identifier, events, command); } [Fact] @@ -159,7 +188,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To() }; - GuardComments.CanDelete(events, command); + GuardComments.CanDelete(commentsId, events, command); } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs index 213eb81a9..709cf26af 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs @@ -17,17 +17,15 @@ using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.States; -#pragma warning disable IDE0019 // Use pattern matching - namespace Squidex.Domain.Apps.Entities.TestHelpers { public abstract class HandlerTestBase { private readonly IStore store = A.Fake>(); - private readonly IPersistence persistence1 = A.Fake>(); - private readonly IPersistence persistence2 = A.Fake(); + private readonly IPersistence persistenceWithState = A.Fake>(); + private readonly IPersistence persistence = A.Fake(); - protected RefToken Actor { get; } = new RefToken(RefTokenType.Subject, Guid.NewGuid().ToString()); + protected RefToken Actor { get; } = new RefToken(RefTokenType.Subject, "me"); protected Guid AppId { get; } = Guid.NewGuid(); @@ -61,15 +59,15 @@ namespace Squidex.Domain.Apps.Entities.TestHelpers protected HandlerTestBase() { A.CallTo(() => store.WithSnapshotsAndEventSourcing(A.Ignored, Id, A>.Ignored, A.Ignored)) - .Returns(persistence1); + .Returns(persistenceWithState); A.CallTo(() => store.WithEventSourcing(A.Ignored, Id, A.Ignored)) - .Returns(persistence2); + .Returns(persistence); - A.CallTo(() => persistence1.WriteEventsAsync(A>>.Ignored)) + A.CallTo(() => persistenceWithState.WriteEventsAsync(A>>.Ignored)) .Invokes((IEnumerable> events) => LastEvents = events); - A.CallTo(() => persistence2.WriteEventsAsync(A>>.Ignored)) + A.CallTo(() => persistence.WriteEventsAsync(A>>.Ignored)) .Invokes((IEnumerable> events) => LastEvents = events); } @@ -136,6 +134,4 @@ namespace Squidex.Domain.Apps.Entities.TestHelpers } } } -} - -#pragma warning restore IDE0019 // Use pattern matching \ No newline at end of file +} \ No newline at end of file diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs index b76a66a62..2cf1025d5 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs @@ -29,7 +29,7 @@ namespace Squidex.Infrastructure.EventSourcing public Task OnErrorAsync(IEventSubscription subscription, Exception exception) { - throw new NotSupportedException(); + throw exception; } public Task OnEventAsync(IEventSubscription subscription, StoredEvent storedEvent) @@ -181,7 +181,6 @@ namespace Squidex.Infrastructure.EventSourcing }; ShouldBeEquivalentTo(readEventsFromPosition, expectedFromPosition); - ShouldBeEquivalentTo(readEventsFromBeginning, expectedFromBeginning); } @@ -212,6 +211,45 @@ namespace Squidex.Infrastructure.EventSourcing ShouldBeEquivalentTo(readEvents2, expected); } + [Theory] + [InlineData(30)] + [InlineData(1000)] + public async Task Should_read_latest_events(int count) + { + var streamName = $"test-{Guid.NewGuid()}"; + + var events = new List(); + + for (var i = 0; i < count; i++) + { + events.Add(new EventData($"Type{i}", new EnvelopeHeaders(), i.ToString())); + } + + for (var i = 0; i < events.Count / 2; i++) + { + var commit = events.Skip(i * 2).Take(2); + + await Sut.AppendAsync(Guid.NewGuid(), streamName, commit.ToArray()); + } + + var offset = 25; + + var take = count - offset; + + var expected1 = events + .Skip(offset) + .Select((x, i) => new StoredEvent(streamName, "Position", i + offset, events[i + offset])) + .ToArray(); + + var expected2 = new StoredEvent[0]; + + var readEvents1 = await Sut.QueryLatestAsync(streamName, take); + var readEvents2 = await Sut.QueryLatestAsync(streamName, 0); + + ShouldBeEquivalentTo(readEvents1, expected1); + ShouldBeEquivalentTo(readEvents2, expected2); + } + [Fact] public async Task Should_delete_stream() { diff --git a/frontend/app/app.module.ts b/frontend/app/app.module.ts index a38224668..802ede6c6 100644 --- a/frontend/app/app.module.ts +++ b/frontend/app/app.module.ts @@ -72,13 +72,13 @@ export function configCurrency() { @NgModule({ imports: [ - BrowserModule, BrowserAnimationsModule, - HttpClientModule, - FormsModule, + BrowserModule, CommonModule, - RouterModule, + FormsModule, + HttpClientModule, ReactiveFormsModule, + RouterModule, SqxFrameworkModule.forRoot(), SqxSharedModule.forRoot(), SqxShellModule, diff --git a/frontend/app/features/settings/pages/contributors/contributor-add-form.component.ts b/frontend/app/features/settings/pages/contributors/contributor-add-form.component.ts index 9ff01192e..77cb5eba7 100644 --- a/frontend/app/features/settings/pages/contributors/contributor-add-form.component.ts +++ b/frontend/app/features/settings/pages/contributors/contributor-add-form.component.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Component, Injectable, Input, OnInit } from '@angular/core'; +import { Component, Injectable, Input, OnChanges } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { Observable } from 'rxjs'; import { withLatestFrom } from 'rxjs/operators'; @@ -51,7 +51,7 @@ export class UsersDataSource implements AutocompleteSource { UsersDataSource ] }) -export class ContributorAddFormComponent implements OnInit { +export class ContributorAddFormComponent implements OnChanges { private defaultValue: any; @Input() @@ -69,7 +69,7 @@ export class ContributorAddFormComponent implements OnInit { ) { } - public ngOnInit() { + public ngOnChanges() { this.defaultValue = { role: this.roles[0].name, contributorId: '' }; this.assignContributorForm.submitCompleted({ newValue: this.defaultValue }); diff --git a/frontend/app/framework/angular/forms/confirm-click.directive.ts b/frontend/app/framework/angular/forms/confirm-click.directive.ts index 2cb836e0a..53627360e 100644 --- a/frontend/app/framework/angular/forms/confirm-click.directive.ts +++ b/frontend/app/framework/angular/forms/confirm-click.directive.ts @@ -48,6 +48,9 @@ export class ConfirmClickDirective implements OnDestroy { @Input() public confirmText: string; + @Input() + public confirmRequired = true; + @Output('sqxConfirmClick') public clickConfirmed = new DelayEventEmitter(); @@ -66,7 +69,8 @@ export class ConfirmClickDirective implements OnDestroy { @HostListener('click', ['$event']) public onClick(event: Event) { - if (this.confirmTitle && + if (this.confirmRequired && + this.confirmTitle && this.confirmTitle.length > 0 && this.confirmText && this.confirmText.length > 0) { diff --git a/frontend/app/framework/angular/http/loading.interceptor.ts b/frontend/app/framework/angular/http/loading.interceptor.ts index 3791b465f..049bd0346 100644 --- a/frontend/app/framework/angular/http/loading.interceptor.ts +++ b/frontend/app/framework/angular/http/loading.interceptor.ts @@ -22,6 +22,12 @@ export class LoadingInterceptor implements HttpInterceptor { public intercept(req: HttpRequest, next: HttpHandler): Observable> { const id = MathHelper.guid(); + const silent = req.headers.has('X-Silent'); + + if (silent) { + return next.handle(req); + } + this.loadingService.startLoading(id); return next.handle(req).pipe(finalize(() => { diff --git a/frontend/app/framework/angular/modals/modal.directive.ts b/frontend/app/framework/angular/modals/modal.directive.ts index 19b047ff1..4a1bc6bc5 100644 --- a/frontend/app/framework/angular/modals/modal.directive.ts +++ b/frontend/app/framework/angular/modals/modal.directive.ts @@ -6,7 +6,6 @@ */ import { ChangeDetectorRef, Directive, EmbeddedViewRef, Input, OnDestroy, Renderer2, TemplateRef, ViewContainerRef } from '@angular/core'; -import { timer } from 'rxjs'; import { DialogModel, @@ -25,6 +24,7 @@ declare type Model = DialogModel | ModalModel | any; export class ModalDirective implements OnDestroy { private readonly eventsView = new ResourceOwner(); private readonly eventsModel = new ResourceOwner(); + private static backdrop: any; private currentModel: DialogModel | ModalModel | null = null; private renderedView: EmbeddedViewRef | null = null; private renderRoots: ReadonlyArray | null; @@ -75,7 +75,9 @@ export class ModalDirective implements OnDestroy { if (isOpen) { if (!this.renderedView) { - this.renderedView = this.getContainer().createEmbeddedView(this.templateRef); + const container = this.getContainer(); + + this.renderedView = container.createEmbeddedView(this.templateRef); this.renderRoots = this.renderedView.rootNodes.filter(x => !!x.style); this.setupStyles(); @@ -89,6 +91,8 @@ export class ModalDirective implements OnDestroy { this.renderedView = null; this.renderRoots = null; + remove(this.renderer, ModalDirective.backdrop); + this.changeDetector.detectChanges(); } } @@ -104,6 +108,7 @@ export class ModalDirective implements OnDestroy { if (this.renderRoots) { for (const node of this.renderRoots) { this.renderer.setStyle(node, 'display', 'block'); + this.renderer.setStyle(node, 'z-index', 2000); } } } @@ -112,9 +117,7 @@ export class ModalDirective implements OnDestroy { if (isModel(value)) { this.currentModel = value; - this.eventsModel.own(value.isOpen.subscribe(update => { - this.update(update); - })); + this.eventsModel.own(value.isOpen.subscribe(isOpen => this.update(isOpen))); } else { this.update(value === true); } @@ -125,12 +128,25 @@ export class ModalDirective implements OnDestroy { return; } - if (this.closeAuto) { - document.addEventListener('mousedown', this.documentClickListener, true); + if (this.closeAuto && this.renderRoots && this.renderRoots.length > 0) { + let backdrop = ModalDirective.backdrop; + + if (!backdrop) { + backdrop = this.renderer.createElement('div'); + + this.renderer.setStyle(backdrop, 'position', 'fixed'); + this.renderer.setStyle(backdrop, 'top', 0); + this.renderer.setStyle(backdrop, 'left', 0); + this.renderer.setStyle(backdrop, 'right', 0); + this.renderer.setStyle(backdrop, 'bottom', 0); + this.renderer.setStyle(backdrop, 'z-index', 1500); + + ModalDirective.backdrop = backdrop; + } + + insertBefore(this.renderer, this.renderRoots[0], backdrop); - this.eventsView.own(() => { - document.removeEventListener('mousedown', this.documentClickListener, true); - }); + this.eventsView.own(this.renderer.listen(backdrop, 'click', this.backdropListener)); } if (this.closeAlways && this.renderRoots) { @@ -146,13 +162,9 @@ export class ModalDirective implements OnDestroy { } } - private documentClickListener = (event: MouseEvent) => { - const model = this.currentModel; - + private backdropListener = (event: MouseEvent) => { if (!this.isClickedInside(event)) { - this.eventsView.own(timer(100).subscribe(() => { - this.hideModal(model); - })); + this.hideModal(this.currentModel); } } @@ -183,6 +195,26 @@ export class ModalDirective implements OnDestroy { } } +function insertBefore(renderer: Renderer2, refElement: any, element: any) { + if (element && refElement) { + const parent = renderer.parentNode(refElement); + + if (parent) { + renderer.insertBefore(parent, element, refElement); + } + } +} + +function remove(renderer: Renderer2, element: any) { + if (element) { + const parent = renderer.parentNode(element); + + if (parent) { + renderer.removeChild(parent, element); + } + } +} + function isModel(model: Model): model is DialogModel | ModalModel { return Types.is(model, DialogModel) || Types.is(model, ModalModel); } \ No newline at end of file diff --git a/frontend/app/shared/components/asset-uploader.component.html b/frontend/app/shared/components/asset-uploader.component.html index d0d00899d..1027b7389 100644 --- a/frontend/app/shared/components/asset-uploader.component.html +++ b/frontend/app/shared/components/asset-uploader.component.html @@ -1,7 +1,7 @@