Browse Source

Feature/current users (#774)

* Provide user role in context context.

* Also provide user for backwards compatibility.

* Show current users on resource.

* Fix tests.
pull/778/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
9dca3f9a47
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 11
      backend/extensions/Squidex.Extensions/APM/Stackdriver/StackdriverExceptionHandler.cs
  2. 4
      backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs
  3. 36
      backend/src/Squidex.Domain.Apps.Entities/Comments/GrainWatchingService.cs
  4. 17
      backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingGrain.cs
  5. 17
      backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingService.cs
  6. 88
      backend/src/Squidex.Domain.Apps.Entities/Comments/WatchingGrain.cs
  7. 58
      backend/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs
  8. 51
      backend/src/Squidex.Web/Pipeline/ActionContextLogAppender.cs
  9. 14
      backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs
  10. 41
      backend/src/Squidex/Areas/Api/Controllers/Comments/CommentsController.cs
  11. 7
      backend/src/Squidex/Config/Domain/CommentsServices.cs
  12. 46
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/GrainWatchingServiceTests.cs
  13. 72
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/WatchingGrainTests.cs
  14. 2
      frontend/app/features/content/pages/content/content-page.component.html
  15. 6
      frontend/app/features/content/pages/content/content-page.component.ts
  16. 6
      frontend/app/framework/angular/stateful.component.ts
  17. 9
      frontend/app/framework/utils/rxjs-extensions.ts
  18. 4
      frontend/app/shared/components/comments/comments.component.ts
  19. 11
      frontend/app/shared/components/watching-users.component.html
  20. 31
      frontend/app/shared/components/watching-users.component.scss
  21. 33
      frontend/app/shared/components/watching-users.component.ts
  22. 1
      frontend/app/shared/declarations.ts
  23. 4
      frontend/app/shared/module.ts
  24. 5
      frontend/app/shared/services/apps.service.spec.ts
  25. 6
      frontend/app/shared/services/apps.service.ts
  26. 23
      frontend/app/shared/services/auth.service.ts
  27. 19
      frontend/app/shared/services/comments.service.spec.ts
  28. 12
      frontend/app/shared/services/comments.service.ts

11
backend/extensions/Squidex.Extensions/APM/Stackdriver/StackdriverExceptionHandler.cs

@ -53,9 +53,16 @@ namespace Squidex.Extensions.APM.Stackdriver
public void Append(IObjectWriter writer, SemanticLogLevel logLevel, Exception exception)
{
if (exception != null && exception is not DomainException)
try
{
logger.Log(exception, httpContextWrapper);
if (exception != null && exception is not DomainException)
{
logger.Log(exception, httpContextWrapper);
}
}
catch
{
return;
}
}
}

4
backend/src/Squidex.Domain.Apps.Entities/Backup/BackupGrain.cs

