Browse Source

Merge pull request #329 from Squidex/feature-comments

Feature comments
pull/330/head
Sebastian Stehle 7 years ago
committed by GitHub
parent
commit
e21ea2166e
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 32
      src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs
  2. 25
      src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs
  3. 18
      src/Squidex.Domain.Apps.Entities/Comments/Commands/CreateComment.cs
  4. 18
      src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs
  5. 18
      src/Squidex.Domain.Apps.Entities/Comments/Commands/UpdateComment.cs
  6. 126
      src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs
  7. 96
      src/Squidex.Domain.Apps.Entities/Comments/CommentsResult.cs
  8. 88
      src/Squidex.Domain.Apps.Entities/Comments/Guards/GuardComments.cs
  9. 18
      src/Squidex.Domain.Apps.Entities/Comments/ICommentGrain.cs
  10. 13
      src/Squidex.Domain.Apps.Entities/Comments/State/CommentsState.cs
  11. 4
      src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs
  12. 20
      src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs
  13. 18
      src/Squidex.Domain.Apps.Events/Comments/CommentDeleted.cs
  14. 20
      src/Squidex.Domain.Apps.Events/Comments/CommentUpdated.cs
  15. 16
      src/Squidex.Domain.Apps.Events/Comments/CommentsEvent.cs
  16. 51
      src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs
  17. 4
      src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  18. 2
      src/Squidex/Areas/Api/Controllers/Assets/Models/AssetReplacedDto.cs
  19. 2
      src/Squidex/Areas/Api/Controllers/Assets/Models/UpdateAssetDto.cs
  20. 137
      src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs
  21. 53
      src/Squidex/Areas/Api/Controllers/Comments/Models/CommentDto.cs
  22. 48
      src/Squidex/Areas/Api/Controllers/Comments/Models/CommentsDto.cs
  23. 33
      src/Squidex/Areas/Api/Controllers/Comments/Models/UpsertCommentDto.cs
  24. 5
      src/Squidex/Config/Domain/EntitiesServices.cs
  25. 4
      src/Squidex/app/features/administration/pages/users/user-page.component.ts
  26. 1
      src/Squidex/app/features/content/declarations.ts
  27. 6
      src/Squidex/app/features/content/module.ts
  28. 1
      src/Squidex/app/features/content/pages/comments/comments-page.component.html
  29. 2
      src/Squidex/app/features/content/pages/comments/comments-page.component.scss
  30. 30
      src/Squidex/app/features/content/pages/comments/comments-page.component.ts
  31. 2
      src/Squidex/app/features/content/pages/content/content-history.component.html
  32. 2
      src/Squidex/app/features/content/pages/content/content-history.component.ts
  33. 4
      src/Squidex/app/features/content/pages/content/content-page.component.html
  34. 6
      src/Squidex/app/features/content/pages/content/content-page.component.ts
  35. 6
      src/Squidex/app/features/rules/pages/rules/rule-wizard.component.ts
  36. 2
      src/Squidex/app/features/schemas/pages/schema/field-wizard.component.ts
  37. 2
      src/Squidex/app/features/schemas/pages/schema/schema-edit-form.component.ts
  38. 2
      src/Squidex/app/features/schemas/pages/schema/schema-scripts-form.component.ts
  39. 2
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.ts
  40. 2
      src/Squidex/app/framework/angular/forms/validators.ts
  41. 12
      src/Squidex/app/framework/angular/http/http-extensions.ts
  42. 19
      src/Squidex/app/shared/components/comment.component.html
  43. 43
      src/Squidex/app/shared/components/comment.component.scss
  44. 38
      src/Squidex/app/shared/components/comment.component.ts
  45. 26
      src/Squidex/app/shared/components/comments.component.html
  46. 11
      src/Squidex/app/shared/components/comments.component.scss
  47. 80
      src/Squidex/app/shared/components/comments.component.ts
  48. 2
      src/Squidex/app/shared/components/history.component.html
  49. 2
      src/Squidex/app/shared/declarations.ts
  50. 3
      src/Squidex/app/shared/internal.ts
  51. 8
      src/Squidex/app/shared/module.ts
  52. 2
      src/Squidex/app/shared/services/assets.service.ts
  53. 2
      src/Squidex/app/shared/services/auth.service.ts
  54. 152
      src/Squidex/app/shared/services/comments.service.spec.ts
  55. 136
      src/Squidex/app/shared/services/comments.service.ts
  56. 2
      src/Squidex/app/shared/services/contents.service.ts
  57. 2
      src/Squidex/app/shared/services/schemas.service.ts
  58. 4
      src/Squidex/app/shared/state/apps.state.spec.ts
  59. 5
      src/Squidex/app/shared/state/apps.state.ts
  60. 22
      src/Squidex/app/shared/state/comments.form.ts
  61. 119
      src/Squidex/app/shared/state/comments.state.spec.ts
  62. 128
      src/Squidex/app/shared/state/comments.state.ts
  63. 2
      src/Squidex/app/shared/state/contents.state.ts
  64. 2
      src/Squidex/app/shared/state/rules.state.ts
  65. 2
      src/Squidex/app/shared/state/schemas.state.ts
  66. 8
      src/Squidex/app/theme/icomoon/demo-files/demo.css
  67. 324
      src/Squidex/app/theme/icomoon/demo.html
  68. BIN
      src/Squidex/app/theme/icomoon/fonts/icomoon.eot
  69. 1
      src/Squidex/app/theme/icomoon/fonts/icomoon.svg
  70. BIN
      src/Squidex/app/theme/icomoon/fonts/icomoon.ttf
  71. BIN
      src/Squidex/app/theme/icomoon/fonts/icomoon.woff
  72. 2
      src/Squidex/app/theme/icomoon/selection.json
  73. 40
      src/Squidex/app/theme/icomoon/style.css
  74. 162
      tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsGrainTests.cs
  75. 164
      tests/Squidex.Domain.Apps.Entities.Tests/Comments/Guards/GuardCommentsTests.cs

32
src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs

@ -0,0 +1,32 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using NodaTime;
using Squidex.Infrastructure;
using System;
namespace Squidex.Domain.Apps.Core.Comments
{
public sealed class Comment
{
public Guid Id { get; }
public Instant Time { get; }
public RefToken User { get; }
public string Text { get; }
public Comment(Guid id, Instant time, RefToken user, string text)
{
Id = id;
Time = time;
Text = text;
User = user;
}
}
}

25
src/Squidex.Domain.Apps.Entities/Comments/Commands/CommentsCommand.cs

@ -0,0 +1,25 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Comments.Commands
{
public abstract class CommentsCommand : SquidexCommand, IAggregateCommand, IAppCommand
{
public Guid CommentsId { get; set; }
public NamedId<Guid> AppId { get; set; }
Guid IAggregateCommand.AggregateId
{
get { return CommentsId; }
}
}
}

18
src/Squidex.Domain.Apps.Entities/Comments/Commands/CreateComment.cs

@ -0,0 +1,18 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
namespace Squidex.Domain.Apps.Entities.Comments.Commands
{
public sealed class CreateComment : CommentsCommand
{
public Guid CommentId { get; } = Guid.NewGuid();
public string Text { get; set; }
}
}

18
src/Squidex.Domain.Apps.Entities/Comments/Commands/DeleteComment.cs

@ -0,0 +1,18 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// 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; }
}
}

18
src/Squidex.Domain.Apps.Entities/Comments/Commands/UpdateComment.cs

@ -0,0 +1,18 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// 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; }
}
}

126
src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs

@ -0,0 +1,126 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
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.Reflection;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Comments
{
public sealed class CommentsGrain : DomainObjectGrainBase<CommentsState>, ICommentGrain
{
private readonly IStore<Guid> store;
private readonly List<Envelope<CommentsEvent>> events = new List<Envelope<CommentsEvent>>();
private CommentsState snapshot = new CommentsState { Version = EtagVersion.Empty };
private IPersistence persistence;
public override CommentsState Snapshot
{
get { return snapshot; }
}
public CommentsGrain(IStore<Guid> store, ISemanticLog log)
: base(log)
{
Guard.NotNull(store, nameof(store));
this.store = store;
}
protected override void ApplyEvent(Envelope<IEvent> @event)
{
snapshot = new CommentsState { Version = snapshot.Version + 1 };
events.Add(@event.To<CommentsEvent>());
}
protected override void RestorePreviousSnapshot(CommentsState previousSnapshot, long previousVersion)
{
snapshot = previousSnapshot;
}
protected override Task ReadAsync(Type type, Guid id)
{
persistence = store.WithEventSourcing<Guid>(GetType(), id, ApplyEvent);
return persistence.ReadAsync();
}
protected override async Task WriteAsync(Envelope<IEvent>[] events, long previousVersion)
{
if (events.Length > 0)
{
await persistence.WriteEventsAsync(events);
}
}
protected override Task<object> ExecuteAsync(IAggregateCommand command)
{
switch (command)
{
case CreateComment createComment:
return UpsertAsync(createComment, c =>
{
GuardComments.CanCreate(c);
Create(c);
return EntityCreatedResult.Create(createComment.CommentId, Version);
});
case UpdateComment updateComment:
return UpsertAsync(updateComment, c =>
{
GuardComments.CanUpdate(events, c);
Update(c);
});
case DeleteComment deleteComment:
return UpsertAsync(deleteComment, c =>
{
GuardComments.CanDelete(events, c);
Delete(c);
});
default:
throw new NotSupportedException();
}
}
public void Create(CreateComment command)
{
RaiseEvent(SimpleMapper.Map(command, new CommentCreated()));
}
public void Update(UpdateComment command)
{
RaiseEvent(SimpleMapper.Map(command, new CommentUpdated()));
}
public void Delete(DeleteComment command)
{
RaiseEvent(SimpleMapper.Map(command, new CommentDeleted()));
}
public Task<CommentsResult> GetCommentsAsync(long version = EtagVersion.Any)
{
return Task.FromResult(CommentsResult.FromEvents(events, Version, (int)version));
}
}
}

96
src/Squidex.Domain.Apps.Entities/Comments/CommentsResult.cs

