Browse Source

Mentions for comments and notifications

* More the API more idempotent

* Mention users.

* Temp

* Rebuild comments grain to use string key.

* Improvements for event store.

* Temporary.

* Notifications.

* Modal fixes.

* Build fix.

* Another build fix.

* Frontend errors fixed.
pull/464/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
a9aa608d59
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      backend/src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs
  2. 12
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs
  3. 11
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/CreateComment.cs
  4. 5
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs
  5. 4
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/UpdateComment.cs
  6. 113
      backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsCommandMiddleware.cs
  7. 123
      backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs
  8. 3
      backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsLoader.cs
  9. 6
      backend/src/Squidex.Domain.Apps.Entities/Comments/CommentsResult.cs
  10. 12
      backend/src/Squidex.Domain.Apps.Entities/Comments/Guards/GuardComments.cs
  11. 8
      backend/src/Squidex.Domain.Apps.Entities/Comments/ICommentsGrain.cs
  12. 3
      backend/src/Squidex.Domain.Apps.Entities/Comments/ICommentsLoader.cs
  13. 19
      backend/src/Squidex.Domain.Apps.Entities/Comments/State/CommentsState.cs
  14. 6
      backend/src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs
  15. 2
      backend/src/Squidex.Domain.Apps.Events/Comments/CommentDeleted.cs
  16. 3
      backend/src/Squidex.Domain.Apps.Events/Comments/CommentUpdated.cs
  17. 4
      backend/src/Squidex.Domain.Apps.Events/Comments/CommentsEvent.cs
  18. 2
      backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore.cs
  19. 51
      backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Reader.cs
  20. 7
      backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore_Writer.cs
  21. 17
      backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterBuilder.cs
  22. 7
      backend/src/Squidex.Infrastructure.Azure/EventSourcing/FilterExtensions.cs
  23. 46
      backend/src/Squidex.Infrastructure.GetEventStore/EventSourcing/GetEventStore.cs
  24. 8
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs
  25. 50
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs
  26. 2
      backend/src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs
  27. 2
      backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs
  28. 40
      backend/src/Squidex.Infrastructure/Orleans/StateFilter.cs
  29. 2
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
  30. 7
      backend/src/Squidex/Areas/Api/Controllers/Apps/Models/ContributorDto.cs
  31. 18
      backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs
  32. 5
      backend/src/Squidex/Areas/Api/Controllers/Comments/Models/CommentDto.cs
  33. 9
      backend/src/Squidex/Areas/Api/Controllers/Comments/Models/UpsertCommentDto.cs
  34. 102
      backend/src/Squidex/Areas/Api/Controllers/Comments/Notifications/UserNotificationsController.cs
  35. 4
      backend/src/Squidex/Config/Domain/CommandsServices.cs
  36. 0
      backend/src/Squidex/wwwroot/images/dashboard-feedback.svg
  37. 10
      backend/src/Squidex/wwwroot/images/dashboard_schema.svg
  38. 180
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsCommandMiddlewareTests.cs
  39. 79
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsGrainTests.cs
  40. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsLoaderTests.cs
  41. 47
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/Guards/GuardCommentsTests.cs
  42. 20
      backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs
  43. 42
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/EventStoreTests.cs
  44. 8
      frontend/app/app.module.ts
  45. 6
      frontend/app/features/settings/pages/contributors/contributor-add-form.component.ts
  46. 6
      frontend/app/framework/angular/forms/confirm-click.directive.ts
  47. 6
      frontend/app/framework/angular/http/loading.interceptor.ts
  48. 64
      frontend/app/framework/angular/modals/modal.directive.ts
  49. 2
      frontend/app/shared/components/asset-uploader.component.html
  50. 18
      frontend/app/shared/components/asset-uploader.component.scss
  51. 7
      frontend/app/shared/components/asset-uploader.component.ts
  52. 17
      frontend/app/shared/components/comment.component.html
  53. 31
      frontend/app/shared/components/comment.component.scss
  54. 20
      frontend/app/shared/components/comment.component.ts
  55. 14
      frontend/app/shared/components/comments.component.html
  56. 11
      frontend/app/shared/components/comments.component.scss
  57. 28
      frontend/app/shared/components/comments.component.ts
  58. 2
      frontend/app/shared/module.ts
  59. 23
      frontend/app/shared/services/comments.service.spec.ts
  60. 31
      frontend/app/shared/services/comments.service.ts
  61. 3
      frontend/app/shared/services/contributors.service.spec.ts
  62. 2
      frontend/app/shared/services/contributors.service.ts
  63. 48
      frontend/app/shared/state/comments.state.spec.ts
  64. 30
      frontend/app/shared/state/comments.state.ts
  65. 1
      frontend/app/shell/declarations.ts
  66. 2
      frontend/app/shell/module.ts
  67. 4
      frontend/app/shell/pages/internal/internal-area.component.html
  68. 28
      frontend/app/shell/pages/internal/notifications-menu.component.html
  69. 13
      frontend/app/shell/pages/internal/notifications-menu.component.scss
  70. 110
      frontend/app/shell/pages/internal/notifications-menu.component.ts
  71. 4
      frontend/app/shell/pages/internal/profile-menu.component.html
  72. 22
      frontend/app/shell/pages/internal/profile-menu.component.scss
  73. 2
      frontend/app/theme/_bootstrap-vars.scss
  74. 30
      frontend/app/theme/_bootstrap.scss
  75. 8
      frontend/package-lock.json
  76. 1
      frontend/package.json

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

12
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<Guid> AppId { get; set; }
public Guid CommentId { get; set; }
Guid IAggregateCommand.AggregateId
{
get { return CommentsId; }
}
public NamedId<Guid> AppId { get; set; }
}
}

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

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

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

113
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<Task> 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<ICommentsGrain>(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<string>();
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();
}
}
}
}
}
}

123
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<CommentsState>, ICommentsGrain
public sealed class CommentsGrain : GrainOfString, ICommentsGrain
{
private readonly IStore<Guid> store;
private readonly List<Envelope<CommentsEvent>> uncommittedEvents = new List<Envelope<CommentsEvent>>();
private readonly List<Envelope<CommentsEvent>> events = new List<Envelope<CommentsEvent>>();
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<Guid> 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<IEvent> @event)
protected override async Task OnActivateAsync(string key)
{
snapshot = new CommentsState { Version = snapshot.Version + 1 };
streamName = $"comments-{key}";
events.Add(@event.To<CommentsEvent>());
}
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<CommentsEvent>());
}
}
protected override async Task WriteAsync(Envelope<IEvent>[] newEvents, long previousVersion)
public async Task<J<object>> ExecuteAsync(J<CommentsCommand> command)
{
if (newEvents.Length > 0)
{
await persistence.WriteEventsAsync(newEvents);
}
var result = await ExecuteAsync(command.Value);
return result.AsJ();
}
protected override Task<object?> ExecuteAsync(IAggregateCommand command)
private Task<object> 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<object> Upsert<TCommand>(TCommand command, Func<TCommand, object> 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<Envelope<CommentsEvent>> GetUncommittedEvents()
{
return uncommittedEvents;
}
public Task<CommentsResult> GetCommentsAsync(long version = EtagVersion.Any)
{
return Task.FromResult(CommentsResult.FromEvents(events, Version, (int)version));

3
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<CommentsResult> GetCommentsAsync(Guid id, long version = EtagVersion.Any)
public Task<CommentsResult> GetCommentsAsync(string id, long version = EtagVersion.Any)
{
var grain = grainFactory.GetGrain<ICommentsGrain>(id);

6
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))
{

12
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<Envelope<CommentsEvent>> events, UpdateComment command)
public static void CanUpdate(string commentsId, List<Envelope<CommentsEvent>> 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<Envelope<CommentsEvent>> events, DeleteComment command)
public static void CanDelete(string commentsId, List<Envelope<CommentsEvent>> 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.");
}
}