@ -51,8 +51,8 @@ namespace Squidex.Domain.Apps.Entities.Backup
IEventStore eventStore,
IGrainState<BackupState> state,
IServiceProvider serviceProvider,
IUserResolver userResolver,
ISemanticLog log)
ISemanticLog log,
IUserResolver userResolver)
{
this.backupArchiveLocation = backupArchiveLocation;
this.backupArchiveStore = backupArchiveStore;

36
backend/src/Squidex.Domain.Apps.Entities/Comments/GrainWatchingService.cs

@ -0,0 +1,36 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Orleans;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Comments
{
public sealed class GrainWatchingService : IWatchingService
{
private readonly IGrainFactory grainFactory;
public GrainWatchingService(IGrainFactory grainFactory)
{
this.grainFactory = grainFactory;
}
public Task<string[]> GetWatchingUsersAsync(DomainId appId, string resource, string userId)
{
Guard.NotNullOrEmpty(resource, nameof(resource));
Guard.NotNullOrEmpty(userId, nameof(userId));
return GetGrain(appId).GetWatchingUsersAsync(resource, userId);
}
private IWatchingGrain GetGrain(DomainId appId)
{
return grainFactory.GetGrain<IWatchingGrain>(appId.ToString());
}
}
}

17
backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingGrain.cs

@ -0,0 +1,17 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Orleans;
namespace Squidex.Domain.Apps.Entities.Comments
{
public interface IWatchingGrain : IGrainWithStringKey
{
Task<string[]> GetWatchingUsersAsync(string resource, string userId);
}
}

17
backend/src/Squidex.Domain.Apps.Entities/Comments/IWatchingService.cs

@ -0,0 +1,17 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using System.Threading.Tasks;
namespace Squidex.Domain.Apps.Entities.Comments
{
public interface IWatchingService
{
Task<string[]> GetWatchingUsersAsync(DomainId appId, string resource, string userId);
}
}

88
backend/src/Squidex.Domain.Apps.Entities/Comments/WatchingGrain.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 System.Linq;
using System.Threading.Tasks;
using NodaTime;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
namespace Squidex.Domain.Apps.Entities.Comments
{
public sealed class WatchingGrain : GrainBase, IWatchingGrain
{
private static readonly Duration Timeout = Duration.FromMinutes(1);
private readonly Dictionary<string, Dictionary<string, Instant>> users = new Dictionary<string, Dictionary<string, Instant>>();
private readonly IClock clock;
public WatchingGrain(IClock clock)
{
this.clock = clock;
}
public override Task OnActivateAsync()
{
var time = TimeSpan.FromSeconds(30);
RegisterTimer(x =>
{
Cleanup();
return Task.CompletedTask;
}, null, time, time);
return Task.CompletedTask;
}
public Task<string[]> GetWatchingUsersAsync(string resource, string userId)
{
Guard.NotNullOrEmpty(resource, nameof(resource));
Guard.NotNullOrEmpty(userId, nameof(userId));
var usersByResource = users.GetOrAddNew(resource);
usersByResource[userId] = clock.GetCurrentInstant();
return Task.FromResult(usersByResource.Keys.ToArray());
}
public void Cleanup()
{
if (users.Count == 0)
{
return;
}
var now = clock.GetCurrentInstant();
foreach (var (resource, usersByResource) in users.ToList())
{
foreach (var (userId, lastSeen) in usersByResource.ToList())
{
var timeSinceLastSeen = now - lastSeen;
if (timeSinceLastSeen > Timeout)
{
usersByResource.Remove(userId);
}
}
if (usersByResource.Count == 0)
{
users.Remove(resource);
}
}
if (users.Count == 0)
{
TryDeactivateOnIdle();
}
}
}
}

58
backend/src/Squidex.Domain.Apps.Entities/Tags/TagGrain.cs

@ -26,6 +26,8 @@ namespace Squidex.Domain.Apps.Entities.Tags
public TagsExport Tags { get; set; } = new TagsExport();
}
public TagsExport Tags => state.Value.Tags;
public TagGrain(IGrainState<State> state)
{
this.state = state;
@ -53,28 +55,9 @@ namespace Squidex.Domain.Apps.Entities.Tags
{
if (!string.IsNullOrWhiteSpace(tag))
{
var tagName = tag.ToLowerInvariant();
var tagId = string.Empty;
var (key, value) = state.Value.Tags.FirstOrDefault(x => string.Equals(x.Value.Name, tagName, StringComparison.OrdinalIgnoreCase));
if (value != null)
{
tagId = key;
if (ids == null || !ids.Contains(tagId))
{
value.Count++;
}
}
else
{
tagId = DomainId.NewGuid().ToString();
var name = tag.ToLowerInvariant();
state.Value.Tags.Add(tagId, new Tag { Name = tagName });
}
result.Add(tagName, tagId);
result.Add(name, GetId(name, ids));
}
}
}
@ -85,13 +68,13 @@ namespace Squidex.Domain.Apps.Entities.Tags
{
if (!result.ContainsValue(id))
{
if (state.Value.Tags.TryGetValue(id, out var tagInfo))
if (Tags.TryGetValue(id, out var tagInfo))
{
tagInfo.Count--;
if (tagInfo.Count <= 0)
{
state.Value.Tags.Remove(id);
Tags.Remove(id);
}
}
}
@ -103,13 +86,34 @@ namespace Squidex.Domain.Apps.Entities.Tags
return result;
}
private string GetId(string name, HashSet<string>? ids)
{
var (id, value) = Tags.FirstOrDefault(x => string.Equals(x.Value.Name, name, StringComparison.OrdinalIgnoreCase));
if (value != null)
{
if (ids == null || !ids.Contains(id))
{
value.Count++;
}
}
else
{
id = DomainId.NewGuid().ToString();
Tags.Add(id, new Tag { Name = name });
}
return id;
}
public Task<Dictionary<string, string>> GetTagIdsAsync(HashSet<string> names)
{
var result = new Dictionary<string, string>();
foreach (var name in names)
{
var id = state.Value.Tags.FirstOrDefault(x => string.Equals(x.Value.Name, name, StringComparison.OrdinalIgnoreCase)).Key;
var (id, _) = Tags.FirstOrDefault(x => string.Equals(x.Value.Name, name, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(id))
{
@ -126,7 +130,7 @@ namespace Squidex.Domain.Apps.Entities.Tags
foreach (var id in ids)
{
if (state.Value.Tags.TryGetValue(id, out var tagInfo))
if (Tags.TryGetValue(id, out var tagInfo))
{
result[id] = tagInfo.Name;
}
@ -137,14 +141,14 @@ namespace Squidex.Domain.Apps.Entities.Tags
public Task<TagsSet> GetTagsAsync()
{
var tags = state.Value.Tags.Values.ToDictionary(x => x.Name, x => x.Count);
var tags = Tags.Values.ToDictionary(x => x.Name, x => x.Count);
return Task.FromResult(new TagsSet(tags, state.Version));
}
public Task<TagsExport> GetExportableTagsAsync()
{
return Task.FromResult(state.Value.Tags);
return Task.FromResult(Tags);
}
}
}

51
backend/src/Squidex.Web/Pipeline/ActionContextLogAppender.cs

@ -27,36 +27,43 @@ namespace Squidex.Web.Pipeline
public void Append(IObjectWriter writer, SemanticLogLevel logLevel, Exception? exception)
{
var httpContext = httpContextAccessor.HttpContext;
if (string.IsNullOrEmpty(httpContext?.Request?.Method))
try
{
return;
}
var requestId = GetRequestId(httpContext);
var httpContext = httpContextAccessor.HttpContext;
var logContext = (requestId, context: httpContext, actionContextAccessor);
if (string.IsNullOrEmpty(httpContext?.Request?.Method))
{
return;
}
writer.WriteObject("web", logContext, (ctx, w) =>
{
w.WriteProperty("requestId", ctx.requestId);
w.WriteProperty("requestPath", ctx.context.Request.Path);
w.WriteProperty("requestMethod", ctx.context.Request.Method);
var requestId = GetRequestId(httpContext);
var actionContext = ctx.actionContextAccessor.ActionContext;
var logContext = (requestId, context: httpContext, actionContextAccessor);
if (actionContext != null)
writer.WriteObject("web", logContext, (ctx, w) =>
{
w.WriteObject("routeValues", actionContext.ActionDescriptor.RouteValues, (routeValues, r) =>
w.WriteProperty("requestId", ctx.requestId);
w.WriteProperty("requestPath", ctx.context.Request.Path);
w.WriteProperty("requestMethod", ctx.context.Request.Method);
var actionContext = ctx.actionContextAccessor.ActionContext;
if (actionContext != null)
{
foreach (var (key, value) in routeValues)
w.WriteObject("routeValues", actionContext.ActionDescriptor.RouteValues, (routeValues, r) =>
{
r.WriteProperty(key, value);
}
});
}
});
foreach (var (key, value) in routeValues)
{
r.WriteProperty(key, value);
}
});
}
});
}
catch
{
return;
}
}
private static string GetRequestId(HttpContext httpContext)