@ -0,0 +1,96 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using Squidex.Domain.Apps.Core.Comments;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Entities.Comments
{
public sealed class CommentsResult
{
public List<Comment> CreatedComments { get; set; } = new List<Comment>();
public List<Comment> UpdatedComments { get; set; } = new List<Comment>();
public List<Guid> DeletedComments { get; set; } = new List<Guid>();
public long Version { get; set; }
public static CommentsResult FromEvents(IEnumerable<Envelope<CommentsEvent>> events, long currentVersion, int lastVersion)
{
var result = new CommentsResult { Version = currentVersion };
foreach (var @event in events.Skip(lastVersion < 0 ? 0 : lastVersion + 1))
{
switch (@event.Payload)
{
case CommentDeleted deleted:
{
var id = deleted.CommentId;
if (result.CreatedComments.Any(x => x.Id == id))
{
result.CreatedComments.RemoveAll(x => x.Id == id);
}
else if (result.UpdatedComments.Any(x => x.Id == id))
{
result.UpdatedComments.RemoveAll(x => x.Id == id);
result.DeletedComments.Add(id);
}
else
{
result.DeletedComments.Add(id);
}
break;
}
case CommentCreated created:
{
var comment = new Comment(
created.CommentId,
@event.Headers.Timestamp(),
@event.Payload.Actor,
created.Text);
result.CreatedComments.Add(comment);
break;
}
case CommentUpdated updated:
{
var id = updated.CommentId;
var comment = new Comment(
id,
@event.Headers.Timestamp(),
@event.Payload.Actor,
updated.Text);
if (result.CreatedComments.Any(x => x.Id == id))
{
result.CreatedComments.RemoveAll(x => x.Id == id);
result.CreatedComments.Add(comment);
}
else
{
result.UpdatedComments.Add(comment);
}
break;
}
}
}
return result;
}
}
}

88
src/Squidex.Domain.Apps.Entities/Comments/Guards/GuardComments.cs

@ -0,0 +1,88 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Entities.Comments.Guards
{
public static class GuardComments
{
public static void CanCreate(CreateComment command)
{
Guard.NotNull(command, nameof(command));
Validate.It(() => "Cannot create comment.", e =>
{
if (string.IsNullOrWhiteSpace(command.Text))
{
e("Text is required.", nameof(command.Text));
}
});
}
public static void CanUpdate(List<Envelope<CommentsEvent>> events, UpdateComment command)
{
Guard.NotNull(command, nameof(command));
var comment = FindComment(events, command.CommentId);
if (!comment.Payload.Actor.Equals(command.Actor))
{
throw new DomainException("Comment is created by another actor.");
}
Validate.It(() => "Cannot update comment.", e =>
{
if (string.IsNullOrWhiteSpace(command.Text))
{
e("Text is required.", nameof(command.Text));
}
});
}
public static void CanDelete(List<Envelope<CommentsEvent>> events, DeleteComment command)
{
Guard.NotNull(command, nameof(command));
var comment = FindComment(events, command.CommentId);
if (!comment.Payload.Actor.Equals(command.Actor))
{
throw new DomainException("Comment is created by another actor.");
}
}
private static Envelope<CommentCreated> FindComment(List<Envelope<CommentsEvent>> events, Guid commentId)
{
Envelope<CommentCreated> result = null;
foreach (var @event in events)
{
if (@event.Payload is CommentCreated created && created.CommentId == commentId)
{
result = @event.To<CommentCreated>();
}
else if (@event.Payload is CommentDeleted deleted && deleted.CommentId == commentId)
{
result = null;
}
}
if (result == null)
{
throw new DomainObjectNotFoundException(commentId.ToString(), "Comments", typeof(CommentsGrain));
}
return result;
}
}
}

18
src/Squidex.Domain.Apps.Entities/Comments/ICommentGrain.cs

@ -0,0 +1,18 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Comments
{
public interface ICommentGrain : IDomainObjectGrain
{
Task<CommentsResult> GetCommentsAsync(long version = EtagVersion.Any);
}
}

13
src/Squidex.Domain.Apps.Entities/Comments/State/CommentsState.cs

@ -0,0 +1,13 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Comments.State
{
public sealed class CommentsState : DomainObjectState<CommentsState>
{
}
}

4
src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs

@ -28,8 +28,6 @@ namespace Squidex.Domain.Apps.Entities.Contents
public Instant LastModified { get; set; }
public Status Status { get; set; }
public ScheduleJob ScheduleJob { get; set; }
public RefToken CreatedBy { get; set; }
@ -40,6 +38,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
public NamedContentData DataDraft { get; set; }
public Status Status { get; set; }
public bool IsPending { get; set; }
public static ContentEntity Create(CreateContent command, EntityCreatedResult<NamedContentData> result)

20
src/Squidex.Domain.Apps.Events/Comments/CommentCreated.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Comments
{
[EventType(nameof(CommentCreated))]
public sealed class CommentCreated : CommentsEvent
{
public Guid CommentId { get; set; }
public string Text { get; set; }
}
}

18
src/Squidex.Domain.Apps.Events/Comments/CommentDeleted.cs

@ -0,0 +1,18 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Comments
{
[EventType(nameof(CommentDeleted))]
public sealed class CommentDeleted : CommentsEvent
{
public Guid CommentId { get; set; }
}
}

20
src/Squidex.Domain.Apps.Events/Comments/CommentUpdated.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Comments
{
[EventType(nameof(CommentUpdated))]
public sealed class CommentUpdated : CommentsEvent
{
public Guid CommentId { get; set; }
public string Text { get; set; }
}
}

16
src/Squidex.Domain.Apps.Events/Comments/CommentsEvent.cs

@ -0,0 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
namespace Squidex.Domain.Apps.Events.Comments
{
public abstract class CommentsEvent : AppEvent
{
public Guid CommentsId { get; set; }
}
}

51
src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs

@ -21,6 +21,13 @@ namespace Squidex.Infrastructure.Commands
private readonly ISemanticLog log;
private Guid id;
private enum Mode
{
Create,
Update,
Upsert
}
public Guid Id
{
get { return id; }
@ -81,45 +88,65 @@ namespace Squidex.Infrastructure.Commands
protected Task<object> CreateReturnAsync<TCommand>(TCommand command, Func<TCommand, Task<object>> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler, false);
return InvokeAsync(command, handler, Mode.Create);
}
protected Task<object> CreateReturnAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToAsync(), false);
return InvokeAsync(command, handler?.ToAsync(), Mode.Create);
}
protected Task<object> CreateAsync<TCommand>(TCommand command, Func<TCommand, Task> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler.ToDefault<TCommand, object>(), false);
return InvokeAsync(command, handler.ToDefault<TCommand, object>(), Mode.Create);
}
protected Task<object> CreateAsync<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), false);
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), Mode.Create);
}
protected Task<object> UpdateReturnAsync<TCommand>(TCommand command, Func<TCommand, Task<object>> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler, true);
return InvokeAsync(command, handler, Mode.Update);
}
protected Task<object> UpdateAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToAsync(), true);
return InvokeAsync(command, handler?.ToAsync(), Mode.Update);
}
protected Task<object> UpdateAsync<TCommand>(TCommand command, Func<TCommand, Task> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToDefault<TCommand, object>(), true);
return InvokeAsync(command, handler?.ToDefault<TCommand, object>(), Mode.Update);
}
protected Task<object> UpdateAsync<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), true);
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), Mode.Update);
}
protected Task<object> UpsertReturnAsync<TCommand>(TCommand command, Func<TCommand, Task<object>> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler, Mode.Upsert);
}
protected Task<object> UpsertAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToAsync(), Mode.Upsert);
}
protected Task<object> UpsertAsync<TCommand>(TCommand command, Func<TCommand, Task> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToDefault<TCommand, object>(), Mode.Upsert);
}
protected Task<object> UpsertAsync<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), Mode.Upsert);
}
private async Task<object> InvokeAsync<TCommand>(TCommand command, Func<TCommand, Task<object>> handler, bool isUpdate) where TCommand : class, IAggregateCommand
private async Task<object> InvokeAsync<TCommand>(TCommand command, Func<TCommand, Task<object>> handler, Mode mode) where TCommand : class, IAggregateCommand
{
Guard.NotNull(command, nameof(command));
@ -128,7 +155,7 @@ namespace Squidex.Infrastructure.Commands
throw new DomainObjectVersionException(id.ToString(), GetType(), Version, command.ExpectedVersion);
}
if (isUpdate && Version < 0)
if (mode == Mode.Update && Version < 0)
{
try
{
@ -141,7 +168,7 @@ namespace Squidex.Infrastructure.Commands
throw new DomainObjectNotFoundException(id.ToString(), GetType());
}
if (!isUpdate && Version >= 0)
if (mode == Mode.Create && Version >= 0)
{
throw new DomainException("Object has already been created.");
}
@ -158,7 +185,7 @@ namespace Squidex.Infrastructure.Commands
if (result == null)
{
if (isUpdate)
if (mode == Mode.Update || (mode == Mode.Upsert && Version == 0))
{
result = new EntitySavedResult(Version);
}

4
src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -215,7 +215,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
var context = await CommandBus.PublishAsync(command);
var result = context.Result<AssetSavedResult>();
var response = AssetReplacedDto.Create(command, result);
var response = AssetReplacedDto.FromCommand(command, result);
return StatusCode(201, response);
}
@ -236,7 +236,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
[Route("apps/{app}/assets/{id}/")]
[ProducesResponseType(typeof(ErrorDto), 400)]
[ApiCosts(1)]
public async Task<IActionResult> PutAsset(string app, Guid id, [FromBody] AssetUpdateDto request)
public async Task<IActionResult> PutAsset(string app, Guid id, [FromBody] UpdateAssetDto request)
{
await CommandBus.PublishAsync(request.ToCommand(id));

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

@ -49,7 +49,7 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
/// </summary>
public long Version { get; set; }
public static AssetReplacedDto Create(UpdateAsset command, AssetSavedResult result)
public static AssetReplacedDto FromCommand(UpdateAsset command, AssetSavedResult result)
{
var response = new AssetReplacedDto
{

2
src/Squidex/Areas/Api/Controllers/Assets/Models/AssetUpdateDto.cs → src/Squidex/Areas/Api/Controllers/Assets/Models/UpdateAssetDto.cs

@ -12,7 +12,7 @@ using Squidex.Domain.Apps.Entities.Assets.Commands;
namespace Squidex.Areas.Api.Controllers.Assets.Models
{
public sealed class AssetUpdateDto
public sealed class UpdateAssetDto
{
/// <summary>
/// The new name of the asset.

137
src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs

@ -0,0 +1,137 @@
// ==========================================================================
// 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 Orleans;
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.Pipeline;
namespace Squidex.Areas.Api.Controllers.Comments
{
/// <summary>
/// Manages comments for any kind of resource.
/// </summary>
[ApiAuthorize]
[ApiExceptionFilter]
[AppApi]
[ApiExplorerSettings(GroupName = nameof(Comments))]
public sealed class CommentsController : ApiController
{
private readonly IGrainFactory grainFactory;
public CommentsController(ICommandBus commandBus, IGrainFactory grainFactory)
: base(commandBus)
{
this.grainFactory = grainFactory;
}
/// <summary>
/// Get all comments.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="commentsId">The id of the comments.</param>
/// <returns>
/// 200 => All comments returned.
/// 404 => App not found.
/// </returns>
[HttpGet]
[Route("apps/{app}/comments/{commentsId}")]
[ProducesResponseType(typeof(CommentsDto), 200)]
[ApiCosts(0)]
public async Task<IActionResult> GetComments(string app, Guid commentsId)
{
if (!long.TryParse(Request.Headers["If-None-Match"], out var version))
{
version = EtagVersion.Any;
}
var result = await grainFactory.GetGrain<ICommentGrain>(commentsId).GetCommentsAsync(version);
var response = CommentsDto.FromResult(result);
Response.Headers["ETag"] = response.Version.ToString();
return Ok(response);
}
/// <summary>
/// Create a new comment.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="commentsId">The id of the comments.</param>
/// <param name="request">The comment object that needs to created.</param>
/// <returns>
/// 201 => Comment created.
/// 400 => Comment is not valid.
/// 404 => App not found.
/// </returns>
[HttpPost]
[Route("apps/{app}/comments/{commentsId}")]
[ProducesResponseType(typeof(EntityCreatedDto), 201)]
[ProducesResponseType(typeof(ErrorDto), 400)]
[ApiCosts(0)]
public async Task<IActionResult> PostComment(string app, Guid commentsId, [FromBody] UpsertCommentDto request)
{
var command = request.ToCreateCommand(commentsId);
var context = await CommandBus.PublishAsync(command);
var response = CommentDto.FromCommand(command);
return CreatedAtAction(nameof(GetComments), new { commentsId }, response);
}
/// <summary>
/// Updates the comment.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="commentsId">The id of the comments.</param>
/// <param name="commentId">The id of the comment.</param>
/// <param name="request">The comment object that needs to updated.</param>
/// <returns>
/// 204 => Comment updated.
/// 400 => Comment text not valid.
/// 404 => Comment or app not found.
/// </returns>
[MustBeAppReader]
[HttpPut]
[Route("apps/{app}/comments/{commentsId}/{commentId}")]
[ProducesResponseType(typeof(ErrorDto), 400)]
[ApiCosts(0)]
public async Task<IActionResult> PutComment(string app, Guid commentsId, Guid commentId, [FromBody] UpsertCommentDto request)
{
await CommandBus.PublishAsync(request.ToUpdateComment(commentsId, commentId));
return NoContent();
}
/// <summary>
/// Deletes the comment.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="commentsId">The id of the comments.</param>
/// <param name="commentId">The id of the comment.</param>
/// <returns>
/// 204 => Comment deleted.
/// 404 => Comment or app not found.
/// </returns>
[HttpDelete]
[Route("apps/{app}/comments/{commentsId}/{commentId}")]
[ProducesResponseType(typeof(ErrorDto), 400)]
[ApiCosts(0)]
public async Task<IActionResult> DeleteComment(string app, Guid commentsId, Guid commentId)
{
await CommandBus.PublishAsync(new DeleteComment { CommentsId = commentsId, CommentId = commentId });
return NoContent();
}
}
}

53
src/Squidex/Areas/Api/Controllers/Comments/Models/CommentDto.cs

@ -0,0 +1,53 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.ComponentModel.DataAnnotations;
using NodaTime;
using Squidex.Domain.Apps.Core.Comments;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Comments.Models
{
public sealed class CommentDto
{
/// <summary>
/// The id of the comment.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// The time when the comment was created or updated last.
/// </summary>
[Required]
public Instant Time { get; set; }
/// <summary>
/// The user who created or updated the comment.
/// </summary>
[Required]
public RefToken User { get; set; }
/// <summary>
/// The text of the comment.
/// </summary>
[Required]
public string Text { get; set; }
public static CommentDto FromComment(Comment comment)
{
return SimpleMapper.Map(comment, new CommentDto());
}
public static CommentDto FromCommand(CreateComment command)
{
return SimpleMapper.Map(command, new CommentDto { Id = command.CommentId, User = command.Actor, Time = SystemClock.Instance.GetCurrentInstant() });
}
}
}

48
src/Squidex/Areas/Api/Controllers/Comments/Models/CommentsDto.cs

@ -0,0 +1,48 @@
// ==========================================================================
// 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 Squidex.Domain.Apps.Entities.Comments;
namespace Squidex.Areas.Api.Controllers.Comments.Models
{
public sealed class CommentsDto
{
/// <summary>
/// The created comments including the updates.
/// </summary>
public List<CommentDto> CreatedComments { get; set; }
/// <summary>
/// The updates comments since the last version.
/// </summary>
public List<CommentDto> UpdatedComments { get; set; }
/// <summary>
/// The deleted comments since the last version.
/// </summary>
public List<Guid> DeletedComments { get; set; }
/// <summary>
/// The current version.
/// </summary>
public long Version { get; set; }
public static CommentsDto FromResult(CommentsResult result)
{
return new CommentsDto
{
CreatedComments = result.CreatedComments.Select(CommentDto.FromComment).ToList(),
UpdatedComments = result.UpdatedComments.Select(CommentDto.FromComment).ToList(),
DeletedComments = result.DeletedComments,
Version = result.Version
};
}
}
}

33
src/Squidex/Areas/Api/Controllers/Comments/Models/UpsertCommentDto.cs

@ -0,0 +1,33 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Comments.Models
{
public sealed class UpsertCommentDto
{
/// <summary>
/// The comment text.
/// </summary>
[Required]
public string Text { get; set; }
public CreateComment ToCreateCommand(Guid commentsId)
{
return SimpleMapper.Map(this, new CreateComment { CommentsId = commentsId });
}
public UpdateComment ToUpdateComment(Guid commentsId, Guid commentId)
{
return SimpleMapper.Map(this, new UpdateComment { CommentsId = commentsId, CommentId = commentId });
}
}
}

5
src/Squidex/Config/Domain/EntitiesServices.cs

@ -24,6 +24,8 @@ using Squidex.Domain.Apps.Entities.Apps.Templates;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Backup;
using Squidex.Domain.Apps.Entities.Comments;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Contents.Edm;
@ -160,6 +162,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<GrainCommandMiddleware<AppCommand, IAppGrain>>()
.As<ICommandMiddleware>();
services.AddSingletonAs<GrainCommandMiddleware<CommentsCommand, ICommentGrain>>()
.As<ICommandMiddleware>();
services.AddSingletonAs<GrainCommandMiddleware<ContentCommand, IContentGrain>>()
.As<ICommandMiddleware>();

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

@ -55,14 +55,14 @@ export class UserPageComponent implements OnDestroy, OnInit {
if (value) {
if (this.user) {
this.usersState.update(this.user.user, value)
.subscribe(user => {
.subscribe(() => {
this.userForm.submitCompleted();
}, error => {
this.userForm.submitFailed(error);
});
} else {
this.usersState.create(value)
.subscribe(user => {
.subscribe(() => {
this.back();
}, error => {
this.userForm.submitFailed(error);

1
src/Squidex/app/features/content/declarations.ts

@ -5,6 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
export * from './pages/comments/comments-page.component';
export * from './pages/content/content-field.component';
export * from './pages/content/content-history.component';
export * from './pages/content/content-page.component';

6
src/Squidex/app/features/content/module.ts

@ -25,6 +25,7 @@ import {
ArrayEditorComponent,
ArrayItemComponent,
AssetsEditorComponent,
CommentsPageComponent,
ContentFieldComponent,
ContentHistoryComponent,
ContentItemComponent,
@ -75,6 +76,10 @@ const routes: Routes = [
data: {
channel: 'contents.{contentId}'
}
},
{
path: 'comments',
component: CommentsPageComponent
}
]
}
@ -95,6 +100,7 @@ const routes: Routes = [
ArrayEditorComponent,
ArrayItemComponent,
AssetsEditorComponent,
CommentsPageComponent,
ContentFieldComponent,
ContentHistoryComponent,
ContentItemComponent,

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

@ -0,0 +1 @@
<sqx-comments [commentsId]="commentsId"></sqx-comments>

2
src/Squidex/app/features/content/pages/comments/comments-page.component.scss

@ -0,0 +1,2 @@
@import '_vars';
@import '_mixins';

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

@ -0,0 +1,30 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { allParams } from '@app/shared';
@Component({
selector: 'sqx-comments-page',
styleUrls: ['./comments-page.component.scss'],
templateUrl: './comments-page.component.html'
})
export class CommentsPageComponent implements OnInit {
public commentsId: string;
constructor(
private readonly route: ActivatedRoute
) {
}
public ngOnInit() {
this.commentsId = allParams(this.route)['contentId'];
}
}

2
src/Squidex/app/features/content/pages/content/content-history.component.html

@ -1,4 +1,4 @@
<sqx-panel desiredWidth="16rem" isBlank="true" [isLazyLoaded]="false">
<sqx-panel desiredWidth="20rem" isBlank="true" [isLazyLoaded]="false">
<ng-container title>
Activity
</ng-container>

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

@ -51,7 +51,7 @@ export class ContentHistoryComponent {
timer(0, 10000),
this.messageBus.of(HistoryChannelUpdated).pipe(delay(1000))
).pipe(
switchMap(app => this.historyService.getHistory(this.appsState.appName, this.channel)));
switchMap(() => this.historyService.getHistory(this.appsState.appName, this.channel)));
constructor(
private readonly appsState: AppsState,

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

@ -125,6 +125,10 @@
<i class="icon-time"></i>
</a>
<a class="panel-link" routerLink="comments" routerLinkActive="active" #linkHistory>
<i class="icon-comments"></i>
</a>
<sqx-onboarding-tooltip helpId="history" [for]="linkHistory" position="leftTop" after="120000">
The sidebar navigation contains useful context specific links. Here you can view the history how this schema has changed over time.
</sqx-onboarding-tooltip>

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

@ -141,14 +141,14 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
if (this.content) {
if (asProposal) {
this.contentsState.proposeUpdate(this.content, value)
.subscribe(dto => {
.subscribe(() => {
this.contentForm.submitCompleted();
}, error => {
this.contentForm.submitFailed(error);
});
} else {
this.contentsState.update(this.content, value)
.subscribe(dto => {
.subscribe(() => {
this.contentForm.submitCompleted();
}, error => {
this.contentForm.submitFailed(error);
@ -156,7 +156,7 @@ export class ContentPageComponent implements CanComponentDeactivate, OnDestroy,
}
} else {
this.contentsState.create(value, publish)
.subscribe(dto => {
.subscribe(() => {
this.back();
}, error => {
this.contentForm.submitFailed(error);

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

@ -121,7 +121,7 @@ export class RuleWizardComponent implements OnInit {
const requestDto = new CreateRuleDto(this.trigger, this.action);
this.rulesState.create(requestDto)
.subscribe(dto => {
.subscribe(() => {
this.complete();
this.actionForm.submitCompleted();
@ -134,7 +134,7 @@ export class RuleWizardComponent implements OnInit {
private updateTrigger() {
this.rulesState.updateTrigger(this.rule, this.trigger)
.subscribe(dto => {
.subscribe(() => {
this.complete();
this.triggerForm.submitCompleted();
@ -145,7 +145,7 @@ export class RuleWizardComponent implements OnInit {
private updateAction() {
this.rulesState.updateAction(this.rule, this.action)
.subscribe(dto => {
.subscribe(() => {
this.complete();
this.actionForm.submitCompleted();

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

@ -60,7 +60,7 @@ export class FieldWizardComponent implements OnInit {
if (value) {
this.schemasState.addField(this.schema, value, this.parent)
.subscribe(dto => {
.subscribe(() => {
this.addFieldForm.submitCompleted({ type: fieldTypes[0].type });
if (next) {

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

@ -47,7 +47,7 @@ export class SchemaEditFormComponent implements OnInit {
if (value) {
this.schemasState.update(this.schema, value)
.subscribe(dto => {
.subscribe(() => {
this.complete();
}, error => {
this.editForm.submitFailed(error);

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

@ -53,7 +53,7 @@ export class SchemaScriptsFormComponent implements OnInit {
if (value) {
this.schemasState.configureScripts(this.schema, value)
.subscribe(dto => {
.subscribe(() => {
this.complete();
}, error => {
this.editForm.submitFailed(error);

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

@ -94,7 +94,7 @@ export class ContributorsPageComponent implements OnInit {
const requestDto = new AppContributorDto(user, 'Editor');
this.contributorsState.assign(requestDto)
.subscribe(dto => {
.subscribe(() => {
this.assignContributorForm.submitCompleted();
}, error => {
this.assignContributorForm.submitFailed(error);

2
src/Squidex/app/framework/angular/forms/validators.ts

@ -126,7 +126,7 @@ export module ValidatorsEx {
}
export function noop(): ValidatorFn {
return (control: AbstractControl) => {
return () => {
return null;
};
}

12
src/Squidex/app/framework/angular/http/http-extensions.ts

@ -17,31 +17,31 @@ export module HTTP {
export function getVersioned<T>(http: HttpClient, url: string, version?: Version): Observable<Versioned<HttpResponse<T>>> {
const headers = createHeaders(version);
return handleVersion(http.get<T>(url, { observe: 'response', headers }), version);
return handleVersion(http.get<T>(url, { observe: 'response', headers }));
}
export function postVersioned<T>(http: HttpClient, url: string, body: any, version?: Version): Observable<Versioned<HttpResponse<T>>> {
const headers = createHeaders(version);
return handleVersion(http.post<T>(url, body, { observe: 'response', headers }), version);
return handleVersion(http.post<T>(url, body, { observe: 'response', headers }));
}
export function putVersioned<T>(http: HttpClient, url: string, body: any, version?: Version): Observable<Versioned<HttpResponse<T>>> {
const headers = createHeaders(version);
return handleVersion(http.put<T>(url, body, { observe: 'response', headers }), version);
return handleVersion(http.put<T>(url, body, { observe: 'response', headers }));
}
export function patchVersioned<T>(http: HttpClient, url: string, body: any, version?: Version): Observable<Versioned<HttpResponse<T>>> {
const headers = createHeaders(version);
return handleVersion(http.request<T>('PATCH', url, { body, observe: 'response', headers }), version);
return handleVersion(http.request<T>('PATCH', url, { body, observe: 'response', headers }));
}
export function deleteVersioned<T>(http: HttpClient, url: string, version?: Version): Observable<Versioned<HttpResponse<T>>> {
const headers = createHeaders(version);
return handleVersion(http.delete<T>(url, { observe: 'response', headers }), version);
return handleVersion(http.delete<T>(url, { observe: 'response', headers }));
}
function createHeaders(version?: Version): HttpHeaders {
@ -52,7 +52,7 @@ export module HTTP {
}
}
function handleVersion<T>(httpRequest: Observable<HttpResponse<T>>, version?: Version): Observable<Versioned<HttpResponse<T>>> {
function handleVersion<T>(httpRequest: Observable<HttpResponse<T>>): Observable<Versioned<HttpResponse<T>>> {
return httpRequest.pipe(map((response: HttpResponse<T>) => {
const etag = response.headers.get('etag') || '';

19
src/Squidex/app/shared/components/comment.component.html

@ -0,0 +1,19 @@
<div class="comment row no-gutters">
<div class="col col-auto">
<img class="user-picture" [attr.title]="comment.user | sqxUserNameRef:null" [attr.src]="comment.user | sqxUserPictureRef" />
</div>
<div class="col pl-2">
<div class="comment-message">
<div class="user-row">
<div class="user-ref">{{comment.user | sqxUserNameRef:null}}</div>
<button *ngIf="comment.user === userId" type="button" class="btn btn-sm btn-link btn-danger item-remove" (click)="deleting.emit()!">
<i class="icon-bin2"></i>
</button>
</div>
<div>{{comment.text}}</div>
<div class="comment-created text-muted">{{comment.time | sqxFromNow}}</div>
</div>
</div>
</div>

43
src/Squidex/app/shared/components/comment.component.scss

@ -0,0 +1,43 @@
@import '_vars';
@import '_mixins';
.user-ref {
font-weight: bold;
}
.item-remove {
@include absolute(-5px, -15px, auto, auto);
display: none;
}
.user-row {
& {
position: relative;
}
&:hover {
.item-remove {
display: block;
}
}
}
.comment {
& {
font-size: .9rem;
font-weight: normal;
margin-bottom: .75rem;
}
&-message {
margin-bottom: .375rem;
}
&-created {
font-size: .75rem;
}
}
.text-muted {
color: $color-history;
}

38
src/Squidex/app/shared/components/comment.component.ts

@ -0,0 +1,38 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { CommentDto, UpsertCommentForm } from '@app/shared/internal';
@Component({
selector: 'sqx-comment',
styleUrls: ['./comment.component.scss'],
templateUrl: './comment.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CommentComponent {
public editForm = new UpsertCommentForm(this.formBuilder);
@Input()
public comment: CommentDto;
@Input()
public userId: string;
@Output()
public deleting = new EventEmitter();
@Output()
public updated = new EventEmitter<string>();
constructor(
private readonly formBuilder: FormBuilder
) {
}
}

26
src/Squidex/app/shared/components/comments.component.html

@ -0,0 +1,26 @@
<sqx-panel desiredWidth="20rem" isBlank="true" [isLazyLoaded]="false" contentClass="grid">
<ng-container title>
Comments
</ng-container>
<ng-container content>
<div class="grid-content" #scrollMe [scrollTop]="scrollMe.scrollHeight">
<sqx-comment *ngFor="let comment of state.comments | async; trackBy: trackByComment"
[comment]="comment"
[userId]="userId"
(updated)="update(comment, $event)"
(deleting)="delete(comment)">
</sqx-comment>
</div>
<div class="grid-footer">
<form [formGroup]="commentForm.form" (submit)="comment()">
<input class="form-control" name="text" formControlName="text" placeholder="Create a comment" />
</form>
</div>
</ng-container>
</sqx-panel>

11
src/Squidex/app/shared/components/comments.component.scss

@ -0,0 +1,11 @@
@import '_vars';
@import '_mixins';
.grid-footer {
border-top-width: 1px;
}
.grid-body,
.grid-footer {
padding: 1rem;
}

80
src/Squidex/app/shared/components/comments.component.ts

@ -0,0 +1,80 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { Subscription, timer } from 'rxjs';
import { onErrorResumeNext, switchMap } from 'rxjs/operators';
import {
AppsState,
AuthService,
CommentDto,
CommentsService,
CommentsState,
DialogService,
UpsertCommentForm
} from '@app/shared/internal';
@Component({
selector: 'sqx-comments',
styleUrls: ['./comments.component.scss'],
templateUrl: './comments.component.html'
})
export class CommentsComponent implements OnDestroy, OnInit {
private timer: Subscription;
public state: CommentsState;
public userId: string;
public commentForm = new UpsertCommentForm(this.formBuilder);
@Input()
public commentsId: string;
constructor(authService: AuthService,
private readonly appsState: AppsState,
private readonly commentsService: CommentsService,
private readonly dialogs: DialogService,
private readonly formBuilder: FormBuilder
) {
this.userId = authService.user!.token;
}
public ngOnDestroy() {
this.timer.unsubscribe();
}
public ngOnInit() {
this.state = new CommentsState(this.appsState, this.commentsId, this.commentsService, this.dialogs);
this.timer = timer(0, 4000).pipe(switchMap(() => this.state.load()), onErrorResumeNext()).subscribe();
}
public delete(comment: CommentDto) {
this.state.delete(comment.id).pipe(onErrorResumeNext()).subscribe();
}
public update(comment: CommentDto, text: string) {
this.state.update(comment.id, text).pipe(onErrorResumeNext()).subscribe();
}
public comment() {
const value = this.commentForm.submit();
if (value) {
this.state.create(value.text).pipe(onErrorResumeNext()).subscribe();
this.commentForm.submitCompleted({});
}
}
public trackByComment(index: number, comment: CommentDto) {
return comment.id;
}
}

2
src/Squidex/app/shared/components/history.component.html

@ -1,4 +1,4 @@
<sqx-panel desiredWidth="16rem" isBlank="true" [isLazyLoaded]="false">
<sqx-panel desiredWidth="20rem" isBlank="true" [isLazyLoaded]="false">
<ng-container title>
Activity
</ng-container>

2
src/Squidex/app/shared/declarations.ts

@ -9,6 +9,8 @@ export * from './components/app-form.component';
export * from './components/asset.component';
export * from './components/assets-list.component';
export * from './components/assets-selector.component';
export * from './components/comment.component';
export * from './components/comments.component';
export * from './components/help.component';
export * from './components/geolocation-editor.component';
export * from './components/history.component';

3
src/Squidex/app/shared/internal.ts

@ -27,6 +27,7 @@ export * from './services/apps.service';
export * from './services/assets.service';
export * from './services/auth.service';
export * from './services/backups.service';
export * from './services/comments.service';
export * from './services/contents.service';
export * from './services/graphql.service';
export * from './services/help.service';
@ -49,6 +50,8 @@ export * from './state/backups.forms';
export * from './state/backups.state';
export * from './state/clients.forms';
export * from './state/clients.state';
export * from './state/comments.form';
export * from './state/comments.state';
export * from './state/contents.forms';
export * from './state/contents.state';
export * from './state/contributors.forms';

8
src/Squidex/app/shared/module.ts

@ -34,6 +34,9 @@ import {
BackupsService,
BackupsState,
ClientsState,
CommentComponent,
CommentsComponent,
CommentsService,
ContentMustExistGuard,
ContentsService,
ContentsState,
@ -97,6 +100,8 @@ import {
AssetUrlPipe,
AssetsListComponent,
AssetsSelectorComponent,
CommentComponent,
CommentsComponent,
FileIconPipe,
GeolocationEditorComponent,
HelpComponent,
@ -122,6 +127,8 @@ import {
AssetUrlPipe,
AssetsListComponent,
AssetsSelectorComponent,
CommentComponent,
CommentsComponent,
FileIconPipe,
GeolocationEditorComponent,
HelpComponent,
@ -163,6 +170,7 @@ export class SqxSharedModule {
BackupsService,
BackupsState,
ClientsState,
CommentsService,
ContentMustExistGuard,
ContentsService,
ContentsState,

2
src/Squidex/app/shared/services/assets.service.ts

@ -237,7 +237,7 @@ export class AssetsService {
throw 'Invalid';
}
}),
tap(dto => {
tap(() => {
this.analytics.trackEvent('Asset', 'Uploaded', appName);
}),
pretifyError('Failed to upload asset. Please reload.'));

2
src/Squidex/app/shared/services/auth.service.ts

@ -185,7 +185,7 @@ export class AuthService {
}
return true;
}, err => {
}, error => {
this.user$.next(null);
return false;

152
src/Squidex/app/shared/services/comments.service.spec.ts

@ -0,0 +1,152 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { inject, TestBed } from '@angular/core/testing';
import {
ApiUrlConfig,
CommentDto,
CommentsDto,
CommentsService,
DateTime,
UpsertCommentDto,
Version
} from './../';
describe('CommentsService', () => {
const user = 'me';
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
HttpClientTestingModule
],
providers: [
CommentsService,
{ provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }
]
});
});
afterEach(inject([HttpTestingController], (httpMock: HttpTestingController) => {
httpMock.verify();
}));
it('should make get request to get comments',
inject([CommentsService, HttpTestingController], (commentsService: CommentsService, httpMock: HttpTestingController) => {
let comments: CommentsDto;
commentsService.getComments('my-app', 'my-comments', new Version('123')).subscribe(result => {
comments = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/comments/my-comments');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-None-Match')).toBe('123');
req.flush({
createdComments: [{
id: '123',
text: 'text1',
time: '2016-10-12T10:10',
user: user
}],
updatedComments: [{
id: '456',
text: 'text2',
time: '2017-11-12T12:12',
user: user
}],
deletedComments: ['789'],
version: '9'
});
expect(comments!).toEqual(
new CommentsDto(
[
new CommentDto('123', DateTime.parseISO_UTC('2016-10-12T10:10'), 'text1', user)
], [
new CommentDto('456', DateTime.parseISO_UTC('2017-11-12T12:12'), 'text2', user)
], [
'789'
],
new Version('9'))
);
}));
it('should make get request to get comments and return empty result for 304',
inject([CommentsService, HttpTestingController], (commentsService: CommentsService, httpMock: HttpTestingController) => {
let comments: CommentsDto;
commentsService.getComments('my-app', 'my-comments', new Version('123')).subscribe(result => {
comments = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/comments/my-comments');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-None-Match')).toBe('123');
req.flush({}, { status: 304, statusText: 'NotModified' });
expect(comments!).toEqual(new CommentsDto([], [], [], new Version('123')));
}));
it('should make post request to create comment',
inject([CommentsService, HttpTestingController], (commentsService: CommentsService, httpMock: HttpTestingController) => {
let comment: CommentDto;
commentsService.postComment('my-app', 'my-comments', new UpsertCommentDto('text1')).subscribe(result => {
comment = <CommentDto>result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/comments/my-comments');
expect(req.request.method).toEqual('POST');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({
id: '123',
text: 'text1',
time: '2016-10-12T10:10',
user: user
});
expect(comment!).toEqual(new CommentDto('123', DateTime.parseISO_UTC('2016-10-12T10:10'), 'text1', user));
}));
it('should make put request to replace comment content',
inject([CommentsService, HttpTestingController], (commentsService: CommentsService, httpMock: HttpTestingController) => {
commentsService.putComment('my-app', 'my-comments', '123', new UpsertCommentDto('text1')).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/comments/my-comments/123');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({});
}));
it('should make delete request to delete comment',
inject([CommentsService, HttpTestingController], (commentsService: CommentsService, httpMock: HttpTestingController) => {
commentsService.deleteComment('my-app', 'my-comments', '123').subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/comments/my-comments/123');
expect(req.request.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush({});
}));
});

136
src/Squidex/app/shared/services/comments.service.ts

@ -0,0 +1,136 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, of, throwError } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import {
ApiUrlConfig,
DateTime,
Model,
pretifyError,
Types,
Version
} from '@app/framework';
export class CommentsDto extends Model {
constructor(
public readonly createdComments: CommentDto[],
public readonly updatedComments: CommentDto[],
public readonly deletedComments: string[],
public readonly version: Version
) {
super();
}
}
export class CommentDto extends Model {
constructor(
public readonly id: string,
public readonly time: DateTime,
public readonly text: string,
public readonly user: string
) {
super();
}
public with(value: Partial<CommentDto>): CommentDto {
return this.clone(value);
}
}
export class UpsertCommentDto {
constructor(
public readonly text: string
) {
}
}
@Injectable()
export class CommentsService {
constructor(
private readonly http: HttpClient,
private readonly apiUrl: ApiUrlConfig
) {
}
public getComments(appName: string, commentsId: string, version: Version): Observable<CommentsDto> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/comments/${commentsId}`);
const options = {
headers: new HttpHeaders().set('If-None-Match', version.value)
};
return this.http.get(url, options).pipe(
catchError(err => {
if (err.status === 304) {
return of(new CommentsDto([], [], [], version));
}
return throwError(err);
}),
map(response => {
if (Types.is(response, CommentsDto)) {
return response;
}
const body: any = response;
return new CommentsDto(
body.createdComments.map((item: any) => {
return new CommentDto(
item.id,
DateTime.parseISO_UTC(item.time),
item.text,
item.user);
}),
body.updatedComments.map((item: any) => {
return new CommentDto(
item.id,
DateTime.parseISO_UTC(item.time),
item.text,
item.user);
}),
body.deletedComments,
new Version(body.version)
);
}),
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}`);
return this.http.post(url, dto).pipe(
map(response => {
const body: any = response;
return new CommentDto(
body.id,
DateTime.parseISO_UTC(body.time),
body.text,
body.user);
}),
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}`);
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}`);
return this.http.delete(url).pipe(
pretifyError('Failed to delete comment.'));
}
}

2
src/Squidex/app/shared/services/contents.service.ts

@ -190,7 +190,7 @@ export class ContentsService {
body.data,
response.version);
}),
tap(content => {
tap(() => {
this.analytics.trackEvent('Content', 'Created', appName);
}),
pretifyError('Failed to create content. Please reload.'));

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

@ -344,7 +344,7 @@ export class SchemasService {
body.scriptDelete,
body.scriptChange);
}),
tap(schema => {
tap(() => {
this.analytics.trackEvent('Schema', 'Created', appName);
}),
pretifyError('Failed to create schema. Please reload.'));

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

@ -87,7 +87,7 @@ describe('AppsState', () => {
appsService.setup(x => x.postApp(request))
.returns(() => of(newApp));
appsState.create(request, now).subscribe();
appsState.create(request).subscribe();
expect(appsState.snapshot.apps.values).toEqual([newApp, ...oldApps]);
});
@ -101,7 +101,7 @@ describe('AppsState', () => {
appsService.setup(x => x.deleteApp(newApp.name))
.returns(() => of({}));
appsState.create(request, now).subscribe();
appsState.create(request).subscribe();
const appsAfterCreate = appsState.snapshot.apps.values;

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

@ -10,7 +10,6 @@ import { Observable, of } from 'rxjs';
import { distinctUntilChanged, map, tap } from 'rxjs/operators';
import {
DateTime,
DialogService,
ImmutableArray,
notify,
@ -73,7 +72,7 @@ export class AppsState extends State<Snapshot> {
}));
}
public create(request: CreateAppDto, now?: DateTime): Observable<AppDto> {
public create(request: CreateAppDto): Observable<AppDto> {
return this.appsService.postApp(request).pipe(
tap(dto => {
this.next(s => {
@ -86,7 +85,7 @@ export class AppsState extends State<Snapshot> {
public delete(name: string): Observable<any> {
return this.appsService.deleteApp(name).pipe(
tap(app => {
tap(() => {
this.next(s => {
const apps = s.apps.filter(x => x.name !== name);

22
src/Squidex/app/shared/state/comments.form.ts

@ -0,0 +1,22 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Form } from '@app/framework';
export class UpsertCommentForm extends Form<FormGroup> {
constructor(formBuilder: FormBuilder) {
super(formBuilder.group({
text: ['',
[
Validators.required
]
]
}));
}
}

119
src/Squidex/app/shared/state/comments.state.spec.ts

@ -0,0 +1,119 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq';
import {
AppsState,
CommentDto,
CommentsDto,
CommentsService,
CommentsState,
DateTime,
DialogService,
ImmutableArray,
UpsertCommentDto,
Version
} from '@app/shared';
describe('CommentsState', () => {
const app = 'my-app';
const commentsId = 'my-comments';
const now = DateTime.today();
const user = 'not-me';
const oldComments = new CommentsDto([
new CommentDto('1', now, 'text1', user),
new CommentDto('2', now, 'text2', user)
], [], [], new Version('1'));
let dialogs: IMock<DialogService>;
let appsState: IMock<AppsState>;
let commentsService: IMock<CommentsService>;
let commentsState: CommentsState;
beforeEach(() => {
dialogs = Mock.ofType<DialogService>();
appsState = Mock.ofType<AppsState>();
appsState.setup(x => x.appName)
.returns(() => app);
commentsService = Mock.ofType<CommentsService>();
commentsService.setup(x => x.getComments(app, commentsId, new Version('')))
.returns(() => of(oldComments));
commentsState = new CommentsState(appsState.object, commentsId, commentsService.object, dialogs.object);
commentsState.load().subscribe();
});
it('should load and merge comments', () => {
const newComments = new CommentsDto([
new CommentDto('3', now, 'text3', user)
], [
new CommentDto('2', now, 'text2_2', user)
], ['1'], new Version('2'));
commentsService.setup(x => x.getComments(app, commentsId, new Version('1')))
.returns(() => of(newComments));
commentsState.load().subscribe();
expect(commentsState.snapshot.isLoaded).toBeTruthy();
expect(commentsState.snapshot.comments).toEqual(ImmutableArray.of([
new CommentDto('2', now, 'text2_2', user),
new CommentDto('3', now, 'text3', user)
]));
commentsService.verify(x => x.getComments(app, commentsId, It.isAny()), Times.exactly(2));
});
it('should add comment to snapshot when created', () => {
const newComment = new CommentDto('3', now, 'text3', user);
commentsService.setup(x => x.postComment(app, commentsId, new UpsertCommentDto('text3')))
.returns(() => of(newComment));
commentsState.create('text3').subscribe();
expect(commentsState.snapshot.comments).toEqual(ImmutableArray.of([
new CommentDto('1', now, 'text1', user),
new CommentDto('2', now, 'text2', user),
new CommentDto('3', now, 'text3', user)
]));
});
it('should update properties when updated', () => {
commentsService.setup(x => x.putComment(app, commentsId, '2', new UpsertCommentDto('text2_2')))
.returns(() => of({}));
commentsState.update('2', 'text2_2', now).subscribe();
expect(commentsState.snapshot.comments).toEqual(ImmutableArray.of([
new CommentDto('1', now, 'text1', user),
new CommentDto('2', now, 'text2_2', user)
]));
commentsService.verify(x => x.putComment(app, commentsId, '2', new UpsertCommentDto('text2_2')), Times.once());
});
it('should remove comment from snapshot when deleted', () => {
commentsService.setup(x => x.deleteComment(app, commentsId, '2'))
.returns(() => of({}));
commentsState.delete('2').subscribe();
expect(commentsState.snapshot.comments).toEqual(ImmutableArray.of([
new CommentDto('1', now, 'text1', user)
]));
commentsService.verify(x => x.deleteComment(app, commentsId, '2'), Times.once());
});
});

128
src/Squidex/app/shared/state/comments.state.ts

@ -0,0 +1,128 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { distinctUntilChanged, map, tap } from 'rxjs/operators';
import {
DateTime,
DialogService,
ImmutableArray,
notify,
State,
Version
} from '@app/framework';
import {
CommentDto,
CommentsService,
UpsertCommentDto
} from './../services/comments.service';
import { AppsState } from './apps.state';
interface Snapshot {
comments: ImmutableArray<CommentDto>;
version: Version;
isLoaded?: boolean;
}
@Injectable()
export class CommentsState extends State<Snapshot> {
public comments =
this.changes.pipe(map(x => x.comments),
distinctUntilChanged());
public isLoaded =
this.changes.pipe(map(x => !!x.isLoaded),
distinctUntilChanged());
constructor(
private readonly appsState: AppsState,
private readonly commentsId: string,
private readonly commentsService: CommentsService,
private readonly dialogs: DialogService
) {
super({ comments: ImmutableArray.empty(), version: new Version('') });
}
public load(): Observable<any> {
return this.commentsService.getComments(this.appName, this.commentsId, this.version).pipe(
tap(dtos => {
this.next(s => {
let comments = s.comments;
for (let created of dtos.createdComments) {
if (!comments.find(x => x.id === created.id)) {
comments = comments.push(created);
}
}
for (let updated of dtos.updatedComments) {
comments = comments.replaceBy('id', updated);
}
for (let deleted of dtos.deletedComments) {
comments = comments.filter(x => x.id !== deleted);
}
return { ...s, comments, isLoaded: true, version: dtos.version };
});
}),
notify(this.dialogs));
}
public create(text: string): Observable<any> {
return this.commentsService.postComment(this.appName, this.commentsId, new UpsertCommentDto(text)).pipe(
tap(dto => {
this.next(s => {
const comments = s.comments.push(dto);
return { ...s, comments };
});
}),
notify(this.dialogs));
}
public update(commentId: string, text: string, now?: DateTime): Observable<any> {
return this.commentsService.putComment(this.appName, this.commentsId, commentId, new UpsertCommentDto(text)).pipe(
tap(() => {
this.next(s => {
const comments = s.comments.map(c => c.id === commentId ? update(c, text, now || DateTime.now()) : c);
return { ...s, comments };
});
}),
notify(this.dialogs));
}
public delete(commentId: string): Observable<any> {
return this.commentsService.deleteComment(this.appName, this.commentsId, commentId).pipe(
tap(() => {
this.next(s => {
const comments = s.comments.filter(c => c.id !== commentId);
return { ...s, comments };
});
}),
notify(this.dialogs));
}
private get version() {
return this.snapshot.version;
}
private get appName() {
return this.appsState.appName;
}
}
const update = (comment: CommentDto, text: string, time: DateTime) =>
comment.with({ text, time });

2
src/Squidex/app/shared/state/contents.state.ts

@ -132,7 +132,7 @@ export abstract class ContentsStateBase extends State<Snapshot> {
notify(this.dialogs));
}
public create(request: any, publish: boolean, now?: DateTime) {
public create(request: any, publish: boolean) {
return this.contentsService.postContent(this.appName, this.schemaName, request, publish).pipe(
tap(dto => {
this.dialogs.notifyInfo('Contents created successfully.');

2
src/Squidex/app/shared/state/rules.state.ts

@ -87,7 +87,7 @@ export class RulesState extends State<Snapshot> {
public delete(rule: RuleDto): Observable<any> {
return this.rulesService.deleteRule(this.appName, rule.id, rule.version).pipe(
tap(dto => {
tap(() => {
this.next(s => {
const rules = s.rules.removeAll(x => x.id === rule.id);

2
src/Squidex/app/shared/state/schemas.state.ts

@ -140,7 +140,7 @@ export class SchemasState extends State<Snapshot> {
public delete(schema: SchemaDto): Observable<any> {
return this.schemasService.deleteSchema(this.appName, schema.name, schema.version).pipe(
tap(dto => {
tap(() => {
return this.next(s => {
const schemas = s.schemas.filter(x => x.id !== schema.id);
const selectedSchema = s.selectedSchema && s.selectedSchema.id === schema.id ? null : s.selectedSchema;

8
src/Squidex/app/theme/icomoon/demo-files/demo.css

@ -147,19 +147,19 @@ p {
font-size: 16px;
}
.fs1 {
font-size: 32px;
font-size: 24px;
}
.fs2 {
font-size: 20px;
font-size: 32px;
}
.fs3 {
font-size: 32px;
font-size: 20px;
}
.fs4 {
font-size: 32px;
}
.fs5 {
font-size: 24px;
font-size: 32px;
}
.fs6 {
font-size: 28px;

324
src/Squidex/app/theme/icomoon/demo.html

@ -9,11 +9,94 @@
<link rel="stylesheet" href="style.css"></head>
<body>
<div class="bgc1 clearfix">
<h1 class="mhmm mvm"><span class="fgc1">Font Name:</span> icomoon <small class="fgc1">(Glyphs:&nbsp;98)</small></h1>
<h1 class="mhmm mvm"><span class="fgc1">Font Name:</span> icomoon <small class="fgc1">(Glyphs:&nbsp;99)</small></h1>
</div>
<div class="clearfix mhl ptl">
<h1 class="mvm mtn fgc1">Grid Size: 16</h1>
<h1 class="mvm mtn fgc1">Grid Size: 24</h1>
<div class="glyph fs1">
<div class="clearfix bshadow0 pbs">
<span class="icon-comments">
</span>
<span class="mls"> icon-comments</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e95f" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe95f;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="clearfix bshadow0 pbs">
<span class="icon-backup">
</span>
<span class="mls"> icon-backup</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e95b" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe95b;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="clearfix bshadow0 pbs">
<span class="icon-support">
</span>
<span class="mls"> icon-support</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e95a" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe95a;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-RichText">
</span>
<span class="mls"> icon-control-RichText</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e939" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe939;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="clearfix bshadow0 pbs">
<span class="icon-download">
</span>
<span class="mls"> icon-download</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e93e" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe93e;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
</div>
<div class="clearfix mhl ptl">
<h1 class="mvm mtn fgc1">Grid Size: 16</h1>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-spinner2">
@ -29,7 +112,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-star-full">
@ -45,7 +128,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-star-empty">
@ -61,7 +144,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-twitter">
@ -77,7 +160,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-hour-glass">
@ -93,7 +176,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-spinner">
@ -109,7 +192,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-clock">
@ -125,7 +208,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-bin2">
@ -141,7 +224,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-earth">
@ -157,7 +240,7 @@
<input type="text" readonly value="earth, globe2" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-elapsed">
@ -173,7 +256,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-google">
@ -189,7 +272,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-lock">
@ -205,7 +288,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-microsoft">
@ -221,7 +304,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-pause">
@ -237,7 +320,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-play">
@ -253,7 +336,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-reset">
@ -269,7 +352,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-settings2">
@ -285,7 +368,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-timeout">
@ -301,7 +384,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs1">
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-unlocked">
@ -320,7 +403,7 @@
</div>
<div class="clearfix mhl ptl">
<h1 class="mvm mtn fgc1">Grid Size: 20</h1>
<div class="glyph fs2">
<div class="glyph fs3">
<div class="clearfix bshadow0 pbs">
<span class="icon-grid1">
@ -336,7 +419,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="glyph fs3">
<div class="clearfix bshadow0 pbs">
<span class="icon-list">
@ -352,7 +435,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="glyph fs3">
<div class="clearfix bshadow0 pbs">
<span class="icon-info">
@ -368,26 +451,10 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs2">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-Stars">
</span>
<span class="mls"> icon-control-Stars</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e93a" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe93a;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
</div>
<div class="clearfix mhl ptl">
<h1 class="mvm mtn fgc1">Grid Size: 32</h1>
<div class="glyph fs3">
<div class="glyph fs4">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-Color">
@ -403,7 +470,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs3">
<div class="glyph fs4">
<div class="clearfix bshadow0 pbs">
<span class="icon-browser">
@ -419,7 +486,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs3">
<div class="glyph fs4">
<div class="clearfix bshadow0 pbs">
<span class="icon-checkmark">
@ -435,7 +502,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs3">
<div class="glyph fs4">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-Stars">
@ -454,7 +521,7 @@
</div>
<div class="clearfix mhl ptl">
<h1 class="mvm mtn fgc1">Grid Size: Unknown</h1>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-prerender">
@ -470,7 +537,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-circle">
@ -486,7 +553,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-Slug">
@ -502,7 +569,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-type-Tags">
@ -518,7 +585,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-activity">
@ -534,7 +601,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-history">
@ -550,7 +617,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-time">
@ -566,7 +633,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-add">
@ -582,7 +649,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-plus">
@ -598,7 +665,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-check-circle">
@ -614,7 +681,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-check-circle-filled">
@ -630,7 +697,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-close">
@ -646,7 +713,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-type-References">
@ -662,7 +729,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-Checkbox">
@ -678,7 +745,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-Dropdown">
@ -694,7 +761,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-Input">
@ -710,7 +777,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-Radio">
@ -726,7 +793,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-TextArea">
@ -742,7 +809,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-Toggle">
@ -758,7 +825,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-copy">
@ -774,7 +841,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-dashboard">
@ -790,7 +857,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-delete">
@ -806,7 +873,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-bin">
@ -822,7 +889,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-delete-filled">
@ -838,7 +905,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-document-delete">
@ -854,7 +921,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-document-disable">
@ -870,7 +937,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-document-publish">
@ -886,7 +953,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-drag">
@ -902,7 +969,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-filter">
@ -918,7 +985,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-github">
@ -934,7 +1001,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-help">
@ -950,7 +1017,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-location">
@ -966,7 +1033,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-Map">
@ -982,7 +1049,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-type-Geolocation">
@ -998,7 +1065,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-logo">
@ -1014,7 +1081,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-media">
@ -1030,7 +1097,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-type-Assets">
@ -1046,7 +1113,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-trigger-AssetChanged">
@ -1062,7 +1129,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-more">
@ -1078,7 +1145,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-dots">
@ -1094,7 +1161,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-pencil">
@ -1110,7 +1177,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-reference">
@ -1126,7 +1193,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-schemas">
@ -1142,7 +1209,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-search">
@ -1158,7 +1225,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-settings">
@ -1174,7 +1241,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-type-Boolean">
@ -1190,7 +1257,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-type-DateTime">
@ -1206,7 +1273,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-type-Json">
@ -1222,7 +1289,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-json">
@ -1238,7 +1305,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-type-Number">
@ -1254,7 +1321,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-type-String">
@ -1270,7 +1337,7 @@
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs4">
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-user">
@ -1287,73 +1354,6 @@
</div>
</div>
</div>
<div class="clearfix mhl ptl">
<h1 class="mvm mtn fgc1">Grid Size: 24</h1>
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-backup">
</span>
<span class="mls"> icon-backup</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e95b" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe95b;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-support">
</span>
<span class="mls"> icon-support</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e95a" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe95a;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-control-RichText">
</span>
<span class="mls"> icon-control-RichText</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e939" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe939;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
<div class="glyph fs5">
<div class="clearfix bshadow0 pbs">
<span class="icon-download">
</span>
<span class="mls"> icon-download</span>
</div>
<fieldset class="fs0 size1of1 clearfix hidden-false">
<input type="text" readonly value="e93e" class="unit size1of2" />
<input type="text" maxlength="1" readonly value="&#xe93e;" class="unitRight size1of2 talign-right" />
</fieldset>
<div class="fs0 bshadow0 clearfix hidden-true">
<span class="unit pvs fgc1">liga: </span>
<input type="text" readonly value="" class="liga unitRight" />
</div>
</div>
</div>
<div class="clearfix mhl ptl">
<h1 class="mvm mtn fgc1">Grid Size: 14</h1>
<div class="glyph fs6">

BIN
src/Squidex/app/theme/icomoon/fonts/icomoon.eot

Binary file not shown.

1
src/Squidex/app/theme/icomoon/fonts/icomoon.svg

@ -102,6 +102,7 @@
<glyph unicode="&#xe95c;" glyph-name="twitter" d="M1024 733.6c-37.6-16.8-78.2-28-120.6-33 43.4 26 76.6 67.2 92.4 116.2-40.6-24-85.6-41.6-133.4-51-38.4 40.8-93 66.2-153.4 66.2-116 0-210-94-210-210 0-16.4 1.8-32.4 5.4-47.8-174.6 8.8-329.4 92.4-433 219.6-18-31-28.4-67.2-28.4-105.6 0-72.8 37-137.2 93.4-174.8-34.4 1-66.8 10.6-95.2 26.2 0-0.8 0-1.8 0-2.6 0-101.8 72.4-186.8 168.6-206-17.6-4.8-36.2-7.4-55.4-7.4-13.6 0-26.6 1.4-39.6 3.8 26.8-83.4 104.4-144.2 196.2-146-72-56.4-162.4-90-261-90-17 0-33.6 1-50.2 3 93.2-59.8 203.6-94.4 322.2-94.4 386.4 0 597.8 320.2 597.8 597.8 0 9.2-0.2 18.2-0.6 27.2 41 29.4 76.6 66.4 104.8 108.6z" />
<glyph unicode="&#xe95d;" glyph-name="star-full" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538z" />
<glyph unicode="&#xe95e;" glyph-name="star-empty" d="M1024 562.95l-353.78 51.408-158.22 320.582-158.216-320.582-353.784-51.408 256-249.538-60.432-352.352 316.432 166.358 316.432-166.358-60.434 352.352 256.002 249.538zM512 206.502l-223.462-117.48 42.676 248.83-180.786 176.222 249.84 36.304 111.732 226.396 111.736-226.396 249.836-36.304-180.788-176.222 42.678-248.83-223.462 117.48z" />
<glyph unicode="&#xe95f;" glyph-name="comments" d="M854 256.667v512h-684v-598l86 86h598zM854 852.667c46 0 84-38 84-84v-512c0-46-38-86-84-86h-598l-170-170v768c0 46 38 84 84 84h684z" />
<glyph unicode="&#xe9ca;" glyph-name="earth" d="M512 960c-282.77 0-512-229.23-512-512s229.23-512 512-512 512 229.23 512 512-229.23 512-512 512zM512-0.002c-62.958 0-122.872 13.012-177.23 36.452l233.148 262.29c5.206 5.858 8.082 13.422 8.082 21.26v96c0 17.674-14.326 32-32 32-112.99 0-232.204 117.462-233.374 118.626-6 6.002-14.14 9.374-22.626 9.374h-128c-17.672 0-32-14.328-32-32v-192c0-12.122 6.848-23.202 17.69-28.622l110.31-55.156v-187.886c-116.052 80.956-192 215.432-192 367.664 0 68.714 15.49 133.806 43.138 192h116.862c8.488 0 16.626 3.372 22.628 9.372l128 128c6 6.002 9.372 14.14 9.372 22.628v77.412c40.562 12.074 83.518 18.588 128 18.588 70.406 0 137.004-16.26 196.282-45.2-4.144-3.502-8.176-7.164-12.046-11.036-36.266-36.264-56.236-84.478-56.236-135.764s19.97-99.5 56.236-135.764c36.434-36.432 85.218-56.264 135.634-56.26 3.166 0 6.342 0.080 9.518 0.236 13.814-51.802 38.752-186.656-8.404-372.334-0.444-1.744-0.696-3.488-0.842-5.224-81.324-83.080-194.7-134.656-320.142-134.656z" />
<glyph unicode="&#xf00a;" glyph-name="grid" d="M292.571 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM292.571 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM292.571 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 237.714v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM658.286 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 530.286v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857zM1024 822.857v-109.714c0-30.286-24.571-54.857-54.857-54.857h-182.857c-30.286 0-54.857 24.571-54.857 54.857v109.714c0 30.286 24.571 54.857 54.857 54.857h182.857c30.286 0 54.857-24.571 54.857-54.857z" />
<glyph unicode="&#xf0c9;" glyph-name="list1" horiz-adv-x="878" d="M877.714 182.857v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571zM877.714 475.428v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571zM877.714 768v-73.143c0-20-16.571-36.571-36.571-36.571h-804.571c-20 0-36.571 16.571-36.571 36.571v73.143c0 20 16.571 36.571 36.571 36.571h804.571c20 0 36.571-16.571 36.571-36.571z" />

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

BIN
src/Squidex/app/theme/icomoon/fonts/icomoon.ttf

Binary file not shown.

BIN
src/Squidex/app/theme/icomoon/fonts/icomoon.woff

Binary file not shown.

2
src/Squidex/app/theme/icomoon/selection.json

File diff suppressed because one or more lines are too long

40
src/Squidex/app/theme/icomoon/style.css

@ -1,10 +1,10 @@
@font-face {
font-family: 'icomoon';
src: url('fonts/icomoon.eot?z1dhmx');
src: url('fonts/icomoon.eot?z1dhmx#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?z1dhmx') format('truetype'),
url('fonts/icomoon.woff?z1dhmx') format('woff'),
url('fonts/icomoon.svg?z1dhmx#icomoon') format('svg');
src: url('fonts/icomoon.eot?jlrdyp');
src: url('fonts/icomoon.eot?jlrdyp#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?jlrdyp') format('truetype'),
url('fonts/icomoon.woff?jlrdyp') format('woff'),
url('fonts/icomoon.svg?jlrdyp#icomoon') format('svg');
font-weight: normal;
font-style: normal;
}
@ -24,6 +24,21 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-comments:before {
content: "\e95f";
}
.icon-backup:before {
content: "\e95b";
}
.icon-support:before {
content: "\e95a";
}
.icon-control-RichText:before {
content: "\e939";
}
.icon-download:before {
content: "\e93e";
}
.icon-spinner2:before {
content: "\e959";
}
@ -90,9 +105,6 @@
.icon-info:before {
content: "\e93c";
}
.icon-control-Stars:before {
content: "\e93a";
}
.icon-control-Color:before {
content: "\e94d";
}
@ -261,18 +273,6 @@
.icon-user:before {
content: "\e928";
}
.icon-backup:before {
content: "\e95b";
}
.icon-support:before {
content: "\e95a";
}
.icon-control-RichText:before {
content: "\e939";
}
.icon-download:before {
content: "\e93e";
}
.icon-single-content:before {
content: "\e958";
}

162
tests/Squidex.Domain.Apps.Entities.Tests/Comments/CommentsGrainTests.cs

@ -0,0 +1,162 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
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.Commands;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Comments
{
public class CommentsGrainTests : HandlerTestBase<CommentsGrain, CommentsState>
{
private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly Guid commentsId = Guid.NewGuid();
private readonly CommentsGrain sut;
protected override Guid Id
{
get { return commentsId; }
}
public CommentsGrainTests()
{
sut = new CommentsGrain(Store, A.Dummy<ISemanticLog>());
sut.OnActivateAsync(Id).Wait();
}
[Fact]
public async Task Create_should_create_events()
{
var command = new CreateComment { Text = "text1" };
var result = await sut.ExecuteAsync(CreateCommentsCommand(command));
result.ShouldBeEquivalent(EntityCreatedResult.Create(command.CommentId, 0));
sut.GetCommentsAsync(0).Result.Should().BeEquivalentTo(new CommentsResult { Version = 0 });
sut.GetCommentsAsync(-1).Result.Should().BeEquivalentTo(new CommentsResult
{
CreatedComments = new List<Comment>
{
new Comment(command.CommentId, LastEvents.ElementAt(0).Headers.Timestamp(), command.Actor, "text1")
},
Version = 0
});
LastEvents
.ShouldHaveSameEvents(
CreateCommentsEvent(new CommentCreated { CommentId = command.CommentId, Text = command.Text })
);
}
[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 sut.ExecuteAsync(CreateCommentsCommand(createCommand));
var result = await sut.ExecuteAsync(CreateCommentsCommand(updateCommand));
result.ShouldBeEquivalent(new EntitySavedResult(1));
sut.GetCommentsAsync(-1).Result.Should().BeEquivalentTo(new CommentsResult
{
CreatedComments = new List<Comment>
{
new Comment(createCommand.CommentId, LastEvents.ElementAt(0).Headers.Timestamp(), createCommand.Actor, "text2")
},
Version = 1
});
sut.GetCommentsAsync(0).Result.Should().BeEquivalentTo(new CommentsResult
{
UpdatedComments = new List<Comment>
{
new Comment(createCommand.CommentId, LastEvents.ElementAt(0).Headers.Timestamp(), createCommand.Actor, "text2")
},
Version = 1
});
LastEvents
.ShouldHaveSameEvents(
CreateCommentsEvent(new CommentUpdated { CommentId = createCommand.CommentId, 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 sut.ExecuteAsync(CreateCommentsCommand(createCommand));
await sut.ExecuteAsync(CreateCommentsCommand(updateCommand));
var result = await sut.ExecuteAsync(CreateCommentsCommand(deleteCommand));
result.ShouldBeEquivalent(new EntitySavedResult(2));
sut.GetCommentsAsync(-1).Result.Should().BeEquivalentTo(new CommentsResult { Version = 2 });
sut.GetCommentsAsync(0).Result.Should().BeEquivalentTo(new CommentsResult
{
DeletedComments = new List<Guid>
{
deleteCommand.CommentId
},
Version = 2
});
sut.GetCommentsAsync(1).Result.Should().BeEquivalentTo(new CommentsResult
{
DeletedComments = new List<Guid>
{
deleteCommand.CommentId
},
Version = 2
});
LastEvents
.ShouldHaveSameEvents(
CreateCommentsEvent(new CommentDeleted { CommentId = createCommand.CommentId })
);
}
private Task ExecuteDeleteAsync()
{
return sut.ExecuteAsync(CreateCommentsCommand(new DeleteComment()));
}
protected T CreateCommentsEvent<T>(T @event) where T : CommentsEvent
{
@event.CommentsId = commentsId;
return CreateEvent(@event);
}
protected T CreateCommentsCommand<T>(T command) where T : CommentsCommand
{
command.CommentsId = commentsId;
return CreateCommand(command);
}
}
}

164
tests/Squidex.Domain.Apps.Entities.Tests/Comments/Guards/GuardCommentsTests.cs

@ -0,0 +1,164 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Domain.Apps.Events.Comments;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Comments.Guards
{
public class GuardCommentsTests
{
private readonly RefToken user1 = new RefToken(RefTokenType.Subject, "1");
private readonly RefToken user2 = new RefToken(RefTokenType.Subject, "2");
[Fact]
public void CanCreate_should_throw_exception_if_text_not_defined()
{
var command = new CreateComment();
ValidationAssert.Throws(() => GuardComments.CanCreate(command),
new ValidationError("Text is required.", "Text"));
}
[Fact]
public void CanCreate_should_not_throw_exception_if_text_defined()
{
var command = new CreateComment { Text = "text" };
GuardComments.CanCreate(command);
}
[Fact]
public void CanUpdate_should_throw_exception_if_text_not_defined()
{
var commentId = Guid.NewGuid();
var command = new UpdateComment { CommentId = commentId, Actor = user1 };
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
ValidationAssert.Throws(() => GuardComments.CanUpdate(events, command),
new ValidationError("Text is required.", "Text"));
}
[Fact]
public void CanUpdate_should_throw_exception_if_comment_from_another_user()
{
var commentId = Guid.NewGuid();
var command = new UpdateComment { CommentId = commentId, Actor = user2, Text = "text2" };
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
Assert.Throws<DomainException>(() => GuardComments.CanUpdate(events, command));
}
[Fact]
public void CanUpdate_should_throw_exception_if_comment_not_found()
{
var commentId = Guid.NewGuid();
var command = new UpdateComment { CommentId = commentId, Actor = user1 };
var events = new List<Envelope<CommentsEvent>>();
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanUpdate(events, command));
}
[Fact]
public void CanUpdate_should_throw_exception_if_comment_deleted_found()
{
var commentId = Guid.NewGuid();
var command = new UpdateComment { CommentId = commentId, Actor = user1 };
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>(),
Envelope.Create(new CommentDeleted { CommentId = commentId }).To<CommentsEvent>(),
};
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanUpdate(events, command));
}
[Fact]
public void CanUpdate_should_not_throw_exception_if_comment_from_same_user()
{
var commentId = Guid.NewGuid();
var command = new UpdateComment { CommentId = commentId, Actor = user1, Text = "text2" };
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
GuardComments.CanUpdate(events, command);
}
[Fact]
public void CanDelete_should_throw_exception_if_comment_from_another_user()
{
var commentId = Guid.NewGuid();
var command = new DeleteComment { CommentId = commentId, Actor = user2 };
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
Assert.Throws<DomainException>(() => GuardComments.CanDelete(events, command));
}
[Fact]
public void CanDelete_should_throw_exception_if_comment_not_found()
{
var commentId = Guid.NewGuid();
var command = new DeleteComment { CommentId = commentId, Actor = user1 };
var events = new List<Envelope<CommentsEvent>>();
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanDelete(events, command));
}
[Fact]
public void CanDelete_should_throw_exception_if_comment_deleted()
{
var commentId = Guid.NewGuid();
var command = new DeleteComment { CommentId = commentId, Actor = user1 };
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>(),
Envelope.Create(new CommentDeleted { CommentId = commentId }).To<CommentsEvent>(),
};
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanDelete(events, command));
}
[Fact]
public void CanDelete_should_not_throw_exception_if_comment_from_same_user()
{
var commentId = Guid.NewGuid();
var command = new DeleteComment { CommentId = commentId, Actor = user1 };
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
GuardComments.CanDelete(events, command);
}
}
}
Loading…
Cancel
Save