8
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<J<object>> ExecuteAsync(J<CommentsCommand> command);
Task<CommentsResult> GetCommentsAsync(long version = EtagVersion.Any);
}
}

3
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<CommentsResult> GetCommentsAsync(Guid id, long version = EtagVersion.Any);
Task<CommentsResult> GetCommentsAsync(string id, long version = EtagVersion.Any);
}
}

19
backend/src/Squidex.Domain.Apps.Entities/Comments/State/CommentsState.cs

@ -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<CommentsState>
{
public override CommentsState Apply(Envelope<IEvent> @event)
{
return this;
}
}
}

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

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

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

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

2
backend/src/Squidex.Infrastructure.Azure/EventSourcing/CosmosDbEventStore.cs

@ -83,7 +83,7 @@ namespace Squidex.Infrastructure.EventSourcing
{
Paths = new Collection<string>
{
"/PartitionId"
"/id"
}
},
Id = Constants.LeaseCollection

51
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<StoredEvent> EmptyEvents = new List<StoredEvent>();
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<IReadOnlyList<StoredEvent>> QueryLatestAsync(string streamName, int count)
{
Guard.NotNullOrEmpty(streamName);
ThrowIfDisposed();
if (count <= 0)
{
return EmptyEvents;
}
using (Profiler.TraceMethod<CosmosDbEventStore>())
{
var query = FilterBuilder.ByStreamNameDesc(streamName, count);
var result = new List<StoredEvent>();
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<StoredEvent> ordered = result.OrderBy(x => x.EventStreamNumber);
if (result.Count > count)
{
ordered = ordered.Skip(result.Count - count);
}
return ordered.ToList();
}
}
public async Task<IReadOnlyList<StoredEvent>> QueryAsync(string streamName, long streamPosition = 0)
{
Guard.NotNullOrEmpty(streamName);

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

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

7
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<T> FirstOrDefaultAsync<T>(this IQueryable<T> 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<CosmosDbEventCommit, Task> handler, CancellationToken ct = default)
{
var query = documentClient.CreateDocumentQuery<CosmosDbEventCommit>(collectionUri, querySpec);
var query = documentClient.CreateDocumentQuery<CosmosDbEventCommit>(collectionUri, querySpec, CrossPartition);
return query.QueryAsync(handler, ct);
}

46
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<StoredEvent> EmptyEvents = new List<StoredEvent>();
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<IReadOnlyList<StoredEvent>> QueryLatestAsync(string streamName, int count)
{
Guard.NotNullOrEmpty(streamName);
if (count <= 0)
{
return EmptyEvents;
}
using (Profiler.TraceMethod<GetEventStore>())
{
var result = new List<StoredEvent>();
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<StoredEvent> ordered = result.OrderBy(x => x.EventStreamNumber);
if (result.Count > count)
{
ordered = ordered.Skip(result.Count - count);
}
return ordered.ToList();
}
}
public async Task<IReadOnlyList<StoredEvent>> QueryAsync(string streamName, long streamPosition = 0)
{
Guard.NotNullOrEmpty(streamName);

8
backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore.cs

@ -50,8 +50,12 @@ namespace Squidex.Infrastructure.EventSourcing
{
new CreateIndexModel<MongoEventCommit>(
Index
.Ascending(x => x.Timestamp)
.Ascending(x => x.EventStream)),
.Ascending(x => x.EventStream)
.Ascending(x => x.Timestamp)),
new CreateIndexModel<MongoEventCommit>(
Index
.Ascending(x => x.EventStream)
.Descending(x => x.Timestamp)),
new CreateIndexModel<MongoEventCommit>(
Index
.Ascending(x => x.EventStream)

50
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<MongoEventCommit>, IEventStore
{
private static readonly List<StoredEvent> EmptyEvents = new List<StoredEvent>();
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<IReadOnlyList<StoredEvent>> QueryLatestAsync(string streamName, int count)
{
Guard.NotNullOrEmpty(streamName);
if (count <= 0)
{
return EmptyEvents;
}
using (Profiler.TraceMethod<MongoEventStore>())
{
var commits =
await Collection.Find(
Filter.Eq(EventStreamField, streamName))
.Sort(Sort.Descending(TimestampField)).Limit(count).ToListAsync();
var result = new List<StoredEvent>();
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<StoredEvent> ordered = result.OrderBy(x => x.EventStreamNumber);
if (result.Count > count)
{
ordered = ordered.Skip(result.Count - count);
}
return ordered.ToList();
}
}
public async Task<IReadOnlyList<StoredEvent>> QueryAsync(string streamName, long streamPosition = 0)
{
Guard.NotNullOrEmpty(streamName);

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

2
backend/src/Squidex.Infrastructure/EventSourcing/IEventStore.cs

@ -16,6 +16,8 @@ namespace Squidex.Infrastructure.EventSourcing
{
Task CreateIndexAsync(string property);
Task<IReadOnlyList<StoredEvent>> QueryLatestAsync(string streamName, int count);
Task<IReadOnlyList<StoredEvent>> QueryAsync(string streamName, long streamPosition = 0);
Task QueryAsync(Func<StoredEvent, Task> callback, string? streamFilter = null, string? position = null, CancellationToken ct = default);

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

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

7
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; }
/// <summary>
/// The email address.
/// </summary>
[Required]
public string ContributorEmail { get; set; }
/// <summary>
/// The role of the contributor.
/// </summary>
@ -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
{

18
backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs

@ -20,7 +20,7 @@ using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Comments
{
/// <summary>
/// Manages comments for any kind of resource.
/// Manages comments for any kind of app resource.
/// </summary>
[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<IActionResult> GetComments(string app, Guid commentsId, [FromQuery] long version = EtagVersion.Any)
public async Task<IActionResult> 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
/// </returns>
[HttpPost]
[Route("apps/{app}/comments/{commentsId}")]
[ProducesResponseType(typeof(EntityCreatedDto), 201)]
[ProducesResponseType(typeof(CommentDto), 201)]
[ApiPermission(Permissions.AppCommon)]
[ApiCosts(0)]
public async Task<IActionResult> PostComment(string app, Guid commentsId, [FromBody] UpsertCommentDto request)
public async Task<IActionResult> 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<IActionResult> PutComment(string app, Guid commentsId, Guid commentId, [FromBody] UpsertCommentDto request)
public async Task<IActionResult> 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<IActionResult> DeleteComment(string app, Guid commentsId, Guid commentId)
public async Task<IActionResult> 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();
}

5
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; }
/// <summary>
/// The url where the comment is created.
/// </summary>
public Uri? Url { get; set; }
public static CommentDto FromComment(Comment comment)
{
return SimpleMapper.Map(comment, new CommentDto());

9
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)
/// <summary>
/// The url where the comment is created.
/// </summary>
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 });
}

102
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
{
/// <summary>
/// Manages user notifications.
/// </summary>
[ApiExplorerSettings(GroupName = nameof(Notifications))]
public sealed class UserNotificationsController : ApiController
{
private static readonly NamedId<Guid> NoApp = NamedId.Of(Guid.Empty, "none");
private readonly ICommentsLoader commentsLoader;
public UserNotificationsController(ICommandBus commandBus, ICommentsLoader commentsLoader)
: base(commandBus)
{
this.commentsLoader = commentsLoader;
}
/// <summary>
/// Get all notifications.
/// </summary>
/// <param name="userId">The user id.</param>
/// <param name="version">The current version.</param>
/// <remarks>
/// When passing in a version you can retrieve all updates since then.
/// </remarks>
/// <returns>
/// 200 => All comments returned.
/// </returns>
[HttpGet]
[Route("users/{userId}/notifications")]
[ProducesResponseType(typeof(CommentsDto), 200)]
[ApiPermission]
public async Task<IActionResult> 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);
}
/// <summary>
/// Deletes the notification.
/// </summary>
/// <param name="userId">The user id.</param>
/// <param name="commentId">The id of the comment.</param>
/// <returns>
/// 204 => Comment deleted.
/// 404 => Comment not found.
/// </returns>
[HttpDelete]
[Route("users/{userId}/notifications/{commentId}")]
[ApiPermission]
public async Task<IActionResult> 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.");
}
}
}
}