14
backend/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs

@ -83,6 +83,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
/// </summary>
public bool CanAccessContent { get; set; }
/// <summary>
/// The role name of the user.
/// </summary>
public string? RoleName { get; set; }
/// <summary>
/// The properties from the role.
/// </summary>
@ -101,6 +106,15 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
{
isContributor = true;
result.RoleName = roleName;
result.RoleProperties = role.Properties;
result.Permissions = permissions.ToIds();
permissions = role.Permissions;
}
else if (app.Clients.TryGetValue(userId, out var client) && app.Roles.TryGet(app.Name, client.Role, isFrontend, out role))
{
result.RoleName = roleName;
result.RoleProperties = role.Properties;
result.Permissions = permissions.ToIds();

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

@ -15,6 +15,8 @@ using Squidex.Domain.Apps.Entities.Comments;
using Squidex.Domain.Apps.Entities.Comments.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.Translations;
using Squidex.Shared;
using Squidex.Web;
@ -27,11 +29,36 @@ namespace Squidex.Areas.Api.Controllers.Comments
public sealed class CommentsController : ApiController
{
private readonly ICommentsLoader commentsLoader;
private readonly IWatchingService watchingService;
public CommentsController(ICommandBus commandBus, ICommentsLoader commentsLoader)
public CommentsController(ICommandBus commandBus, ICommentsLoader commentsLoader,
IWatchingService watchingService)
: base(commandBus)
{
this.commentsLoader = commentsLoader;
this.watchingService = watchingService;
}
/// <summary>
/// Get all watching users..
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="resource">The path to the resource.</param>
/// <returns>
/// 200 => Watching users returned.
/// 404 => App not found.
/// </returns>
[HttpGet]
[Route("apps/{app}/watching/{*resource}")]
[ProducesResponseType(typeof(string[]), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous]
[ApiCosts(0)]
public async Task<IActionResult> GetWatchingUsers(string app, string? resource = null)
{
var result = await watchingService.GetWatchingUsersAsync(App.Id, resource ?? "all", UserId());
return Ok(result);
}
/// <summary>
@ -140,5 +167,17 @@ namespace Squidex.Areas.Api.Controllers.Comments
return NoContent();
}
private string UserId()
{
var subject = User.OpenIdSubject();
if (string.IsNullOrWhiteSpace(subject))
{
throw new DomainForbiddenException(T.Get("common.httpOnlyAsUser"));
}
return subject;
}
}
}