4
backend/src/Squidex/Config/Domain/CommandsServices.cs

@ -84,10 +84,10 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<RuleCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<GrainCommandMiddleware<AssetFolderCommand, IAssetFolderGrain>>()
services.AddSingletonAs<CommentsCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<GrainCommandMiddleware<CommentsCommand, ICommentsGrain>>()
services.AddSingletonAs<GrainCommandMiddleware<AssetFolderCommand, IAssetFolderGrain>>()
.As<ICommandMiddleware>();
services.AddSingletonAs<GrainCommandMiddleware<SchemaCommand, ISchemaGrain>>()

0
backend/src/Squidex/wwwroot/images/dashboard_feedback.svg → backend/src/Squidex/wwwroot/images/dashboard-feedback.svg

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

10
backend/src/Squidex/wwwroot/images/dashboard_schema.svg

@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" x="0" y="0" version="1.1" xml:space="preserve" viewBox="0 0 86 64">
<path id="path36" fill="#c7e0ff" stroke="#2e3842" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M54.2 63H11.8C5.9 63 1 58.1 1 52.2V28.8C1 22.9 5.9 18 11.8 18h42.4C60.1 18 65 22.9 65 28.8v23.4C65 58.1 60.1 63 54.2 63z"/>
<path id="line42" fill="none" stroke="#7d878e" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M12.1 12h42.6"/>
<path id="line84" fill="none" stroke="#b4bcc1" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M18.1 6h30.6"/>
<path id="path86" fill="none" stroke="#eef6ff" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M54.2 61H11.9c-4.9 0-8.8-4-8.8-8.8V28.9c0-4.9 4-8.8 8.8-8.8h42.3c4.9 0 8.8 4 8.8 8.8v23.3c0 4.8-4 8.8-8.8 8.8z"/>
<path id="path88" fill="#3389ff" d="M54.7 23v14.1c0 2.7-2.2 4.9-4.9 4.9H16.2c-2.7 0-4.9-2.2-4.9-4.9V23h-2v14.1c0 3.8 3.1 6.9 6.9 6.9h33.6c3.8 0 6.9-3.1 6.9-6.9V23z" opacity=".2"/>
<circle id="circle122" cx="74.9" cy="11.1" r="11.1" fill="#3389ff"/>
<path id="line124" fill="none" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M70 11h10" class="st19"/>
<path id="line126" fill="none" stroke="#fff" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M75 16V6" class="st19"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

180
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<IGrainFactory>();
private readonly IUserResolver userResolver = A.Fake<IUserResolver>();
private readonly ICommandBus commandBus = A.Fake<ICommandBus>();
private readonly RefToken actor = new RefToken(RefTokenType.Subject, "me");
private readonly NamedId<Guid> 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<string>.Ignored))
.Returns(Task.FromResult<IUser?>(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<ICommentsGrain>();
var result = "Completed";
A.CallTo(() => grainFactory.GetGrain<ICommentsGrain>(commentsId.ToString(), null))
.Returns(grain);
A.CallTo(() => grain.ExecuteAsync(A<J<CommentsCommand>>.That.Matches(x => x.Value == command)))
.Returns(new J<object>(result));
var isNextCalled = false;
await sut.HandleAsync(context, () =>
{
isNextCalled = true;
return TaskHelper.Done;
});
Assert.True(isNextCalled);
A.CallTo(() => grain.ExecuteAsync(A<J<CommentsCommand>>.That.Matches(x => x.Value == command)))
.Returns(new J<object>(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<ICommand>.That.Matches(x => IsForUser(x, "id1"))))
.MustHaveHappened();
A.CallTo(() => commandBus.PublishAsync(A<ICommand>.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<string>.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<string>.Ignored))
.MustNotHaveHappened();
}
protected CommandContext CreateContextForCommand<TCommand>(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<IUser>();
A.CallTo(() => user.Id).Returns(id);
A.CallTo(() => user.Email).Returns(email);
A.CallTo(() => userResolver.FindByIdOrEmailAsync(email))
.Returns(user);
}
protected T CreateCommentsCommand<T>(T command) where T : CommentsCommand
{
command.Actor = actor;
command.AppId = appId;
command.CommentsId = commentsId.ToString();
command.CommentId = commentId;
return command;
}
}
}

79
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<CommentsState>
public class CommentsGrainTests
{
private readonly IEventStore eventStore = A.Fake<IEventStore>();
private readonly IEventDataFormatter eventDataFormatter = A.Fake<IEventDataFormatter>();
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<Envelope<IEvent>> LastEvents { get; private set; } = Enumerable.Empty<Envelope<IEvent>>();
public CommentsGrainTests()
{
sut = new CommentsGrain(Store, A.Dummy<ISemanticLog>());
A.CallTo(() => eventStore.AppendAsync(A<Guid>.Ignored, A<string>.Ignored, A<long>.Ignored, A<ICollection<EventData>>.Ignored))
.Invokes(x => LastEvents = sut.GetUncommittedEvents().Select(x => x.To<IEvent>()).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<Comment>
{
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<Comment>
{
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<Comment>
{
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<Guid>
{
deleteCommand.CommentId
commentId
},
Version = 2
});
@ -127,29 +133,48 @@ namespace Squidex.Domain.Apps.Entities.Comments
{
DeletedComments = new List<Guid>
{
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>(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>(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();
}
}
}

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

47
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<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
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<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
Assert.Throws<DomainException>(() => GuardComments.CanUpdate(events, command));
Assert.Throws<DomainException>(() => GuardComments.CanUpdate(commentsId, events, command));
}
[Fact]
@ -76,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards
var events = new List<Envelope<CommentsEvent>>();
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanUpdate(events, command));
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanUpdate(commentsId, events, command));
}
[Fact]
@ -91,7 +92,21 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards
Envelope.Create<CommentsEvent>(new CommentDeleted { CommentId = commentId }).To<CommentsEvent>()
};
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanUpdate(events, command));
Assert.Throws<DomainObjectNotFoundException>(() => 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<CommentsEvent>>
{
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
GuardComments.CanUpdate(user1.Identifier, events, command);
}
[Fact]
@ -105,7 +120,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
GuardComments.CanUpdate(events, command);
GuardComments.CanUpdate(commentsId, events, command);
}
[Fact]
@ -119,7 +134,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
Assert.Throws<DomainException>(() => GuardComments.CanDelete(events, command));
Assert.Throws<DomainException>(() => GuardComments.CanDelete(commentsId, events, command));
}
[Fact]
@ -130,7 +145,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards
var events = new List<Envelope<CommentsEvent>>();
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanDelete(events, command));
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanDelete(commentsId, events, command));
}
[Fact]
@ -145,7 +160,21 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards
Envelope.Create<CommentsEvent>(new CommentDeleted { CommentId = commentId })
};
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanDelete(events, command));
Assert.Throws<DomainObjectNotFoundException>(() => 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<CommentsEvent>>
{
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
GuardComments.CanDelete(user1.Identifier, events, command);
}
[Fact]
@ -159,7 +188,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
GuardComments.CanDelete(events, command);
GuardComments.CanDelete(commentsId, events, command);
}
}
}

20
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<TState>
{
private readonly IStore<Guid> store = A.Fake<IStore<Guid>>();
private readonly IPersistence<TState> persistence1 = A.Fake<IPersistence<TState>>();
private readonly IPersistence persistence2 = A.Fake<IPersistence>();
private readonly IPersistence<TState> persistenceWithState = A.Fake<IPersistence<TState>>();
private readonly IPersistence persistence = A.Fake<IPersistence>();
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<Type>.Ignored, Id, A<HandleSnapshot<TState>>.Ignored, A<HandleEvent>.Ignored))
.Returns(persistence1);
.Returns(persistenceWithState);
A.CallTo(() => store.WithEventSourcing(A<Type>.Ignored, Id, A<HandleEvent>.Ignored))
.Returns(persistence2);
.Returns(persistence);
A.CallTo(() => persistence1.WriteEventsAsync(A<IEnumerable<Envelope<IEvent>>>.Ignored))
A.CallTo(() => persistenceWithState.WriteEventsAsync(A<IEnumerable<Envelope<IEvent>>>.Ignored))
.Invokes((IEnumerable<Envelope<IEvent>> events) => LastEvents = events);
A.CallTo(() => persistence2.WriteEventsAsync(A<IEnumerable<Envelope<IEvent>>>.Ignored))
A.CallTo(() => persistence.WriteEventsAsync(A<IEnumerable<Envelope<IEvent>>>.Ignored))
.Invokes((IEnumerable<Envelope<IEvent>> events) => LastEvents = events);
}
@ -136,6 +134,4 @@ namespace Squidex.Domain.Apps.Entities.TestHelpers
}
}
}
}
#pragma warning restore IDE0019 // Use pattern matching
}