7
backend/src/Squidex/Config/Domain/CommentsServices.cs

@ -1,4 +1,4 @@
// ==========================================================================
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
@ -14,8 +14,11 @@ namespace Squidex.Config.Domain
{
public static void AddSquidexComments(this IServiceCollection services)
{
services.AddSingletonAs<GrainWatchingService>()
.As<IWatchingService>();
services.AddSingletonAs<CommentsLoader>()
.As<ICommentsLoader>();
}
}
}
}

46
backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/GrainWatchingServiceTests.cs

@ -0,0 +1,46 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using FakeItEasy;
using Orleans;
using Squidex.Infrastructure;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Comments
{
public class GrainWatchingServiceTests
{
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>();
private readonly GrainWatchingService sut;
public GrainWatchingServiceTests()
{
sut = new GrainWatchingService(grainFactory);
}
[Fact]
public async Task Should_call_grain_if_retrieving_watching_users()
{
var appId = DomainId.NewGuid();
var userResource = "resource1";
var userIdentity = "user1";
var grain = A.Fake<IWatchingGrain>();
A.CallTo(() => grainFactory.GetGrain<IWatchingGrain>(appId.ToString(), null))
.Returns(grain);
A.CallTo(() => grain.GetWatchingUsersAsync(userResource, userIdentity))
.Returns(Task.FromResult(new[] { "user1", "user2" }));
var result = await sut.GetWatchingUsersAsync(appId, userResource, userIdentity);
Assert.Equal(new[] { "user1", "user2" }, result);
}
}
}

72
backend/tests/Squidex.Domain.Apps.Entities.Tests/Comments/WatchingGrainTests.cs