42
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<EventData>();
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()
{

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

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

6
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) {

6
frontend/app/framework/angular/http/loading.interceptor.ts

@ -22,6 +22,12 @@ export class LoadingInterceptor implements HttpInterceptor {
public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
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(() => {

64
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<any> | null = null;
private renderRoots: ReadonlyArray<HTMLElement> | 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);
}

2
frontend/app/shared/components/asset-uploader.component.html

@ -1,7 +1,7 @@
<ng-container *ngIf="appsState.selectedAppOrNull | async; let app">
<ng-container *ngIf="app.canUploadAssets">
<ul class="nav navbar-nav" *ngIf="assetUploader.uploads | async; let uploads" (sqxDropFile)="addFiles($event)">
<li class="nav-item dropdown">
<li class="nav-item nav-icon dropdown">
<span class="nav-link dropdown-toggle" (click)="modalMenu.toggle()">
<i class="icon-upload-3"></i>

18
frontend/app/shared/components/asset-uploader.component.scss

@ -1,24 +1,6 @@
@import '_vars';
@import '_mixins';
.nav {
& {
padding-right: .5rem;
}
.nav-item {
& {
line-height: 2rem;
}
.nav-link {
color: $color-dark-foreground;
padding-bottom: 0;
padding-top: 0;
}
}
}
.icon-upload-3 {
font-size: 1.4rem;
font-weight: lighter;

7
frontend/app/shared/components/asset-uploader.component.ts

@ -10,11 +10,12 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';
import {
AssetsState,
AssetUploaderState,
DialogModel,
fadeAnimation,
ModalModel,
Upload
} from '@app/shared/internal';
import { AppsState } from '../state/apps.state';
import { AppsState } from './../state/apps.state';
@Component({
selector: 'sqx-asset-uploader',
@ -26,7 +27,7 @@ import { AppsState } from '../state/apps.state';
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AssetUploaderComponent {
public modalMenu = new DialogModel();
public modalMenu = new ModalModel();
constructor(
public readonly appsState: AppsState,

17
frontend/app/shared/components/comment.component.html

@ -7,13 +7,24 @@
<div class="user-row">
<div class="user-ref">{{comment.user | sqxUserNameRef}}</div>
<button *ngIf="comment.user === userId" type="button" class="btn btn-sm btn-text-danger item-remove" (click)="emitDelete()!">
<button *ngIf="comment.user === userToken || canDelete" type="button" class="btn btn-sm btn-text-danger item-remove"
(sqxConfirmClick)="emitDelete()"
confirmTitle="Delete comment"
confirmText="Do you really want to delete the comment?"
[confirmRequired]="confirmDelete">
<i class="icon-bin2"></i>
</button>
</div>
<div>{{comment.text}}</div>
<div class="comment-created text-muted">{{comment.time | sqxFromNow}}</div>
<div [innerHTML]="comment.text | sqxMarkdown"></div>
<div class="comment-created text-muted">
<ng-container *ngIf="canFollow && comment.url">
<a [routerLink]="comment.url">Follow</a>&nbsp;
</ng-container>
{{comment.time | sqxFromNow}}
</div>
</div>
</div>
</div>

31
frontend/app/shared/components/comment.component.scss

@ -1,31 +1,30 @@
@import '_vars';
@import '_mixins';
.item-remove {
@include absolute(-5px, -15px, auto, auto);
display: none;
}
.user-ref {
font-weight: bold;
}
.item-remove {
@include absolute(-5px, -15px, auto, auto);
display: none;
.user-picture {
margin-top: .25rem;
}
.user-row {
& {
position: relative;
}
&:hover {
.item-remove {
display: block;
}
}
}
.comment {
& {
font-size: .9rem;
font-weight: normal;
line-height: 1.25rem;
margin-bottom: .75rem;
}
@ -36,4 +35,18 @@
&-created {
font-size: .75rem;
}
&:hover {
.item-remove {
display: block;
}
}
}
:host ::ng-deep {
p {
&:last-child {
margin-bottom: 0;
}
}
}

20
frontend/app/shared/components/comment.component.ts

@ -6,9 +6,8 @@
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { CommentDto, UpsertCommentForm } from '@app/shared/internal';
import { CommentDto } from '@app/shared/internal';
@Component({
selector: 'sqx-comment',
@ -20,21 +19,20 @@ export class CommentComponent {
@Output()
public delete = new EventEmitter();
@Output()
public update = new EventEmitter<string>();
@Input()
public canDelete = false;
@Input()
public comment: CommentDto;
public canFollow = false;
@Input()
public userId: string;
public confirmDelete = true;
public editForm = new UpsertCommentForm(this.formBuilder);
@Input()
public comment: CommentDto;
constructor(
private readonly formBuilder: FormBuilder
) {
}
@Input()
public userToken: string;
public emitDelete() {
this.delete.emit();

14
frontend/app/shared/components/comments.component.html

@ -7,19 +7,21 @@
<div class="comments-list" #scrollMe [scrollTop]="scrollMe.scrollHeight">
<sqx-comment *ngFor="let comment of commentsState.comments | async; trackBy: trackByComment"
[comment]="comment"
[userId]="userId"
(update)="update(comment, $event)"
(delete)="delete(comment)">
[canDelete]="true"
[canFollow]="false"
(delete)="delete(comment)"
[userToken]="userToken">
</sqx-comment>
</div>
<div class="comments-footer">
<form [formGroup]="commentForm.form" (ngSubmit)="comment()">
<input class="form-control" name="text" formControlName="text" placeholder="Create a comment"
<input class="form-control" name="text" formControlName="text" placeholder="Create a comment"
[mention]="mentionUsers | async"
[mentionConfig]="mentionConfig"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
/>
autocapitalize="off" />
</form>
</div>
</ng-container>

11
frontend/app/shared/components/comments.component.scss

@ -1,6 +1,16 @@
@import '_vars';
@import '_mixins';
:host ::ng-deep {
.mention-menu {
border-color: $color-border !important;
}
.mention-active > a {
background: $color-theme-blue !important;
}
}
.comments {
&-list {
flex-grow: 1;
@ -11,6 +21,7 @@
}
&-footer {
border: 0;
border-top: 1px solid $color-border;
flex-shrink: 0;
}

28
frontend/app/shared/components/comments.component.ts

@ -7,8 +7,9 @@
import { ChangeDetectorRef, Component, Input, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { Router } from '@angular/router';
import { timer } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { map, onErrorResumeNext, switchMap } from 'rxjs/operators';
import {
AppsState,
@ -16,6 +17,7 @@ import {
CommentDto,
CommentsService,
CommentsState,
ContributorsState,
DialogService,
ResourceOwner,
UpsertCommentForm
@ -30,27 +32,36 @@ export class CommentsComponent extends ResourceOwner implements OnInit {
@Input()
public commentsId: string;
public commentsUrl: string;
public commentsState: CommentsState;
public commentForm = new UpsertCommentForm(this.formBuilder);
public userId: string;
public mentionUsers = this.contributorsState.contributors.pipe(map(x => x.map(c => c.contributorEmail)));
public mentionConfig = { dropUp: true };
public userToken: string;
constructor(authService: AuthService,
private readonly appsState: AppsState,
private readonly commentsService: CommentsService,
private readonly contributorsState: ContributorsState,
private readonly changeDetector: ChangeDetectorRef,
private readonly dialogs: DialogService,
private readonly formBuilder: FormBuilder
private readonly formBuilder: FormBuilder,
private readonly router: Router
) {
super();
this.userId = authService.user!.token;
this.userToken = authService.user!.token;
}
public ngOnInit() {
this.commentsState = new CommentsState(this.appsState, this.commentsId, this.commentsService, this.dialogs);
this.contributorsState.load();
this.commentsUrl = `apps/${this.appsState.appName}/comments/${this.commentsId}`;
this.commentsState = new CommentsState(this.commentsUrl, this.commentsService, this.dialogs);
this.own(timer(0, 4000).pipe(switchMap(() => this.commentsState.load())));
this.own(timer(0, 4000).pipe(switchMap(() => this.commentsState.load(true).pipe(onErrorResumeNext()))));
}
public delete(comment: CommentDto) {
@ -65,11 +76,12 @@ export class CommentsComponent extends ResourceOwner implements OnInit {
const value = this.commentForm.submit();
if (value && value.text && value.text.length > 0) {
this.commentsState.create(value.text);
this.commentForm.submitCompleted();
this.commentsState.create(value.text, this.router.url);
this.changeDetector.detectChanges();
}
this.commentForm.submitCompleted();
}
public trackByComment(index: number, comment: CommentDto) {

2
frontend/app/shared/module.ts

@ -9,6 +9,7 @@ import { DragDropModule } from '@angular/cdk/drag-drop';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { ModuleWithProviders, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { MentionModule } from 'angular-mentions';
import { SqxFrameworkModule } from '@app/framework';
@ -114,6 +115,7 @@ import {
@NgModule({
imports: [
DragDropModule,
MentionModule,
RouterModule,
SqxFrameworkModule
],

23
frontend/app/shared/services/comments.service.spec.ts

@ -41,14 +41,15 @@ describe('CommentsService', () => {
let comments: CommentsDto;
commentsService.getComments('my-app', 'my-comments', new Version('123')).subscribe(result => {
commentsService.getComments('my-comments', new Version('123')).subscribe(result => {
comments = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/comments/my-comments?version=123');
const req = httpMock.expectOne('http://service/p/api/my-comments?version=123');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-Silent')).toBe('1');
req.flush({
createdComments: [{
@ -70,9 +71,9 @@ describe('CommentsService', () => {
expect(comments!).toEqual(
new CommentsDto(
[
new CommentDto('123', DateTime.parseISO_UTC('2016-10-12T10:10'), 'text1', user)
new CommentDto('123', DateTime.parseISO_UTC('2016-10-12T10:10'), 'text1', undefined, user)
], [
new CommentDto('456', DateTime.parseISO_UTC('2017-11-12T12:12'), 'text2', user)
new CommentDto('456', DateTime.parseISO_UTC('2017-11-12T12:12'), 'text2', undefined, user)
], [
'789'
],
@ -87,11 +88,11 @@ describe('CommentsService', () => {
let comment: CommentDto;
commentsService.postComment('my-app', 'my-comments', dto).subscribe(result => {
commentsService.postComment('my-comments', dto).subscribe(result => {
comment = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/comments/my-comments');
const req = httpMock.expectOne('http://service/p/api/my-comments');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
@ -103,7 +104,7 @@ describe('CommentsService', () => {
user: user
});
expect(comment!).toEqual(new CommentDto('123', DateTime.parseISO_UTC('2016-10-12T10:10'), 'text1', user));
expect(comment!).toEqual(new CommentDto('123', DateTime.parseISO_UTC('2016-10-12T10:10'), 'text1', undefined, user));
}));
it('should make put request to replace comment content',
@ -111,9 +112,9 @@ describe('CommentsService', () => {
const dto = { text: 'text1' };
commentsService.putComment('my-app', 'my-comments', '123', dto).subscribe();
commentsService.putComment('my-comments', '123', dto).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/comments/my-comments/123');
const req = httpMock.expectOne('http://service/p/api/my-comments/123');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBeNull();
@ -124,9 +125,9 @@ describe('CommentsService', () => {
it('should make delete request to delete comment',
inject([CommentsService, HttpTestingController], (commentsService: CommentsService, httpMock: HttpTestingController) => {
commentsService.deleteComment('my-app', 'my-comments', '123').subscribe();
commentsService.deleteComment('my-comments', '123').subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/comments/my-comments/123');
const req = httpMock.expectOne('http://service/p/api/my-comments/123');
expect(req.request.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toBeNull();

31
frontend/app/shared/services/comments.service.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { HttpClient } from '@angular/common/http';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@ -34,6 +34,7 @@ export class CommentDto extends Model<CommentDto> {
public readonly id: string,
public readonly time: DateTime,
public readonly text: string,
public readonly url: string | undefined,
public readonly user: string
) {
super();
@ -42,6 +43,7 @@ export class CommentDto extends Model<CommentDto> {
export interface UpsertCommentDto {
readonly text: string;
readonly url?: string;
}
@Injectable()
@ -52,10 +54,16 @@ export class CommentsService {
) {
}
public getComments(appName: string, commentsId: string, version: Version): Observable<CommentsDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/comments/${commentsId}?version=${version.value}`);
public getComments(commentsUrl: string, version: Version): Observable<CommentsDto> {
const url = this.apiUrl.buildUrl(`api/${commentsUrl}?version=${version.value}`);
return this.http.get<any>(url).pipe(
const options = {
headers: new HttpHeaders({
'X-Silent': '1'
})
};
return this.http.get<any>(url, options).pipe(
map(body => {
const comments = new CommentsDto(
body.createdComments.map((item: any) => {
@ -63,6 +71,7 @@ export class CommentsService {
item.id,
DateTime.parseISO_UTC(item.time),
item.text,
item.url,
item.user);
}),
body.updatedComments.map((item: any) => {
@ -70,6 +79,7 @@ export class CommentsService {
item.id,
DateTime.parseISO_UTC(item.time),
item.text,
item.url,
item.user);
}),
body.deletedComments,
@ -81,8 +91,8 @@ export class CommentsService {
pretifyError('Failed to load comments.'));
}
public postComment(appName: string, commentsId: string, dto: UpsertCommentDto): Observable<CommentDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/comments/${commentsId}`);
public postComment(commentsUrl: string, dto: UpsertCommentDto): Observable<CommentDto> {
const url = this.apiUrl.buildUrl(`api/${commentsUrl}`);
return this.http.post<any>(url, dto).pipe(
map(body => {
@ -90,6 +100,7 @@ export class CommentsService {
body.id,
DateTime.parseISO_UTC(body.time),
body.text,
body.url,
body.user);
return comment;
@ -97,15 +108,15 @@ export class CommentsService {
pretifyError('Failed to create comment.'));
}
public putComment(appName: string, commentsId: string, commentId: string, dto: UpsertCommentDto): Observable<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/comments/${commentsId}/${commentId}`);
public putComment(commentsUrl: string, commentId: string, dto: UpsertCommentDto): Observable<any> {
const url = this.apiUrl.buildUrl(`api/${commentsUrl}/${commentId}`);
return this.http.put(url, dto).pipe(
pretifyError('Failed to update comment.'));
}
public deleteComment(appName: string, commentsId: string, commentId: string): Observable<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/comments/${commentsId}/${commentId}`);
public deleteComment(commentsUrl: string, commentId: string): Observable<any> {
const url = this.apiUrl.buildUrl(`api/${commentsUrl}/${commentId}`);
return this.http.delete(url).pipe(
pretifyError('Failed to delete comment.'));

3
frontend/app/shared/services/contributors.service.spec.ts

@ -122,6 +122,7 @@ describe('ContributorsService', () => {
items: ids.map(id => ({
contributorId: `id${id}`,
contributorName: `name${id}`,
contributorEmail: `mail${id}@squidex.io`,
role: id % 2 === 0 ? 'Owner' : 'Developer',
_links: {
update: { method: 'PUT', href: `/contributors/id${id}` }
@ -157,5 +158,5 @@ export function createContributor(id: number) {
update: { method: 'PUT', href: `/contributors/id${id}` }
};
return new ContributorDto(links, `id${id}`, `name${id}`, id % 2 === 0 ? 'Owner' : 'Developer');
return new ContributorDto(links, `id${id}`, `name${id}`, `mail${id}@squidex.io`, id % 2 === 0 ? 'Owner' : 'Developer');
}

2
frontend/app/shared/services/contributors.service.ts

@ -42,6 +42,7 @@ export class ContributorDto {
links: ResourceLinks,
public readonly contributorId: string,
public readonly contributorName: string,
public readonly contributorEmail: string,
public readonly role: string
) {
this._links = links;
@ -114,6 +115,7 @@ function parseContributors(response: any) {
new ContributorDto(item._links,
item.contributorId,
item.contributorName,
item.contributorEmail,
item.role));
const { maxContributors, _links, _meta } = response;

48
frontend/app/shared/state/comments.state.spec.ts

@ -21,17 +21,15 @@ import { TestValues } from './_test-helpers';
describe('CommentsState', () => {
const {
app,
appsState,
creator,
modified
} = TestValues;
const commentsId = 'my-comments';
const commentsUrl = 'my-comments';
const oldComments = new CommentsDto([
new CommentDto('1', modified, 'text1', creator),
new CommentDto('2', modified, 'text2', creator)
new CommentDto('1', modified, 'text1', undefined, creator),
new CommentDto('2', modified, 'text2', undefined, creator)
], [], [], new Version('1'));
let dialogs: IMock<DialogService>;
@ -42,7 +40,7 @@ describe('CommentsState', () => {
dialogs = Mock.ofType<DialogService>();
commentsService = Mock.ofType<CommentsService>();
commentsState = new CommentsState(appsState.object, commentsId, commentsService.object, dialogs.object);
commentsState = new CommentsState(commentsUrl, commentsService.object, dialogs.object);
});
beforeEach(() => {
@ -52,15 +50,15 @@ describe('CommentsState', () => {
describe('Loading', () => {
it('should load and merge comments', () => {
const newComments = new CommentsDto([
new CommentDto('3', modified, 'text3', creator)
new CommentDto('3', modified, 'text3', undefined, creator)
], [
new CommentDto('2', modified, 'text2_2', creator)
new CommentDto('2', modified, 'text2_2', undefined, creator)
], ['1'], new Version('2'));
commentsService.setup(x => x.getComments(app, commentsId, new Version('-1')))
commentsService.setup(x => x.getComments(commentsUrl, new Version('-1')))
.returns(() => of(oldComments)).verifiable();
commentsService.setup(x => x.getComments(app, commentsId, new Version('1')))
commentsService.setup(x => x.getComments(commentsUrl, new Version('1')))
.returns(() => of(newComments)).verifiable();
commentsState.load().subscribe();
@ -68,59 +66,59 @@ describe('CommentsState', () => {
expect(commentsState.snapshot.isLoaded).toBeTruthy();
expect(commentsState.snapshot.comments).toEqual([
new CommentDto('2', modified, 'text2_2', creator),
new CommentDto('3', modified, 'text3', creator)
new CommentDto('2', modified, 'text2_2', undefined, creator),
new CommentDto('3', modified, 'text3', undefined, creator)
]);
});
});
describe('Updates', () => {
beforeEach(() => {
commentsService.setup(x => x.getComments(app, commentsId, new Version('-1')))
commentsService.setup(x => x.getComments(commentsUrl, new Version('-1')))
.returns(() => of(oldComments)).verifiable();
commentsState.load().subscribe();
});
it('should add comment to snapshot when created', () => {
const newComment = new CommentDto('3', modified, 'text3', creator);
const newComment = new CommentDto('3', modified, 'text3', undefined, creator);
const request = { text: 'text3' };
const request = { text: 'text3', url: 'url3' };
commentsService.setup(x => x.postComment(app, commentsId, request))
commentsService.setup(x => x.postComment(commentsUrl, request))
.returns(() => of(newComment)).verifiable();
commentsState.create('text3').subscribe();
commentsState.create('text3', 'url3').subscribe();
expect(commentsState.snapshot.comments).toEqual([
new CommentDto('1', modified, 'text1', creator),
new CommentDto('2', modified, 'text2', creator),
new CommentDto('3', modified, 'text3', creator)
new CommentDto('1', modified, 'text1', undefined, creator),
new CommentDto('2', modified, 'text2', undefined, creator),
new CommentDto('3', modified, 'text3', undefined, creator)
]);
});
it('should update properties when updated', () => {
const request = { text: 'text2_2' };
commentsService.setup(x => x.putComment(app, commentsId, '2', request))
commentsService.setup(x => x.putComment(commentsUrl, '2', request))
.returns(() => of({})).verifiable();
commentsState.update(oldComments.createdComments[1], 'text2_2', modified).subscribe();
expect(commentsState.snapshot.comments).toEqual([
new CommentDto('1', modified, 'text1', creator),
new CommentDto('2', modified, 'text2_2', creator)
new CommentDto('1', modified, 'text1', undefined, creator),
new CommentDto('2', modified, 'text2_2', undefined, creator)
]);
});
it('should remove comment from snapshot when deleted', () => {
commentsService.setup(x => x.deleteComment(app, commentsId, '2'))
commentsService.setup(x => x.deleteComment(commentsUrl, '2'))
.returns(() => of({})).verifiable();
commentsState.delete(oldComments.createdComments[1]).subscribe();
expect(commentsState.snapshot.comments).toEqual([
new CommentDto('1', modified, 'text1', creator)
new CommentDto('1', modified, 'text1', undefined, creator)
]);
});
});

30
frontend/app/shared/state/comments.state.ts

@ -17,7 +17,6 @@ import {
} from '@app/framework';
import { CommentDto, CommentsService } from './../services/comments.service';
import { AppsState } from './apps.state';
interface Snapshot {
// The current comments.
@ -39,17 +38,20 @@ export class CommentsState extends State<Snapshot> {
public isLoaded =
this.project(x => x.isLoaded === true);
public versionNumber =
this.project(x => parseInt(x.version.value, 10));
constructor(
private readonly appsState: AppsState,
private readonly commentsId: string,
private readonly commentsUrl: string,
private readonly commentsService: CommentsService,
private readonly dialogs: DialogService
private readonly dialogs: DialogService,
initialVersion = -1
) {
super({ comments: [], version: new Version('-1') });
super({ comments: [], version: new Version(initialVersion.toString()) });
}
public load(): Observable<any> {
return this.commentsService.getComments(this.appName, this.commentsId, this.version).pipe(
public load(silent = false): Observable<any> {
return this.commentsService.getComments(this.commentsUrl, this.version).pipe(
tap(payload => {
this.next(s => {
let comments = s.comments;
@ -71,11 +73,11 @@ export class CommentsState extends State<Snapshot> {
return { ...s, comments, isLoaded: true, version: payload.version };
});
}),
shareSubscribed(this.dialogs));
shareSubscribed(this.dialogs, { silent }));
}
public create(text: string): Observable<CommentDto> {
return this.commentsService.postComment(this.appName, this.commentsId, { text }).pipe(
public create(text: string, url?: string): Observable<CommentDto> {
return this.commentsService.postComment(this.commentsUrl, { text, url }).pipe(
tap(created => {
this.next(s => {
const comments = [...s.comments, created];
@ -87,7 +89,7 @@ export class CommentsState extends State<Snapshot> {
}
public delete(comment: CommentDto): Observable<any> {
return this.commentsService.deleteComment(this.appName, this.commentsId, comment.id).pipe(
return this.commentsService.deleteComment(this.commentsUrl, comment.id).pipe(
tap(() => {
this.next(s => {
const comments = s.comments.removeBy('id', comment);
@ -99,7 +101,7 @@ export class CommentsState extends State<Snapshot> {
}
public update(comment: CommentDto, text: string, now?: DateTime): Observable<CommentDto> {
return this.commentsService.putComment(this.appName, this.commentsId, comment.id, { text }).pipe(
return this.commentsService.putComment(this.commentsUrl, comment.id, { text }).pipe(
map(() => update(comment, text, now || DateTime.now())),
tap(updated => {
this.next(s => {
@ -114,10 +116,6 @@ export class CommentsState extends State<Snapshot> {
private get version() {
return this.snapshot.version;
}
private get appName() {
return this.appsState.appName;
}
}
const update = (comment: CommentDto, text: string, time: DateTime) =>

1
frontend/app/shell/declarations.ts

@ -12,6 +12,7 @@ export * from './pages/home/home-page.component';
export * from './pages/internal/apps-menu.component';
export * from './pages/internal/internal-area.component';
export * from './pages/internal/logo.component';
export * from './pages/internal/notifications-menu.component';
export * from './pages/internal/profile-menu.component';
export * from './pages/login/login-page.component';
export * from './pages/logout/logout-page.component';

2
frontend/app/shell/module.ts

@ -20,6 +20,7 @@ import {
LogoComponent,
LogoutPageComponent,
NotFoundPageComponent,
NotificationsMenuComponent,
ProfileMenuComponent
} from './declarations';
@ -46,6 +47,7 @@ import {
LogoComponent,
LogoutPageComponent,
NotFoundPageComponent,
NotificationsMenuComponent,
ProfileMenuComponent
]
})

4
frontend/app/shell/pages/internal/internal-area.component.html

@ -11,6 +11,10 @@
<sqx-profile-menu></sqx-profile-menu>
</div>
<div class="float-right notifications-menu">
<sqx-notifications-menu></sqx-notifications-menu>
</div>
<div class="float-right assets-menu">
<sqx-asset-uploader></sqx-asset-uploader>
</div>

28
frontend/app/shell/pages/internal/notifications-menu.component.html

@ -0,0 +1,28 @@
<ul class="nav navbar-nav">
<li class="nav-item nav-icon dropdown">
<span class="nav-link dropdown-toggle" (click)="modalMenu.show()">
<i class="icon-comments"></i>
<span class="badge badge-pill" *ngIf="unread">{{unread}}</span>
</span>
<ng-container *sqxModal="modalMenu;onRoot:false">
<div class="dropdown-menu" #scrollMe [scrollTop]="scrollMe.scrollHeight" @fade>
<ng-container *ngIf="commentsState.comments | async; let comments">
<small class="text-muted" *ngIf="comments.length === 0">
No notifications yet.
</small>
<sqx-comment *ngFor="let comment of comments; trackBy: trackByComment"
[comment]="comment"
[confirmDelete]="false"
[canDelete]="true"
[canFollow]="true"
(delete)="delete(comment)"
[userToken]="userToken">
</sqx-comment>
</ng-container>
</div>
</ng-container>
</li>
</ul>

13
frontend/app/shell/pages/internal/notifications-menu.component.scss

@ -0,0 +1,13 @@
@import '_vars';
@import '_mixins';
.dropdown-menu {
left: auto;
max-height: 500px;
min-height: 5rem;
overflow-y: scroll;
padding: 1.5rem;
padding-bottom: 1rem;
right: 0;
width: 300px;
}

110
frontend/app/shell/pages/internal/notifications-menu.component.ts

@ -0,0 +1,110 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { timer } from 'rxjs';
import { onErrorResumeNext, switchMap, tap } from 'rxjs/operators';
import {
AuthService,
CommentDto,
CommentsService,
CommentsState,
DialogService,
fadeAnimation,
LocalStoreService,
ModalModel,
ResourceOwner
} from '@app/shared';
@Component({
selector: 'sqx-notifications-menu',
styleUrls: ['./notifications-menu.component.scss'],
templateUrl: './notifications-menu.component.html',
animations: [
fadeAnimation
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class NotificationsMenuComponent extends ResourceOwner implements OnInit {
private isOpen: boolean;
private configKey: string;
public modalMenu = new ModalModel();
public commentsUrl: string;
public commentsState: CommentsState;
public userId: string;
public userToken: string;
public versionRead = -1;
public versionReceived = -1;
public get unread() {
return Math.max(0, this.versionReceived - this.versionRead);
}
constructor(authService: AuthService,
private readonly changeDetector: ChangeDetectorRef,
private readonly commentsService: CommentsService,
private readonly dialogs: DialogService,
private readonly localStore: LocalStoreService
) {
super();
this.userToken = authService.user!.token;
this.userId = authService.user!.id;
this.configKey = `users.${this.userId}.notifications`;
this.versionRead = localStore.getInt(this.configKey, -1);
this.versionReceived = this.versionRead;
}
public ngOnInit() {
this.commentsUrl = `users/${this.userId}/notifications`;
this.commentsState = new CommentsState(this.commentsUrl, this.commentsService, this.dialogs);
this.own(
this.modalMenu.isOpen.pipe(
tap(isOpen => {
this.isOpen = isOpen;
this.updateVersion();
})
));
this.own(
this.commentsState.versionNumber.pipe(
tap(version => {
this.versionReceived = version;
this.updateVersion();
this.changeDetector.detectChanges();
})));
this.own(timer(0, 4000).pipe(switchMap(() => this.commentsState.load(true).pipe(onErrorResumeNext()))));
}
public delete(comment: CommentDto) {
this.commentsState.delete(comment);
}
public trackByComment(comment: CommentDto) {
return comment.id;
}
private updateVersion() {
if (this.isOpen) {
this.versionRead = this.versionReceived;
this.localStore.setInt(this.configKey, this.versionRead);
}
}
}

4
frontend/app/shell/pages/internal/profile-menu.component.html

@ -1,10 +1,10 @@
<ul class="nav navbar-nav">
<li class="nav-item nav-item-help">
<li class="nav-item nav-icon nav-item-help">
<a class="nav-link" href="https://squidex.io/help" sqxExternalLink="noicon">
<i class="icon-help2"></i>
</a>
</li>
<li class="nav-item dropdown">
<li class="nav-item nav-icon dropdown">
<span class="nav-link dropdown-toggle" (click)="modalMenu.toggle()">
<span class="user">
<img class="user-picture" [src]="snapshot.profileId | sqxUserIdPicture" />

22
frontend/app/shell/pages/internal/profile-menu.component.scss

@ -1,28 +1,6 @@
@import '_vars';
@import '_mixins';
.nav {
.nav-item {
& {
line-height: 2rem;
}
.nav-link {
color: $color-dark-foreground;
padding-bottom: 0;
padding-top: 0;
}
}
&-item-help {
font-size: 1.4rem;
font-weight: lighter;
padding-right: 1rem;
padding-top: 2px;
vertical-align: middle;
}
}
.user-picture {
margin-right: .5rem;
}

2
frontend/app/theme/_bootstrap-vars.scss

@ -49,6 +49,8 @@ $modal-inner-padding: 1.5rem 1.75rem;
$modal-md: 560px;
$modal-title-line-height: 1.8rem;
$navbar-dark-color: $color-dark-foreground;
$close-font-weight: normal;
$close-font-size: 1rem;
$close-text-shadow: none;

30
frontend/app/theme/_bootstrap.scss

@ -154,6 +154,36 @@ a {
.nav-link {
cursor: pointer;
}
.nav-icon {
& {
line-height: 2rem;
margin-left: .5rem;
margin-right: 0;
position: relative;
vertical-align: middle;
}
.nav-link {
color: $color-dark-foreground;
padding-bottom: 0;
padding-top: 0;
i {
font-size: 1.5rem;
font-weight: lighter;
vertical-align: middle;
}
}
.badge {
@include absolute(-.5rem, auto, auto, -.375rem);
background: $color-theme-error;
font-size: .75rem;
font-weight: normal;
padding: .25rem .5rem;
}
}
}
//

8
frontend/package-lock.json

@ -1776,6 +1776,14 @@
"integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=",
"dev": true
},
"angular-mentions": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/angular-mentions/-/angular-mentions-1.1.3.tgz",
"integrity": "sha512-gTe20SQSS62FLW1dOptskeAfeLni9W3IBzTlki3veM+fbMbLeZpqbel7rfHGT15fymm2gTBcrsRyM/wRfItjHQ==",
"requires": {
"tslib": "^1.9.0"
}
},
"angular2-chartjs": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/angular2-chartjs/-/angular2-chartjs-0.5.1.tgz",

1
frontend/package.json

@ -23,6 +23,7 @@
"@angular/platform-browser-dynamic": "8.2.9",
"@angular/platform-server": "8.2.9",
"@angular/router": "8.2.9",
"angular-mentions": "^1.1.3",
"angular2-chartjs": "0.5.1",
"babel-polyfill": "6.26.0",
"bootstrap": "4.3.1",

Loading…
Cancel
Save