@ -0,0 +1,72 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using FakeItEasy;
using NodaTime;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Comments
{
public class WatchingGrainTests
{
private readonly IClock clock = A.Fake<IClock>();
private readonly WatchingGrain sut;
private Instant now = SystemClock.Instance.GetCurrentInstant();
public WatchingGrainTests()
{
A.CallTo(() => clock.GetCurrentInstant())
.ReturnsLazily(() => now);
sut = new WatchingGrain(clock);
}
[Fact]
public async Task Should_only_return_self_if_no_one_watching()
{
var watching = await sut.GetWatchingUsersAsync("resource1", "user1");
Assert.Equal(new[] { "user1" }, watching);
}
[Fact]
public async Task Should_return_users_watching_on_same_resource()
{
await sut.GetWatchingUsersAsync("resource1", "user1");
await sut.GetWatchingUsersAsync("resource2", "user2");
var watching1 = await sut.GetWatchingUsersAsync("resource1", "user3");
var watching2 = await sut.GetWatchingUsersAsync("resource2", "user4");
Assert.Equal(new[] { "user1", "user3" }, watching1);
Assert.Equal(new[] { "user2", "user4" }, watching2);
}
[Fact]
public async Task Should_cleanup_old_users()
{
await sut.GetWatchingUsersAsync("resource1", "user1");
await sut.GetWatchingUsersAsync("resource2", "user2");
now = now.Plus(Duration.FromMinutes(2));
sut.Cleanup();
await sut.GetWatchingUsersAsync("resource1", "user3");
await sut.GetWatchingUsersAsync("resource2", "user4");
sut.Cleanup();
var watching1 = await sut.GetWatchingUsersAsync("resource1", "user5");
var watching2 = await sut.GetWatchingUsersAsync("resource2", "user6");
Assert.Equal(new[] { "user3", "user5" }, watching1);
Assert.Equal(new[] { "user4", "user6" }, watching2);
}
}
}

2
frontend/app/features/content/pages/content/content-page.component.html

@ -51,6 +51,8 @@
<ng-container menu>
<div class="menu">
<ng-container *ngIf="content; else noContentMenu">
<sqx-watching-users resource="{{schema.id}}/{{content.id}}"></sqx-watching-users>
<sqx-notifo topic="apps/{{contentsState.appId}}/schemas/{{schema?.name}}/contents/{{content.id}}"></sqx-notifo>
<ng-container *ngIf="languages.length > 1">

6
frontend/app/features/content/pages/content/content-page.component.ts

@ -57,11 +57,13 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
) {
super();
const role = appsState.snapshot.selectedApp?.roleName;
this.formContext = {
apiUrl: apiUrl.buildUrl('api'),
appId: contentsState.appId,
appName: appsState.appName,
user: authService.user,
appName: contentsState.appName,
user: { role, ...authService.user?.export() },
};
}

6
frontend/app/framework/angular/stateful.component.ts

@ -7,8 +7,8 @@
import { ChangeDetectorRef, Directive, OnDestroy } from '@angular/core';
import { ControlValueAccessor } from '@angular/forms';
import { Observable, Subscription } from 'rxjs';
import { onErrorResumeNext, skip } from 'rxjs/operators';
import { EMPTY, Observable, Subscription } from 'rxjs';
import { catchError, skip } from 'rxjs/operators';
import { State } from './../state';
import { Types } from './../utils/types';
@ -23,7 +23,7 @@ export class ResourceOwner implements OnDestroy {
if (Types.isFunction(subscription['subscribe'])) {
const observable = <Observable<T>>subscription;
this.subscriptions.push(observable.pipe(onErrorResumeNext()).subscribe());
this.subscriptions.push(observable.pipe(catchError(_ => EMPTY)).subscribe());
} else {
this.subscriptions.push(<any>subscription);
}

9
frontend/app/framework/utils/rxjs-extensions.ts

@ -69,7 +69,14 @@ export function defined<T>() {
export function switchSafe<T, R>(project: (source: T) => Observable<R>) {
return function mapOperation(source: Observable<T>) {
return source.pipe(switchMap(project), onErrorResumeNext());
return source.pipe(
switchMap(x => {
try {
return project(x).pipe(catchError(_ => EMPTY));
} catch {
return EMPTY;
}
}));
};
}

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

@ -8,10 +8,10 @@
import { ChangeDetectorRef, Component, ElementRef, Input, OnChanges, QueryList, ViewChild, ViewChildren } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { Router } from '@angular/router';
import { switchSafe } from '@app/framework';
import { AppsState, AuthService, CommentDto, CommentsService, CommentsState, ContributorsState, DialogService, ResourceOwner, UpsertCommentForm } from '@app/shared/internal';
import { MentionConfig } from 'angular-mentions';
import { timer } from 'rxjs';
import { onErrorResumeNext, switchMap } from 'rxjs/operators';
import { CommentComponent } from './comment.component';
@Component({
@ -58,7 +58,7 @@ export class CommentsComponent extends ResourceOwner implements OnChanges {
this.commentsUrl = `apps/${this.appsState.appName}/comments/${this.commentsId}`;
this.commentsState = new CommentsState(this.commentsUrl, this.commentsService, this.dialogs);
this.own(timer(0, 4000).pipe(switchMap(() => this.commentsState.load(true).pipe(onErrorResumeNext()))));
this.own(timer(0, 4000).pipe(switchSafe(() => this.commentsState.load(true))));
}
public scrollDown() {

11
frontend/app/shared/components/watching-users.component.html

@ -0,0 +1,11 @@
<ng-container *ngIf="users | async; let currentUsers">
<ng-container *ngIf="currentUsers.length > 1">
<div class="user-item" *ngFor="let userId of currentUsers | slice:0:5; let i = index" [style]="{ zIndex: i }">
<img class="user-picture" title="{{userId | sqxUserName}}" [src]="userId | sqxUserPicture">
</div>
<div class="user-item" *ngIf="currentUsers.length > 5" [style]="{ zIndex: 6 }">
<div class="user-more">+{{currentUsers.length - 5}}</div>
</div>
</ng-container>
</ng-container>

31
frontend/app/shared/components/watching-users.component.scss

@ -0,0 +1,31 @@
:host {
display: flex;
align-items: center;
margin-right: .5rem;
}
.user-item {
display: inline-block;
margin-right: 0;
margin-left: -1.1rem;
position: relative;
&:hover {
z-index: 1000 !important;
}
.user-picture,
.user-more {
@include circle(2.3rem);
border: 2px solid $color-white;
}
.user-more {
background: $color-border;
font-size: 90%;
font-weight: bolder;
line-height: 2rem;
text-align: center;
z-index: 6;
}
}

33
frontend/app/shared/components/watching-users.component.ts

@ -0,0 +1,33 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { AppsState, CommentsService, switchSafe } from '@app/shared/internal';
import { timer } from 'rxjs';
@Component({
selector: 'sqx-watching-users[resource]',
styleUrls: ['./watching-users.component.scss'],
templateUrl: './watching-users.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WatchingUsersComponent {
private appName: string;
@Input()
public resource: string;
public users =
timer(0, 5000).pipe(
switchSafe((() => this.commentsService.getWatchingUsers(this.appName, this.resource))));
constructor(appsState: AppsState,
private readonly commentsService: CommentsService,
) {
this.appName = appsState.appName;
}
}

1
frontend/app/shared/declarations.ts

@ -56,6 +56,7 @@ export * from './components/search/query-list.component';
export * from './components/search/search-form.component';
export * from './components/search/shared-queries.component';
export * from './components/table-header.component';
export * from './components/watching-users.component';
export * from './guards/app-must-exist.guard';
export * from './guards/content-must-exist.guard';
export * from './guards/load-apps.guard';

4
frontend/app/shared/module.ts

@ -12,7 +12,7 @@ import { RouterModule } from '@angular/router';
import { SqxFrameworkModule } from '@app/framework';
import { MentionModule } from 'angular-mentions';
import { NgxDocViewerModule } from 'ngx-doc-viewer';
import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, AppsState, AssetComponent, AssetDialogComponent, AssetFolderComponent, AssetFolderDialogComponent, AssetFolderDropdownComponent, AssetFolderDropdownItemComponent, AssetHistoryComponent, AssetPathComponent, AssetPreviewUrlPipe, AssetScriptsState, AssetsListComponent, AssetsSelectorComponent, AssetsService, AssetsState, AssetTextEditorComponent, AssetUploaderComponent, AssetUploaderState, AssetUrlPipe, AuthInterceptor, AuthService, AutoSaveService, BackupsService, BackupsState, ClientsService, ClientsState, CommentComponent, CommentsComponent, CommentsService, ContentListCellDirective, ContentListFieldComponent, ContentListHeaderComponent, ContentListWidthPipe, ContentMustExistGuard, ContentsColumnsPipe, ContentSelectorComponent, ContentSelectorItemComponent, ContentsService, ContentsState, ContentStatusComponent, ContentValueComponent, ContentValueEditorComponent, ContributorsService, ContributorsState, FileIconPipe, FilterComparisonComponent, FilterLogicalComponent, FilterNodeComponent, GeolocationEditorComponent, GraphQlService, HelpComponent, HelpMarkdownPipe, HelpService, HistoryComponent, HistoryListComponent, HistoryMessagePipe, HistoryService, ImageCropperComponent, ImageFocusPointComponent, LanguagesService, LanguagesState, LoadAppsGuard, LoadLanguagesGuard, LoadSchemasGuard, MarkdownEditorComponent, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, NewsService, NotifoComponent, PlansService, PlansState, PreviewableType, QueryComponent, QueryListComponent, QueryPathComponent, ReferenceDropdownComponent, ReferenceInputComponent, ReferencesCheckboxesComponent, ReferencesTagsComponent, RichEditorComponent, RolesService, RolesState, RuleEventsState, RuleMustExistGuard, RuleSimulatorState, RulesService, RulesState, SavedQueriesComponent, SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SchemasService, SchemasState, SchemaTagSource, SearchFormComponent, SearchService, SortingComponent, StockPhotoService, TableHeaderComponent, TranslationsService, UIService, UIState, UnsetAppGuard, UsagesService, UserDtoPicture, UserIdPicturePipe, UserNamePipe, UserNameRefPipe, UserPicturePipe, UserPictureRefPipe, UsersProviderService, UsersService, WorkflowsService, WorkflowsState } from './declarations';
import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, AppsState, AssetComponent, AssetDialogComponent, AssetFolderComponent, AssetFolderDialogComponent, AssetFolderDropdownComponent, AssetFolderDropdownItemComponent, AssetHistoryComponent, AssetPathComponent, AssetPreviewUrlPipe, AssetScriptsState, AssetsListComponent, AssetsSelectorComponent, AssetsService, AssetsState, AssetTextEditorComponent, AssetUploaderComponent, AssetUploaderState, AssetUrlPipe, AuthInterceptor, AuthService, AutoSaveService, BackupsService, BackupsState, ClientsService, ClientsState, CommentComponent, CommentsComponent, CommentsService, ContentListCellDirective, ContentListFieldComponent, ContentListHeaderComponent, ContentListWidthPipe, ContentMustExistGuard, ContentsColumnsPipe, ContentSelectorComponent, ContentSelectorItemComponent, ContentsService, ContentsState, ContentStatusComponent, ContentValueComponent, ContentValueEditorComponent, ContributorsService, ContributorsState, FileIconPipe, FilterComparisonComponent, FilterLogicalComponent, FilterNodeComponent, GeolocationEditorComponent, GraphQlService, HelpComponent, HelpMarkdownPipe, HelpService, HistoryComponent, HistoryListComponent, HistoryMessagePipe, HistoryService, ImageCropperComponent, ImageFocusPointComponent, LanguagesService, LanguagesState, LoadAppsGuard, LoadLanguagesGuard, LoadSchemasGuard, MarkdownEditorComponent, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, NewsService, NotifoComponent, PlansService, PlansState, PreviewableType, QueryComponent, QueryListComponent, QueryPathComponent, ReferenceDropdownComponent, ReferenceInputComponent, ReferencesCheckboxesComponent, ReferencesTagsComponent, RichEditorComponent, RolesService, RolesState, RuleEventsState, RuleMustExistGuard, RuleSimulatorState, RulesService, RulesState, SavedQueriesComponent, SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SchemasService, SchemasState, SchemaTagSource, SearchFormComponent, SearchService, SortingComponent, StockPhotoService, TableHeaderComponent, TranslationsService, UIService, UIState, UnsetAppGuard, UsagesService, UserDtoPicture, UserIdPicturePipe, UserNamePipe, UserNameRefPipe, UserPicturePipe, UserPictureRefPipe, UsersProviderService, UsersService, WatchingUsersComponent, WorkflowsService, WorkflowsState } from './declarations';
@NgModule({
imports: [
@ -84,6 +84,7 @@ import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService,
UserNameRefPipe,
UserPicturePipe,
UserPictureRefPipe,
WatchingUsersComponent,
],
exports: [
AppFormComponent,
@ -138,6 +139,7 @@ import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService,
UserNameRefPipe,
UserPicturePipe,
UserPictureRefPipe,
WatchingUsersComponent,
],
})
export class SqxSharedModule {

5
frontend/app/shared/services/apps.service.spec.ts

@ -333,8 +333,7 @@ describe('AppsService', () => {
permissions: ['Owner'],
canAccessApi: id % 2 === 0,
canAccessContent: id % 2 === 0,
planName: 'Free',
planUpgrade: 'Basic',
roleName: `Role${id}`,
roleProperties: createProperties(id),
_links: {
update: { method: 'PUT', href: `apps/${id}` },
@ -394,7 +393,7 @@ export function createApp(id: number, suffix = '') {
['Owner'],
id % 2 === 0,
id % 2 === 0,
'Free', 'Basic',
`Role${id}`,
createProperties(id));
}

6
frontend/app/shared/services/apps.service.ts

@ -47,8 +47,7 @@ export class AppDto {
public readonly permissions: ReadonlyArray<string>,
public readonly canAccessApi: boolean,
public readonly canAccessContent: boolean,
public readonly planName: string | undefined,
public readonly planUpgrade: string | undefined,
public readonly roleName: string | undefined,
public readonly roleProperties: {},
) {
this._links = links;
@ -326,8 +325,7 @@ function parseApp(response: any) {
response.permissions,
response.canAccessApi,
response.canAccessContent,
response.planName,
response.planUpgrade,
response.roleName,
response.roleProperties);
}

23
frontend/app/shared/services/auth.service.ts

@ -52,6 +52,29 @@ export class Profile {
public readonly user: User,
) {
}
public export() {
const result: any = {
id: this.id,
accessToken: this.accessToken,
accessCode: this.accessToken,
authorization: this.authorization,
displayName: this.displayName,
email: this.email,
notifoEnabled: !!this.notifoToken,
notifoToken: this.notifoToken,
picture: this.pictureUrl,
pictureUrl: this.pictureUrl,
token: this.token,
user: this.user,
};
for (const key of Object.keys(this.user.profile)) {
result[key] = this.user.profile[key];
}
return result;
}
}
@Injectable()

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

@ -28,6 +28,25 @@ describe('CommentsService', () => {
httpMock.verify();
}));
it('should make get request to get watching users',
inject([CommentsService, HttpTestingController], (commentsService: CommentsService, httpMock: HttpTestingController) => {
let userIds: ReadonlyArray<string>;
commentsService.getWatchingUsers('my-app', 'my-resource').subscribe(result => {
userIds = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/watching/my-resource');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
expect(req.request.headers.get('X-Silent')).toBe('1');
req.flush(['user1', 'user2']);
expect(userIds!).toEqual(['user1', 'user2']);
}));
it('should make get request to get comments',
inject([CommentsService, HttpTestingController], (commentsService: CommentsService, httpMock: HttpTestingController) => {
let comments: CommentsDto;

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

@ -45,6 +45,18 @@ export class CommentsService {
) {
}
public getWatchingUsers(appId: string, resource: string): Observable<ReadonlyArray<string>> {
const url = this.apiUrl.buildUrl(`api/apps/${appId}/watching/${resource}`);
const options = {
headers: new HttpHeaders({
'X-Silent': '1',
}),
};
return this.http.get<ReadonlyArray<string>>(url, options);
}
public getComments(commentsUrl: string, version: Version): Observable<CommentsDto> {
const url = this.apiUrl.buildUrl(`api/${commentsUrl}?version=${version.value}`);

Loading…
Cancel
Save