Browse Source

Merge branch 'feature/user-settings'

pull/415/head
Sebastian Stehle 6 years ago
parent
commit
1f8359b9f0
  1. 2
      README.md
  2. 67
      src/Squidex.Domain.Apps.Entities/Apps/AppUISettings.cs
  3. 23
      src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs
  4. 9
      src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs
  5. 24
      src/Squidex.Domain.Apps.Entities/Apps/IAppUISettings.cs
  6. 2
      src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs
  7. 87
      src/Squidex/Areas/Api/Controllers/UI/UIController.cs
  8. 3
      src/Squidex/Config/Domain/EntitiesServices.cs
  9. 28
      src/Squidex/app/features/assets/pages/assets-filters-page.component.html
  10. 16
      src/Squidex/app/features/assets/pages/assets-filters-page.component.ts
  11. 2
      src/Squidex/app/features/content/pages/content/content-page.component.html
  12. 4
      src/Squidex/app/features/content/pages/content/content-page.component.ts
  13. 34
      src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html
  14. 12
      src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts
  15. 2
      src/Squidex/app/features/content/shared/content-item.component.html
  16. 3
      src/Squidex/app/features/content/shared/content-item.component.ts
  17. 4
      src/Squidex/app/features/content/shared/due-time-selector.component.html
  18. 2
      src/Squidex/app/features/schemas/pages/schema/field.component.html
  19. 3
      src/Squidex/app/features/schemas/pages/schema/field.component.ts
  20. 2
      src/Squidex/app/features/schemas/pages/schema/schema-page.component.html
  21. 4
      src/Squidex/app/features/schemas/pages/schema/schema-page.component.ts
  22. 66
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html
  23. 10
      src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss
  24. 8
      src/Squidex/app/framework/angular/forms/form-alert.component.ts
  25. 35
      src/Squidex/app/framework/utils/types.spec.ts
  26. 21
      src/Squidex/app/framework/utils/types.ts
  27. 87
      src/Squidex/app/shared/components/saved-queries.component.ts
  28. 7
      src/Squidex/app/shared/components/search-form.component.html
  29. 2
      src/Squidex/app/shared/components/search-form.component.ts
  30. 1
      src/Squidex/app/shared/declarations.ts
  31. 3
      src/Squidex/app/shared/module.ts
  32. 78
      src/Squidex/app/shared/services/ui.service.spec.ts
  33. 27
      src/Squidex/app/shared/services/ui.service.ts
  34. 3
      src/Squidex/app/shared/state/contents.forms.ts
  35. 85
      src/Squidex/app/shared/state/queries.spec.ts
  36. 43
      src/Squidex/app/shared/state/queries.ts
  37. 149
      src/Squidex/app/shared/state/ui.state.spec.ts
  38. 158
      src/Squidex/app/shared/state/ui.state.ts
  39. 24
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs
  40. 76
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsTests.cs

2
README.md

@ -1,6 +1,6 @@
3![Squidex Logo](https://raw.githubusercontent.com/Squidex/squidex/master/media/logo-wide.png "Squidex") 3![Squidex Logo](https://raw.githubusercontent.com/Squidex/squidex/master/media/logo-wide.png "Squidex")
# What is Squidex? # What is Squidex??
Squidex is an open source headless CMS and content management hub. In contrast to a traditional CMS Squidex provides a rich API with OData filter and Swagger definitions. It is up to you to build your UI on top of it. It can be website, a native app or just another server. We build it with ASP.NET Core and CQRS and is tested for Windows and Linux on modern browsers. Squidex is an open source headless CMS and content management hub. In contrast to a traditional CMS Squidex provides a rich API with OData filter and Swagger definitions. It is up to you to build your UI on top of it. It can be website, a native app or just another server. We build it with ASP.NET Core and CQRS and is tested for Windows and Linux on modern browsers.

67
src/Squidex.Domain.Apps.Entities/Apps/AppUISettings.cs

@ -0,0 +1,67 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Orleans;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Orleans;
namespace Squidex.Domain.Apps.Entities.Apps
{
public sealed class AppUISettings : IAppUISettings
{
private readonly IGrainFactory grainFactory;
public AppUISettings(IGrainFactory grainFactory)
{
Guard.NotNull(grainFactory, nameof(grainFactory));
this.grainFactory = grainFactory;
}
public async Task<JsonObject> GetAsync(Guid appId, string userId)
{
var result = await GetGrain(appId, userId).GetAsync();
return result.Value;
}
public Task RemoveAsync(Guid appId, string userId, string path)
{
return GetGrain(appId, userId).RemoveAsync(path);
}
public Task SetAsync(Guid appId, string userId, string path, IJsonValue value)
{
return GetGrain(appId, userId).SetAsync(path, value.AsJ());
}
public Task SetAsync(Guid appId, string userId, JsonObject settings)
{
return GetGrain(appId, userId).SetAsync(settings.AsJ());
}
private IAppUISettingsGrain GetGrain(Guid appId, string userId)
{
return grainFactory.GetGrain<IAppUISettingsGrain>(Key(appId, userId));
}
private string Key(Guid appId, string userId)
{
if (!string.IsNullOrWhiteSpace(userId))
{
return $"{appId}_{userId}";
}
else
{
return $"{appId}";
}
}
}
}

23
src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs

@ -15,7 +15,7 @@ using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Apps namespace Squidex.Domain.Apps.Entities.Apps
{ {
public sealed class AppUISettingsGrain : GrainOfGuid, IAppUISettingsGrain public sealed class AppUISettingsGrain : GrainOfString, IAppUISettingsGrain
{ {
private readonly IGrainState<GrainState> state; private readonly IGrainState<GrainState> state;
@ -46,7 +46,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
public Task SetAsync(string path, J<IJsonValue> value) public Task SetAsync(string path, J<IJsonValue> value)
{ {
var container = GetContainer(path, out var key); var container = GetContainer(path, true, out var key);
if (container == null) if (container == null)
{ {
@ -58,19 +58,19 @@ namespace Squidex.Domain.Apps.Entities.Apps
return state.WriteAsync(); return state.WriteAsync();
} }
public Task RemoveAsync(string path) public async Task RemoveAsync(string path)
{ {
var container = GetContainer(path, out var key); var container = GetContainer(path, false, out var key);
if (container != null) if (container?.ContainsKey(key) == true)
{ {
container.Remove(key); container.Remove(key);
}
return state.WriteAsync(); await state.WriteAsync();
}
} }
private JsonObject GetContainer(string path, out string key) private JsonObject GetContainer(string path, bool add, out string key)
{ {
Guard.NotNullOrEmpty(path, nameof(path)); Guard.NotNullOrEmpty(path, nameof(path));
@ -85,11 +85,18 @@ namespace Squidex.Domain.Apps.Entities.Apps
foreach (var segment in segments.Take(segments.Length - 1)) foreach (var segment in segments.Take(segments.Length - 1))
{ {
if (!current.TryGetValue(segment, out var temp)) if (!current.TryGetValue(segment, out var temp))
{
if (add)
{ {
temp = JsonValue.Object(); temp = JsonValue.Object();
current[segment] = temp; current[segment] = temp;
} }
else
{
return null;
}
}
if (temp is JsonObject next) if (temp is JsonObject next)
{ {

9
src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs

@ -26,6 +26,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
private const string UsersFile = "Users.json"; private const string UsersFile = "Users.json";
private const string SettingsFile = "Settings.json"; private const string SettingsFile = "Settings.json";
private readonly IGrainFactory grainFactory; private readonly IGrainFactory grainFactory;
private readonly IAppUISettings appUISettings;
private readonly IUserResolver userResolver; private readonly IUserResolver userResolver;
private readonly IAppsByNameIndex appsByNameIndex; private readonly IAppsByNameIndex appsByNameIndex;
private readonly HashSet<string> contributors = new HashSet<string>(); private readonly HashSet<string> contributors = new HashSet<string>();
@ -36,12 +37,14 @@ namespace Squidex.Domain.Apps.Entities.Apps
public override string Name { get; } = "Apps"; public override string Name { get; } = "Apps";
public BackupApps(IGrainFactory grainFactory, IUserResolver userResolver) public BackupApps(IGrainFactory grainFactory, IAppUISettings appUISettings, IUserResolver userResolver)
{ {
Guard.NotNull(grainFactory, nameof(grainFactory)); Guard.NotNull(grainFactory, nameof(grainFactory));
Guard.NotNull(userResolver, nameof(userResolver)); Guard.NotNull(userResolver, nameof(userResolver));
Guard.NotNull(appUISettings, nameof(appUISettings));
this.grainFactory = grainFactory; this.grainFactory = grainFactory;
this.appUISettings = appUISettings;
this.userResolver = userResolver; this.userResolver = userResolver;
appsByNameIndex = grainFactory.GetGrain<IAppsByNameIndex>(SingleGrain.Id); appsByNameIndex = grainFactory.GetGrain<IAppsByNameIndex>(SingleGrain.Id);
@ -182,7 +185,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
private async Task WriteSettingsAsync(BackupWriter writer, Guid appId) private async Task WriteSettingsAsync(BackupWriter writer, Guid appId)
{ {
var json = await grainFactory.GetGrain<IAppUISettingsGrain>(appId).GetAsync(); var json = await appUISettings.GetAsync(appId, null);
await writer.WriteJsonAsync(SettingsFile, json); await writer.WriteJsonAsync(SettingsFile, json);
} }
@ -191,7 +194,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
{ {
var json = await reader.ReadJsonAttachmentAsync<JsonObject>(SettingsFile); var json = await reader.ReadJsonAttachmentAsync<JsonObject>(SettingsFile);
await grainFactory.GetGrain<IAppUISettingsGrain>(appId).SetAsync(json); await appUISettings.SetAsync(appId, null, json);
} }
public override async Task CompleteRestoreAsync(Guid appId, BackupReader reader) public override async Task CompleteRestoreAsync(Guid appId, BackupReader reader)

24
src/Squidex.Domain.Apps.Entities/Apps/IAppUISettings.cs

@ -0,0 +1,24 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Entities.Apps
{
public interface IAppUISettings
{
Task<JsonObject> GetAsync(Guid appId, string userId);
Task SetAsync(Guid appId, string userId, string path, IJsonValue value);
Task SetAsync(Guid appId, string userId, JsonObject settings);
Task RemoveAsync(Guid appId, string userId, string path);
}
}

2
src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs

@ -12,7 +12,7 @@ using Squidex.Infrastructure.Orleans;
namespace Squidex.Domain.Apps.Entities.Apps namespace Squidex.Domain.Apps.Entities.Apps
{ {
public interface IAppUISettingsGrain : IGrainWithGuidKey public interface IAppUISettingsGrain : IGrainWithStringKey
{ {
Task<J<JsonObject>> GetAsync(); Task<J<JsonObject>> GetAsync();

87
src/Squidex/Areas/Api/Controllers/UI/UIController.cs

@ -9,11 +9,10 @@ using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Orleans;
using Squidex.Areas.Api.Controllers.UI.Models; using Squidex.Areas.Api.Controllers.UI.Models;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Security;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Web; using Squidex.Web;
@ -24,14 +23,14 @@ namespace Squidex.Areas.Api.Controllers.UI
{ {
private static readonly Permission CreateAppPermission = new Permission(Permissions.AdminAppCreate); private static readonly Permission CreateAppPermission = new Permission(Permissions.AdminAppCreate);
private readonly MyUIOptions uiOptions; private readonly MyUIOptions uiOptions;
private readonly IGrainFactory grainFactory; private readonly IAppUISettings appUISettings;
public UIController(ICommandBus commandBus, IOptions<MyUIOptions> uiOptions, IGrainFactory grainFactory) public UIController(ICommandBus commandBus, IOptions<MyUIOptions> uiOptions, IAppUISettings appUISettings)
: base(commandBus) : base(commandBus)
{ {
this.uiOptions = uiOptions.Value; this.uiOptions = uiOptions.Value;
this.grainFactory = grainFactory; this.appUISettings = appUISettings;
} }
/// <summary> /// <summary>
@ -68,9 +67,28 @@ namespace Squidex.Areas.Api.Controllers.UI
[ApiPermission] [ApiPermission]
public async Task<IActionResult> GetSettings(string app) public async Task<IActionResult> GetSettings(string app)
{ {
var result = await grainFactory.GetGrain<IAppUISettingsGrain>(AppId).GetAsync(); var result = await appUISettings.GetAsync(AppId, null);
return Ok(result.Value); return Ok(result);
}
/// <summary>
/// Get my ui settings.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => UI settings returned.
/// 404 => App not found.
/// </returns>
[HttpGet]
[Route("apps/{app}/ui/settings/me")]
[ProducesResponseType(typeof(Dictionary<string, string>), 200)]
[ApiPermission]
public async Task<IActionResult> GetUserSettings(string app)
{
var result = await appUISettings.GetAsync(AppId, UserId());
return Ok(result);
} }
/// <summary> /// <summary>
@ -88,7 +106,27 @@ namespace Squidex.Areas.Api.Controllers.UI
[ApiPermission] [ApiPermission]
public async Task<IActionResult> PutSetting(string app, string key, [FromBody] UpdateSettingDto request) public async Task<IActionResult> PutSetting(string app, string key, [FromBody] UpdateSettingDto request)
{ {
await grainFactory.GetGrain<IAppUISettingsGrain>(AppId).SetAsync(key, request.Value.AsJ()); await appUISettings.SetAsync(AppId, null, key, request.Value);
return NoContent();
}
/// <summary>
/// Set my ui settings.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="key">The name of the setting.</param>
/// <param name="request">The request with the value to update.</param>
/// <returns>
/// 200 => UI setting set.
/// 404 => App not found.
/// </returns>
[HttpPut]
[Route("apps/{app}/ui/settings/me/{key}")]
[ApiPermission]
public async Task<IActionResult> PutUserSetting(string app, string key, [FromBody] UpdateSettingDto request)
{
await appUISettings.SetAsync(AppId, UserId(), key, request.Value);
return NoContent(); return NoContent();
} }
@ -107,9 +145,40 @@ namespace Squidex.Areas.Api.Controllers.UI
[ApiPermission] [ApiPermission]
public async Task<IActionResult> DeleteSetting(string app, string key) public async Task<IActionResult> DeleteSetting(string app, string key)
{ {
await grainFactory.GetGrain<IAppUISettingsGrain>(AppId).RemoveAsync(key); await appUISettings.RemoveAsync(AppId, null, key);
return NoContent(); return NoContent();
} }
/// <summary>
/// Remove my ui settings.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="key">The name of the setting.</param>
/// <returns>
/// 200 => UI setting removed.
/// 404 => App not found.
/// </returns>
[HttpDelete]
[Route("apps/{app}/ui/settings/me/{key}")]
[ApiPermission]
public async Task<IActionResult> DeleteUserSetting(string app, string key)
{
await appUISettings.RemoveAsync(AppId, UserId(), key);
return NoContent();
}
private string UserId()
{
var subject = User.OpenIdSubject();
if (string.IsNullOrWhiteSpace(subject))
{
throw new DomainForbiddenException("Not allowed for clients.");
}
return subject;
}
} }
} }

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

@ -96,6 +96,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<AppProvider>() services.AddSingletonAs<AppProvider>()
.As<IAppProvider>(); .As<IAppProvider>();
services.AddSingletonAs<AppUISettings>()
.As<IAppUISettings>();
services.AddSingletonAs<AssetEnricher>() services.AddSingletonAs<AssetEnricher>()
.As<IAssetEnricher>(); .As<IAssetEnricher>();

28
src/Squidex/app/features/assets/pages/assets-filters-page.component.html

@ -28,28 +28,10 @@
<hr /> <hr />
<div class="sidebar-section"> <sqx-shared-queries types="contents"
<h3>Saved queries</h3> [queries]="assetsQueries"
[queryUsed]="isQueryUsed"
<ng-container *ngIf="queries.queries | async; let assetQueries"> (search)="search($event.query)">
<ng-container *ngIf="assetQueries.length > 0; else noQuery"> </sqx-shared-queries>
<a class="sidebar-item" *ngFor="let saved of assetQueries; trackBy: trackByQuery" (click)="search(saved.query)"
[class.active]="isSelectedQuery(saved)">
{{saved.name}}
<a class="sidebar-item-remove float-right" (click)="queries.remove(saved)" sqxStopClick>
<i class="icon-close"></i>
</a>
</a>
</ng-container>
<ng-template #noQuery>
<div class="sidebar-item text-muted">
Search for assets and use <i class="icon-star-empty"></i> icon in search form to save query for all contributors.
</div>
</ng-template>
</ng-container>
</div>
</ng-container> </ng-container>
</sqx-panel> </sqx-panel>

16
src/Squidex/app/features/assets/pages/assets-filters-page.component.ts

@ -21,7 +21,7 @@ import {
templateUrl: './assets-filters-page.component.html' templateUrl: './assets-filters-page.component.html'
}) })
export class AssetsFiltersPageComponent { export class AssetsFiltersPageComponent {
public queries = new Queries(this.uiState, 'assets'); public assetsQueries = new Queries(this.uiState, 'assets');
constructor( constructor(
public readonly assetsState: AssetsState, public readonly assetsState: AssetsState,
@ -29,6 +29,10 @@ export class AssetsFiltersPageComponent {
) { ) {
} }
public isQueryUsed = (query: SavedQuery) => {
return this.assetsState.isQueryUsed(query);
}
public search(query: Query) { public search(query: Query) {
this.assetsState.search(query); this.assetsState.search(query);
} }
@ -45,15 +49,7 @@ export class AssetsFiltersPageComponent {
this.assetsState.resetTags(); this.assetsState.resetTags();
} }
public isSelectedQuery(saved: SavedQuery) { public trackByTag(tag: { name: string }) {
return this.assetsState.isQueryUsed(saved);
}
public trackByTag(index: number, tag: { name: string }) {
return tag.name; return tag.name;
} }
public trackByQuery(index: number, query: { name: string }) {
return query.name;
}
} }

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

@ -100,7 +100,7 @@
Viewing <strong>version {{version.value}}</strong>. Viewing <strong>version {{version.value}}</strong>.
</div> </div>
<ng-container *ngFor="let field of schema.fields; trackBy: trackByField.bind(this)"> <ng-container *ngFor="let field of schema.fields; trackBy: trackByFieldFn">
<sqx-content-field <sqx-content-field
[form]="contentForm" [form]="contentForm"
[formContext]="formContext" [formContext]="formContext"

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

@ -64,6 +64,8 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
public language: AppLanguageDto; public language: AppLanguageDto;
public languages: ImmutableArray<AppLanguageDto>; public languages: ImmutableArray<AppLanguageDto>;
public trackByFieldFn: Function;
@ViewChild('dueTimeSelector', { static: false }) @ViewChild('dueTimeSelector', { static: false })
public dueTimeSelector: DueTimeSelectorComponent; public dueTimeSelector: DueTimeSelectorComponent;
@ -80,6 +82,8 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
) { ) {
super(); super();
this.trackByFieldFn = this.trackByField.bind(this);
this.formContext = { user: authService.user, apiUrl: apiUrl.buildUrl('api') }; this.formContext = { user: authService.user, apiUrl: apiUrl.buildUrl('api') };
} }

34
src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html

@ -4,8 +4,8 @@
</ng-container> </ng-container>
<ng-container content> <ng-container content>
<a class="sidebar-item" *ngFor="let default of schemaQueries.defaultQueries; trackBy: trackByTag" (click)="search(default.query)" <a class="sidebar-item" *ngFor="let default of schemaQueries.defaultQueries; trackBy: trackByQuery" (click)="search(default.query)"
[class.active]="isSelectedQuery(default)"> [class.active]="isQueryUsed(default)">
{{default.name}} {{default.name}}
</a> </a>
@ -15,7 +15,7 @@
<h3>Status Queries</h3> <h3>Status Queries</h3>
<a class="sidebar-item status" *ngFor="let status of contentsState.statusQueries | async; trackBy: trackByQuery" (click)="search(status.query)" <a class="sidebar-item status" *ngFor="let status of contentsState.statusQueries | async; trackBy: trackByQuery" (click)="search(status.query)"
[class.active]="isSelectedQuery(status)"> [class.active]="isQueryUsed(status)">
<i class="icon-circle" [style.color]="status.color"></i> {{status.name}} <i class="icon-circle" [style.color]="status.color"></i> {{status.name}}
</a> </a>
@ -23,28 +23,10 @@
<hr /> <hr />
<div class="sidebar-section"> <sqx-shared-queries types="contents"
<h3>Saved queries</h3> [queries]="schemaQueries"
[queryUsed]="isQueryUsed"
<ng-container *ngIf="schemaQueries.queries | async; let queries"> (search)="search($event.query)">
<ng-container *ngIf="queries.length > 0; else noQuery"> </sqx-shared-queries>
<a class="sidebar-item" *ngFor="let saved of queries; trackBy: trackByQuery" (click)="search(saved.query)"
[class.active]="isSelectedQuery(saved)">
{{saved.name}}
<a class="sidebar-item-remove float-right" (click)="schemaQueries.remove(saved)" sqxStopClick>
<i class="icon-close"></i>
</a>
</a>
</ng-container>
<ng-template #noQuery>
<div class="sidebar-item text-muted">
Search for contents and use <i class="icon-star-empty"></i> icon in search form to save query for all contributors.
</div>
</ng-template>
</ng-container>
</div>
</ng-container> </ng-container>
</sqx-panel> </sqx-panel>

12
src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts

@ -43,16 +43,12 @@ export class ContentsFiltersPageComponent extends ResourceOwner implements OnIni
})); }));
} }
public search(query: Query) { public isQueryUsed = (query: SavedQuery) => {
this.contentsState.search(query); return this.contentsState.isQueryUsed(query);
}
public isSelectedQuery(saved: SavedQuery) {
return this.contentsState.isQueryUsed(saved);
} }
public trackByTag(index: number, tag: { name: string }) { public search(query: Query) {
return tag.name; this.contentsState.search(query);
} }
public trackByQuery(index: number, query: { name: string }) { public trackByQuery(index: number, query: { name: string }) {

2
src/Squidex/app/features/content/shared/content-item.component.html

@ -57,7 +57,7 @@
<img class="user-picture" title="{{content.lastModifiedBy | sqxUserNameRef}}" [attr.src]="content.lastModifiedBy | sqxUserPictureRef" /> <img class="user-picture" title="{{content.lastModifiedBy | sqxUserNameRef}}" [attr.src]="content.lastModifiedBy | sqxUserPictureRef" />
</td> </td>
<td class="cell-auto cell-content" *ngFor="let field of schemaFields; let i = index; trackBy: trackByField.bind(this)" [sqxStopClick]="isDirty || (field.isInlineEditable && patchAllowed)"> <td class="cell-auto cell-content" *ngFor="let field of schemaFields; let i = index; trackBy: trackByFieldFn" [sqxStopClick]="isDirty || (field.isInlineEditable && patchAllowed)">
<ng-container *ngIf="field.isInlineEditable && patchAllowed; else displayTemplate"> <ng-container *ngIf="field.isInlineEditable && patchAllowed; else displayTemplate">
<sqx-content-value-editor [form]="patchForm.form" [field]="field"></sqx-content-value-editor> <sqx-content-value-editor [form]="patchForm.form" [field]="field"></sqx-content-value-editor>
</ng-container> </ng-container>

3
src/Squidex/app/features/content/shared/content-item.component.ts

@ -74,6 +74,8 @@ export class ContentItemComponent implements OnChanges {
@Input('sqxContent') @Input('sqxContent')
public content: ContentDto; public content: ContentDto;
public trackByFieldFn: Function;
public patchForm: PatchContentForm; public patchForm: PatchContentForm;
public patchAllowed = false; public patchAllowed = false;
@ -89,6 +91,7 @@ export class ContentItemComponent implements OnChanges {
private readonly changeDetector: ChangeDetectorRef, private readonly changeDetector: ChangeDetectorRef,
private readonly contentsState: ContentsState private readonly contentsState: ContentsState
) { ) {
this.trackByFieldFn = this.trackByField.bind(this);
} }
public ngOnChanges(changes: SimpleChanges) { public ngOnChanges(changes: SimpleChanges) {

4
src/Squidex/app/features/content/shared/due-time-selector.component.html

@ -6,14 +6,14 @@
<ng-container content> <ng-container content>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="radio" [(ngModel)]="dueTimeMode" value="Immediately" id="immediately" name="dueTimeMode"> <input class="form-check-input" type="radio" [(ngModel)]="dueTimeMode" value="Immediately" id="immediately" name="dueTimeMode" />
<label class="form-check-label" for="immediately"> <label class="form-check-label" for="immediately">
Set to {{dueTimeAction}} immediately. Set to {{dueTimeAction}} immediately.
</label> </label>
</div> </div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="radio" [(ngModel)]="dueTimeMode" value="Scheduled" id="scheduled" name="dueTimeMode"> <input class="form-check-input" type="radio" [(ngModel)]="dueTimeMode" value="Scheduled" id="scheduled" name="dueTimeMode" />
<label class="form-check-label" for="scheduled"> <label class="form-check-label" for="scheduled">
Set to {{dueTimeAction}} at a later point date and time. Set to {{dueTimeAction}} at a later point date and time.
</label> </label>

2
src/Squidex/app/features/schemas/pages/schema/field.component.html

@ -98,7 +98,7 @@
[sqxSortDisabled]="!isEditable" [sqxSortDisabled]="!isEditable"
[sqxSortModel]="nested" [sqxSortModel]="nested"
(sqxSort)="sortFields($event)"> (sqxSort)="sortFields($event)">
<div class="nested-field" *ngFor="let nested of nested; trackBy: trackByField.bind(this)"> <div class="nested-field" *ngFor="let nested of nested; trackBy: trackByFieldFn">
<span class="nested-field-line-h"></span> <span class="nested-field-line-h"></span>
<sqx-field [field]="nested" [schema]="schema" [parent]="field" [patterns]="patterns"></sqx-field> <sqx-field [field]="nested" [schema]="schema" [parent]="field" [patterns]="patterns"></sqx-field>

3
src/Squidex/app/features/schemas/pages/schema/field.component.ts

@ -46,6 +46,8 @@ export class FieldComponent implements OnChanges {
public dropdown = new ModalModel(); public dropdown = new ModalModel();
public trackByFieldFn: Function;
public isEditing = false; public isEditing = false;
public isEditable = false; public isEditable = false;
@ -58,6 +60,7 @@ export class FieldComponent implements OnChanges {
private readonly formBuilder: FormBuilder, private readonly formBuilder: FormBuilder,
private readonly schemasState: SchemasState private readonly schemasState: SchemasState
) { ) {
this.trackByFieldFn = this.trackByField.bind(this);
} }
public ngOnChanges(changes: SimpleChanges) { public ngOnChanges(changes: SimpleChanges) {

2
src/Squidex/app/features/schemas/pages/schema/schema-page.component.html

@ -79,7 +79,7 @@
[sqxSortDisabled]="!schema.canOrderFields" [sqxSortDisabled]="!schema.canOrderFields"
[sqxSortModel]="schema.fields" [sqxSortModel]="schema.fields"
(sqxSort)="sortFields($event)"> (sqxSort)="sortFields($event)">
<div *ngFor="let field of schema.fields; trackBy: trackByField.bind(this)"> <div *ngFor="let field of schema.fields; trackBy: trackByFieldFn">
<sqx-field [field]="field" [schema]="schema" [patterns]="patterns"></sqx-field> <sqx-field [field]="field" [schema]="schema" [patterns]="patterns"></sqx-field>
</div> </div>
</div> </div>

4
src/Squidex/app/features/schemas/pages/schema/schema-page.component.ts

@ -48,6 +48,8 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit {
public editSchemaDialog = new DialogModel(); public editSchemaDialog = new DialogModel();
public exportDialog = new DialogModel(); public exportDialog = new DialogModel();
public trackByFieldFn: Function;
constructor( constructor(
public readonly appsState: AppsState, public readonly appsState: AppsState,
public readonly schemasState: SchemasState, public readonly schemasState: SchemasState,
@ -57,6 +59,8 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit {
private readonly messageBus: MessageBus private readonly messageBus: MessageBus
) { ) {
super(); super();
this.trackByFieldFn = this.trackByField.bind(this);
} }
public ngOnInit() { public ngOnInit() {

66
src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html

@ -28,6 +28,39 @@
</ng-container> </ng-container>
<ng-container *ngIf="contributorsState.contributorsPaged | async; let contributors"> <ng-container *ngIf="contributorsState.contributorsPaged | async; let contributors">
<ng-container *ngIf="contributorsState.canCreate | async">
<div class="table-items-header">
<sqx-form-alert marginTop="0" marginBottom="2" light="true">
Just enter the email address to invite someone with no account to the app.
</sqx-form-alert>
<form [formGroup]="assignContributorForm.form" (ngSubmit)="assignContributor()">
<div class="row no-gutters">
<div class="col">
<sqx-autocomplete [source]="usersDataSource" formControlName="user" [inputName]="'contributor'" placeholder="Find existing user" displayProperty="displayName">
<ng-template let-user="$implicit">
<span class="autocomplete-user">
<img class="user-picture autocomplete-user-picture" [attr.src]="user | sqxUserDtoPicture" />
<span class="user-name autocomplete-user-name">{{user.displayName}}</span>
</span>
</ng-template>
</sqx-autocomplete>
</div>
<div class="col-auto pl-1">
<button type="submit" class="btn btn-success" [disabled]="assignContributorForm.hasNoUser | async">Add Contributor</button>
</div>
</div>
</form>
</div>
<div class="import-hint">
<sqx-form-hint class="text-right">
Big team? <a class="force" (click)="importDialog.show()">Add many contributors at once</a>
</sqx-form-hint>
</div>
</ng-container>
<ng-container *ngIf="contributors.length > 0; else noContributors"> <ng-container *ngIf="contributors.length > 0; else noContributors">
<table class="table table-items table-fixed" *ngIf="rolesState.roles | async; let roles"> <table class="table table-items table-fixed" *ngIf="rolesState.roles | async; let roles">
<tbody *ngFor="let contributor of contributors; trackBy: trackByContributor"> <tbody *ngFor="let contributor of contributors; trackBy: trackByContributor">
@ -56,7 +89,7 @@
</tbody> </tbody>
</table> </table>
<sqx-pager [pager]="contributorsState.contributorsPager | async" (prevPage)="goPrev()" (nextPage)="goNext()"></sqx-pager> <sqx-pager [pager]="contributorsState.contributorsPager | async" [hideWhenButtonsDisabled]="true" (prevPage)="goPrev()" (nextPage)="goNext()"></sqx-pager>
</ng-container> </ng-container>
<ng-template #noContributors> <ng-template #noContributors>
@ -64,37 +97,6 @@
No contributors found. No contributors found.
</div> </div>
</ng-template> </ng-template>
<ng-container *ngIf="contributorsState.canCreate | async">
<div class="table-items-footer">
<sqx-form-alert marginTop="0" marginBottom="2" white="true">
Just enter the email address to invite someone with no account to the app.
</sqx-form-alert>
<form [formGroup]="assignContributorForm.form" (ngSubmit)="assignContributor()">
<div class="row no-gutters">
<div class="col">
<sqx-autocomplete [source]="usersDataSource" formControlName="user" [inputName]="'contributor'" placeholder="Find existing user" displayProperty="displayName">
<ng-template let-user="$implicit">
<span class="autocomplete-user">
<img class="user-picture autocomplete-user-picture" [attr.src]="user | sqxUserDtoPicture" />
<span class="user-name autocomplete-user-name">{{user.displayName}}</span>
</span>
</ng-template>
</sqx-autocomplete>
</div>
<div class="col-auto pl-1">
<button type="submit" class="btn btn-success" [disabled]="assignContributorForm.hasNoUser | async">Add Contributor</button>
</div>
</div>
</form>
</div>
<sqx-form-hint class="text-right">
Big team? <a class="force" (click)="importDialog.show()">Add many contributors at once</a>
</sqx-form-hint>
</ng-container>
</ng-container> </ng-container>
</ng-container> </ng-container>
</ng-container> </ng-container>

10
src/Squidex/app/features/settings/pages/contributors/contributors-page.component.scss

@ -10,3 +10,13 @@
margin-left: .25rem; margin-left: .25rem;
} }
} }
.import-hint {
margin-bottom: 1rem;
font-weight: normal;
font-size: 90%;
}
.table-items-header {
margin: 0;
}

8
src/Squidex/app/framework/angular/forms/form-alert.component.ts

@ -16,12 +16,12 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
max-width: 100%; max-width: 100%;
} }
.white { .light {
background: #fff; background: #fcfeff;
} }
`], `],
template: ` template: `
<div class="alert alert-hint mt-{{marginTop}} mb-{{marginBottom}} {{class}}" [class.white]="white"> <div class="alert alert-hint mt-{{marginTop}} mb-{{marginBottom}} {{class}}" [class.light]="light">
<i class="icon-info-outline"></i> <ng-content></ng-content> <i class="icon-info-outline"></i> <ng-content></ng-content>
</div>`, </div>`,
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@ -37,5 +37,5 @@ export class FormAlertComponent {
public marginBottom = 4; public marginBottom = 4;
@Input() @Input()
public white = false; public light = false;
} }

35
src/Squidex/app/framework/utils/types.spec.ts

@ -5,7 +5,7 @@
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/ */
import { Types } from './types'; import { mergeInto, Types } from './types';
describe('Types', () => { describe('Types', () => {
it('should calculate hash string', () => { it('should calculate hash string', () => {
@ -152,6 +152,39 @@ describe('Types', () => {
it('should treat object of empty values as empty', () => { it('should treat object of empty values as empty', () => {
expect(Types.isEmpty({ a: null, b: null })).toBeTruthy(); expect(Types.isEmpty({ a: null, b: null })).toBeTruthy();
}); });
it('should merge deeply', () => {
const source = {};
mergeInto(source, {
rootShared: 1,
rootA: 2,
nested: {
a: 3
},
array: [4]
});
mergeInto(source, {
rootShared: 5,
rootB: 6,
nested: {
b: 7
},
array: [8]
});
expect(source).toEqual({
rootShared: 5,
rootA: 2,
rootB: 6,
nested: {
a: 3,
b: 7
},
array: [4, 8]
});
});
}); });
class MyClass { class MyClass {

21
src/Squidex/app/framework/utils/types.ts

@ -128,3 +128,24 @@ export module Types {
return Types.isUndefined(value) === true || Types.isNull(value) === true; return Types.isUndefined(value) === true || Types.isNull(value) === true;
} }
} }
export function mergeInto(target: object, source: object) {
if (!Types.isObject(target) || !Types.isObject(source)) {
return source;
}
Object.keys(source).forEach(key => {
const targetValue = target[key];
const sourceValue = source[key];
if (Types.isArray(targetValue) && Types.isArray(sourceValue)) {
target[key] = targetValue.concat(sourceValue);
} else if (Types.isObject(targetValue) && Types.isObject(sourceValue)) {
target[key] = mergeInto({ ...targetValue }, sourceValue);
} else {
target[key] = sourceValue;
}
});
return target;
}

87
src/Squidex/app/shared/components/saved-queries.component.ts

@ -0,0 +1,87 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Queries } from '@app/shared/internal';
import { SavedQuery } from '../state/queries';
@Component({
selector: 'sqx-shared-queries',
template: `
<div class="sidebar-section">
<h3>Shared queries</h3>
<ng-container *ngIf="queries.queriesShared | async; let queryList">
<ng-container *ngIf="queryList.length > 0; else noQuery">
<a class="sidebar-item" *ngFor="let saved of queryList; trackBy: trackByQuery" (click)="search.emit(saved)"
[class.active]="isSelectedQuery(saved)">
{{saved.name}}
<a class="sidebar-item-remove float-right" (click)="queries.removeShared(saved)" sqxStopClick>
<i class="icon-close"></i>
</a>
</a>
</ng-container>
<ng-template #noQuery>
<div class="sidebar-item text-muted">
Search for {{types}} and use <i class="icon-star-empty"></i> icon in search form to save query for all contributors.
</div>
</ng-template>
</ng-container>
</div>
<hr />
<div class="sidebar-section">
<h3>My queries</h3>
<ng-container *ngIf="queries.queriesUser | async; let queryList">
<ng-container *ngIf="queryList.length > 0; else noQuery">
<a class="sidebar-item" *ngFor="let saved of queryList; trackBy: trackByQuery" (click)="search.emit(saved)"
[class.active]="isSelectedQuery(saved)">
{{saved.name}}
<a class="sidebar-item-remove float-right" (click)="queries.removeUser(saved)" sqxStopClick>
<i class="icon-close"></i>
</a>
</a>
</ng-container>
<ng-template #noQuery>
<div class="sidebar-item text-muted">
Search for {{types}} and use <i class="icon-star-empty"></i> icon in search form to save query for yourself.
</div>
</ng-template>
</ng-container>
</div>
`
})
export class SavedQueriesComponent {
@Input()
public queryUsed: (saved: SavedQuery) => boolean;
@Input()
public queries: Queries;
@Input()
public types: string;
@Output()
public search = new EventEmitter<SavedQuery>();
public isSelectedQuery(saved: SavedQuery) {
return this.queryUsed && this.queryUsed(saved);
}
public trackByQuery(index: number, query: { name: string }) {
return query.name;
}
}

7
src/Squidex/app/shared/components/search-form.component.html

@ -78,6 +78,13 @@
<input type="text" class="form-control" id="appName" formControlName="name" autocomplete="off" sqxFocusOnInit /> <input type="text" class="form-control" id="appName" formControlName="name" autocomplete="off" sqxFocusOnInit />
</div> </div>
<div class="form-check">
<input class="form-check-input" type="checkbox" formControlName="user" id="user" />
<label class="form-check-label" for="user">
Save the query only for myself.
</label>
</div>
</ng-container> </ng-container>
<ng-container footer> <ng-container footer>

2
src/Squidex/app/shared/components/search-form.component.ts

@ -93,7 +93,7 @@ export class SearchFormComponent implements OnChanges {
if (value) { if (value) {
if (this.queries && this.query) { if (this.queries && this.query) {
this.queries.add(value.name, this.query); this.queries.add(value.name, this.query, value.user);
} }
this.saveQueryForm.submitCompleted(); this.saveQueryForm.submitCompleted();

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

@ -24,6 +24,7 @@ export * from './components/pipes';
export * from './components/references-dropdown.component'; export * from './components/references-dropdown.component';
export * from './components/rich-editor.component'; export * from './components/rich-editor.component';
export * from './components/schema-category.component'; export * from './components/schema-category.component';
export * from './components/saved-queries.component';
export * from './components/search-form.component'; export * from './components/search-form.component';
export * from './components/table-header.component'; export * from './components/table-header.component';

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

@ -78,6 +78,7 @@ import {
RuleEventsState, RuleEventsState,
RulesService, RulesService,
RulesState, RulesState,
SavedQueriesComponent,
SchemaCategoryComponent, SchemaCategoryComponent,
SchemaMustExistGuard, SchemaMustExistGuard,
SchemaMustExistPublishedGuard, SchemaMustExistPublishedGuard,
@ -136,6 +137,7 @@ import {
MarkdownEditorComponent, MarkdownEditorComponent,
QueryComponent, QueryComponent,
ReferencesDropdownComponent, ReferencesDropdownComponent,
SavedQueriesComponent,
SchemaCategoryComponent, SchemaCategoryComponent,
SortingComponent, SortingComponent,
UserDtoPicture, UserDtoPicture,
@ -171,6 +173,7 @@ import {
ReferencesDropdownComponent, ReferencesDropdownComponent,
RichEditorComponent, RichEditorComponent,
RouterModule, RouterModule,
SavedQueriesComponent,
SchemaCategoryComponent, SchemaCategoryComponent,
SearchFormComponent, SearchFormComponent,
UserDtoPicture, UserDtoPicture,

78
src/Squidex/app/shared/services/ui.service.spec.ts

@ -71,12 +71,12 @@ describe('UIService', () => {
expect(settings!).toEqual({ mapType: 'OSM', mapKey: '', canCreateApps: true }); expect(settings!).toEqual({ mapType: 'OSM', mapKey: '', canCreateApps: true });
})); }));
it('should make get request to get settings', it('should make get request to get shared settings',
inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => {
let settings: object; let settings: object;
uiService.getSettings('my-app').subscribe(result => { uiService.getSharedSettings('my-app').subscribe(result => {
settings = result; settings = result;
}); });
@ -92,12 +92,12 @@ describe('UIService', () => {
expect(settings!).toEqual(response); expect(settings!).toEqual(response);
})); }));
it('should return default settings when error occurs', it('should return default shared settings when error occurs',
inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => {
let settings: object; let settings: object;
uiService.getSettings('my-app').subscribe(result => { uiService.getSharedSettings('my-app').subscribe(result => {
settings = result; settings = result;
}); });
@ -111,10 +111,50 @@ describe('UIService', () => {
expect(settings!).toBeDefined(); expect(settings!).toBeDefined();
})); }));
it('should make put request to set value', it('should make get request to get user settings',
inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => {
uiService.putSetting('my-app', 'root.nested', 123).subscribe(); let settings: object;
uiService.getUserSettings('my-app').subscribe(result => {
settings = result;
});
const response = { mapType: 'OSM', mapKey: '' };
const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/me');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.flush(response);
expect(settings!).toEqual(response);
}));
it('should return default user settings when error occurs',
inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => {
let settings: object;
uiService.getUserSettings('my-app').subscribe(result => {
settings = result;
});
const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/me');
expect(req.request.method).toEqual('GET');
expect(req.request.headers.get('If-Match')).toBeNull();
req.error(new ErrorEvent('500'));
expect(settings!).toBeDefined();
}));
it('should make put request to set shared value',
inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => {
uiService.putSharedSetting('my-app', 'root.nested', 123).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/root.nested'); const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/root.nested');
@ -122,14 +162,36 @@ describe('UIService', () => {
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
})); }));
it('should make delete request to remove value', it('should make put request to set user value',
inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => {
uiService.deleteSetting('my-app', 'root.nested').subscribe(); uiService.putUserSetting('my-app', 'root.nested', 123).subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/me/root.nested');
expect(req.request.method).toEqual('PUT');
expect(req.request.headers.get('If-Match')).toBeNull();
}));
it('should make delete request to remove shared value',
inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => {
uiService.deleteSharedSetting('my-app', 'root.nested').subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/root.nested'); const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/root.nested');
expect(req.request.method).toEqual('DELETE'); expect(req.request.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toBeNull(); expect(req.request.headers.get('If-Match')).toBeNull();
})); }));
it('should make delete request to remove user value',
inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => {
uiService.deleteUserSetting('my-app', 'root.nested').subscribe();
const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/me/root.nested');
expect(req.request.method).toEqual('DELETE');
expect(req.request.headers.get('If-Match')).toBeNull();
}));
}); });

27
src/Squidex/app/shared/services/ui.service.ts

@ -33,7 +33,7 @@ export class UIService {
})); }));
} }
public getSettings(appName: string): Observable<object> { public getSharedSettings(appName: string): Observable<object> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings`); const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings`);
return this.http.get<object>(url).pipe( return this.http.get<object>(url).pipe(
@ -42,15 +42,36 @@ export class UIService {
})); }));
} }
public putSetting(appName: string, key: string, value: any): Observable<any> { public getUserSettings(appName: string): Observable<object> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/me`);
return this.http.get<object>(url).pipe(
catchError(() => {
return of({});
}));
}
public putSharedSetting(appName: string, key: string, value: any): Observable<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/${key}`); const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/${key}`);
return this.http.put(url, { value }); return this.http.put(url, { value });
} }
public deleteSetting(appName: string, key: string): Observable<any> { public putUserSetting(appName: string, key: string, value: any): Observable<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/me/${key}`);
return this.http.put(url, { value });
}
public deleteSharedSetting(appName: string, key: string): Observable<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/${key}`); const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/${key}`);
return this.http.delete(url); return this.http.delete(url);
} }
public deleteUserSetting(appName: string, key: string): Observable<any> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/me/${key}`);
return this.http.delete(url);
}
} }

3
src/Squidex/app/shared/state/contents.forms.ts

@ -53,7 +53,8 @@ export class SaveQueryForm extends Form<FormGroup, any> {
[ [
Validators.required Validators.required
] ]
] ],
user: false
})); }));
} }
} }

85
src/Squidex/app/shared/state/queries.spec.ts

@ -21,25 +21,38 @@ describe('Queries', () => {
let uiState: IMock<UIState>; let uiState: IMock<UIState>;
let queries$ = new BehaviorSubject({});
let queries: Queries; let queries: Queries;
beforeEach(() => { beforeEach(() => {
uiState = Mock.ofType<UIState>(); uiState = Mock.ofType<UIState>();
uiState.setup(x => x.get('schemas.my-schema.queries', {})) const shared$ = new BehaviorSubject({
.returns(() => queries$); key1: '{ "fullText": "shared1" }'
});
queries$.next({ const user$ = new BehaviorSubject({
key1: '{ "fullText": "text1" }', key1: '{ "fullText": "user1" }'
key2: 'text2', });
const merged$ = new BehaviorSubject({
key1: '{ "fullText": "merged1" }',
key2: 'merged2',
key3: undefined key3: undefined
}); });
uiState.setup(x => x.get('schemas.my-schema.queries', {}))
.returns(() => merged$);
uiState.setup(x => x.getShared('schemas.my-schema.queries', {}))
.returns(() => shared$);
uiState.setup(x => x.getUser('schemas.my-schema.queries', {}))
.returns(() => user$);
queries = new Queries(uiState.object, prefix); queries = new Queries(uiState.object, prefix);
}); });
it('should load queries', () => { it('should load merged queries', () => {
let converted: SavedQuery[]; let converted: SavedQuery[];
queries.queries.subscribe(x => { queries.queries.subscribe(x => {
@ -49,12 +62,12 @@ describe('Queries', () => {
expect(converted!).toEqual([ expect(converted!).toEqual([
{ {
name: 'key1', name: 'key1',
query: { fullText: 'text1' }, query: { fullText: 'merged1' },
queryJson: encodeQuery({ fullText: 'text1' }) queryJson: encodeQuery({ fullText: 'merged1' })
}, { }, {
name: 'key2', name: 'key2',
query: { fullText: 'text2' }, query: { fullText: 'merged2' },
queryJson: encodeQuery({ fullText: 'text2' }) queryJson: encodeQuery({ fullText: 'merged2' })
}, { }, {
name: 'key3', name: 'key3',
query: undefined, query: undefined,
@ -63,6 +76,38 @@ describe('Queries', () => {
]); ]);
}); });
it('should load shared queries', () => {
let converted: SavedQuery[];
queries.queriesShared.subscribe(x => {
converted = x;
});
expect(converted!).toEqual([
{
name: 'key1',
query: { fullText: 'shared1' },
queryJson: encodeQuery({ fullText: 'shared1' })
}
]);
});
it('should load user queries', () => {
let converted: SavedQuery[];
queries.queriesUser.subscribe(x => {
converted = x;
});
expect(converted!).toEqual([
{
name: 'key1',
query: { fullText: 'user1' },
queryJson: encodeQuery({ fullText: 'user1' })
}
]);
});
it('should provide key', () => { it('should provide key', () => {
let key: string; let key: string;
@ -74,18 +119,26 @@ describe('Queries', () => {
}); });
it('should forward add call to state', () => { it('should forward add call to state', () => {
queries.add('key3', { fullText: 'text3' }); queries.add('key3', { fullText: 'text3' }, true);
expect(true).toBeTruthy();
uiState.verify(x => x.set('schemas.my-schema.queries.key3', '{"fullText":"text3"}', true), Times.once());
});
it('should forward remove shared call to state', () => {
queries.removeShared({ name: 'key3' });
expect(true).toBeTruthy(); expect(true).toBeTruthy();
uiState.verify(x => x.set('schemas.my-schema.queries.key3', '{"fullText":"text3"}'), Times.once()); uiState.verify(x => x.removeShared('schemas.my-schema.queries.key3'), Times.once());
}); });
it('should forward remove call to state', () => { it('should forward remove user call to state', () => {
queries.remove({ name: 'key3' }); queries.removeUser({ name: 'key3' });
expect(true).toBeTruthy(); expect(true).toBeTruthy();
uiState.verify(x => x.remove('schemas.my-schema.queries.key3'), Times.once()); uiState.verify(x => x.removeUser('schemas.my-schema.queries.key3'), Times.once());
}); });
}); });

43
src/Squidex/app/shared/state/queries.ts

@ -6,7 +6,7 @@
*/ */
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { map } from 'rxjs/operators'; import { map, shareReplay } from 'rxjs/operators';
import { compareStringsAsc, Types } from '@app/framework'; import { compareStringsAsc, Types } from '@app/framework';
@ -33,6 +33,8 @@ const OLDEST_FIRST: Query = {
export class Queries { export class Queries {
public queries: Observable<SavedQuery[]>; public queries: Observable<SavedQuery[]>;
public queriesShared: Observable<SavedQuery[]>;
public queriesUser: Observable<SavedQuery[]>;
public defaultQueries: SavedQuery[] = [ public defaultQueries: SavedQuery[] = [
{ name: 'All (newest first)', queryJson: '' }, { name: 'All (newest first)', queryJson: '' },
@ -43,21 +45,36 @@ export class Queries {
private readonly uiState: UIState, private readonly uiState: UIState,
private readonly prefix: string private readonly prefix: string
) { ) {
this.queries = this.uiState.get(`${this.prefix}.queries`, {}).pipe( const path = `${prefix}.queries`;
map(settings => {
let queries = Object.keys(settings).map(name => parseStored(name, settings[name]));
return queries.sort((a, b) => compareStringsAsc(a.name, b.name)); this.queries = this.uiState.get(path, {}).pipe(
}) map(settings => parseQueries(settings)), shareReplay(1));
);
this.queriesShared = this.uiState.getShared(path, {}).pipe(
map(settings => parseQueries(settings)), shareReplay(1));
this.queriesUser = this.uiState.getUser(path, {}).pipe(
map(settings => parseQueries(settings)), shareReplay(1));
}
public add(key: string, query: Query, user = false) {
this.uiState.set(this.getPath(key), JSON.stringify(query), user);
}
public removeShared(saved: SavedQuery) {
this.uiState.removeShared(this.getPath(saved.name));
} }
public add(key: string, query: Query) { public removeUser(saved: SavedQuery) {
this.uiState.set(`${this.prefix}.queries.${key}`, JSON.stringify(query)); this.uiState.removeUser(this.getPath(saved.name));
} }
public remove(saved: SavedQuery) { public remove(saved: SavedQuery) {
this.uiState.remove(`${this.prefix}.queries.${saved.name}`); this.uiState.remove(this.getPath(saved.name));
}
private getPath(key: string): string {
return `${this.prefix}.queries.${key}`;
} }
public getSaveKey(query: Query): Observable<string | undefined> { public getSaveKey(query: Query): Observable<string | undefined> {
@ -76,6 +93,12 @@ export class Queries {
} }
} }
function parseQueries(settings: {}) {
let queries = Object.keys(settings).map(name => parseStored(name, settings[name]));
return queries.sort((a, b) => compareStringsAsc(a.name, b.name));
}
export function parseStored(name: string, raw?: string) { export function parseStored(name: string, raw?: string) {
if (Types.isString(raw)) { if (Types.isString(raw)) {
let query: Query; let query: Query;

149
src/Squidex/app/shared/state/ui.state.spec.ts

@ -6,7 +6,7 @@
*/ */
import { of } from 'rxjs'; import { of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq'; import { IMock, Mock } from 'typemoq';
import { import {
ResourceLinks, ResourceLinks,
@ -24,18 +24,30 @@ describe('UIState', () => {
appsState appsState
} = TestValues; } = TestValues;
const appSettings = { const common = {
mapType: 'GM', key: 'xx',
mapSize: 1024, map: {
type: 'GSM',
sizeX: 800,
sizeY: 600
},
canCreateApps: true canCreateApps: true
}; };
const commonSettings = { const shared = {
mapType: 'OSM', map: {
mapKey: 'Key', type: 'GM', key: 'xyz'
},
canCreateApps: true canCreateApps: true
}; };
const user = {
map: {
sizeX: 1000
},
canCustomize: true
};
const resources: ResourceLinks = { const resources: ResourceLinks = {
['admin/events']: { method: 'GET', href: '/api/events' }, ['admin/events']: { method: 'GET', href: '/api/events' },
['admin/restore']: { method: 'GET', href: '/api/restore' }, ['admin/restore']: { method: 'GET', href: '/api/restore' },
@ -49,17 +61,14 @@ describe('UIState', () => {
beforeEach(() => { beforeEach(() => {
uiService = Mock.ofType<UIService>(); uiService = Mock.ofType<UIService>();
uiService.setup(x => x.getSettings(app))
.returns(() => of(appSettings));
uiService.setup(x => x.getCommonSettings()) uiService.setup(x => x.getCommonSettings())
.returns(() => of(commonSettings)); .returns(() => of(common));
uiService.setup(x => x.putSetting(app, It.isAnyString(), It.isAny())) uiService.setup(x => x.getSharedSettings(app))
.returns(() => of({})); .returns(() => of(shared));
uiService.setup(x => x.deleteSetting(app, It.isAnyString())) uiService.setup(x => x.getUserSettings(app))
.returns(() => of({})); .returns(() => of(user));
usersService = Mock.ofType<UsersService>(); usersService = Mock.ofType<UsersService>();
@ -71,10 +80,15 @@ describe('UIState', () => {
it('should load settings', () => { it('should load settings', () => {
expect(uiState.snapshot.settings).toEqual({ expect(uiState.snapshot.settings).toEqual({
mapType: 'GM', key: 'xx',
mapKey: 'Key', map: {
mapSize: 1024, type: 'GM',
canCreateApps: true sizeX: 1000,
sizeY: 600,
key: 'xyz'
},
canCreateApps: true,
canCustomize: true
}); });
expect(uiState.snapshot.canReadEvents).toBeTruthy(); expect(uiState.snapshot.canReadEvents).toBeTruthy();
@ -82,17 +96,25 @@ describe('UIState', () => {
expect(uiState.snapshot.canRestore).toBeTruthy(); expect(uiState.snapshot.canRestore).toBeTruthy();
}); });
it('should add value to snapshot when set', () => { it('should add value to snapshot when set as shared', () => {
uiService.setup(x => x.putSharedSetting(app, 'root.nested', 123))
.returns(() => of({})).verifiable();
uiState.set('root.nested', 123); uiState.set('root.nested', 123);
expect(uiState.snapshot.settings).toEqual({ expect(uiState.snapshot.settings).toEqual({
mapType: 'GM', key: 'xx',
mapKey: 'Key', map: {
mapSize: 1024, type: 'GM',
sizeX: 1000,
sizeY: 600,
key: 'xyz'
},
canCreateApps: true,
canCustomize: true,
root: { root: {
nested: 123 nested: 123
}, }
canCreateApps: true
}); });
uiState.get('root', {}).subscribe(x => { uiState.get('root', {}).subscribe(x => {
@ -107,36 +129,83 @@ describe('UIState', () => {
expect(x).toEqual(1337); expect(x).toEqual(1337);
}); });
uiService.verify(x => x.putSetting(app, 'root.nested', 123), Times.once()); uiService.verifyAll();
}); });
it('should remove value from snapshot when removed', () => { it('should add value to snapshot when set as user', () => {
uiState.set('root.nested1', 123); uiService.setup(x => x.putUserSetting(app, 'root.nested', 123))
uiState.set('root.nested2', 123); .returns(() => of({})).verifiable();
uiState.remove('root.nested1');
uiState.set('root.nested', 123, true);
expect(uiState.snapshot.settings).toEqual({ expect(uiState.snapshot.settings).toEqual({
mapType: 'GM', key: 'xx',
mapKey: 'Key', map: {
mapSize: 1024, type: 'GM',
root: { sizeX: 1000,
nested2: 123 sizeY: 600,
key: 'xyz'
}, },
canCreateApps: true canCreateApps: true,
canCustomize: true,
root: {
nested: 123
}
}); });
uiState.get('root', {}).subscribe(x => { uiState.get('root', {}).subscribe(x => {
expect(x).toEqual({ nested2: 123 }); expect(x).toEqual({ nested: 123 });
}); });
uiState.get('root.nested2', 0).subscribe(x => { uiState.get('root.nested', 0).subscribe(x => {
expect(x).toEqual(123); expect(x).toEqual(123);
}); });
uiState.get('root.nested1', 1337).subscribe(x => { uiState.get('root.notfound', 1337).subscribe(x => {
expect(x).toEqual(1337); expect(x).toEqual(1337);
}); });
uiService.verify(x => x.deleteSetting(app, 'root.nested1'), Times.once()); uiService.verifyAll();
});
it('should remove value from snapshot and shared settings when removed', () => {
uiService.setup(x => x.deleteSharedSetting(app, 'map.key'))
.returns(() => of({})).verifiable();
uiState.remove('map.key');
expect(uiState.snapshot.settings).toEqual({
key: 'xx',
map: {
type: 'GM',
sizeX: 1000,
sizeY: 600
},
canCreateApps: true,
canCustomize: true
});
uiService.verifyAll();
});
it('should remove value from snapshot and user settings when removed', () => {
uiService.setup(x => x.deleteUserSetting(app, 'map.sizeX'))
.returns(() => of({})).verifiable();
uiState.remove('map.sizeX');
expect(uiState.snapshot.settings).toEqual({
key: 'xx',
map: {
type: 'GM',
sizeX: 800,
sizeY: 600,
key: 'xyz'
},
canCreateApps: true,
canCustomize: true
});
uiService.verifyAll();
}); });
}); });

158
src/Squidex/app/shared/state/ui.state.ts

@ -10,6 +10,7 @@ import { distinctUntilChanged, map } from 'rxjs/operators';
import { import {
hasAnyLink, hasAnyLink,
mergeInto,
State, State,
Types Types
} from '@app/framework'; } from '@app/framework';
@ -24,8 +25,11 @@ interface Snapshot {
// All common settings. // All common settings.
settingsCommon: object & any; settingsCommon: object & any;
// All app settings. // All shared app settings.
settingsApp: object & any; settingsShared: object & any;
// All user app settings.
settingsUser: object & any;
// The merged settings of app and common settings. // The merged settings of app and common settings.
settings: object & any; settings: object & any;
@ -48,6 +52,12 @@ export class UIState extends State<Snapshot> {
public settings = public settings =
this.project(x => x.settings); this.project(x => x.settings);
public settingsShared =
this.project(x => x.settingsShared);
public settingsUser =
this.project(x => x.settingsUser);
public canReadEvents = public canReadEvents =
this.project(x => x.canReadEvents === true); this.project(x => x.canReadEvents === true);
@ -65,34 +75,54 @@ export class UIState extends State<Snapshot> {
distinctUntilChanged()); distinctUntilChanged());
} }
public getShared<T>(path: string, defaultValue: T) {
return this.settingsShared.pipe(map(x => this.getValue(x, path, defaultValue)),
distinctUntilChanged());
}
public getUser<T>(path: string, defaultValue: T) {
return this.settingsUser.pipe(map(x => this.getValue(x, path, defaultValue)),
distinctUntilChanged());
}
constructor( constructor(
private readonly appsState: AppsState, private readonly appsState: AppsState,
private readonly uiService: UIService, private readonly uiService: UIService,
private readonly usersService: UsersService private readonly usersService: UsersService
) { ) {
super({ settings: {}, settingsCommon: {}, settingsApp: {} }); super({
settings: {},
settingsCommon: {},
settingsShared: {},
settingsUser: {}
});
this.loadResources(); this.loadResources();
this.loadCommon(); this.loadCommon();
appsState.selectedValidApp.subscribe(app => { appsState.selectedValidApp.subscribe(app => {
this.load(); this.load(app.name);
}); });
} }
private load() { private load(app: string) {
this.next(s => updateAppSettings(s, {})); this.next(s => updateSettings(s, {}));
this.uiService.getSettings(this.appName) this.uiService.getSharedSettings(app)
.subscribe(payload => { .subscribe(payload => {
this.next(s => updateAppSettings(s, payload)); this.next(s => updateSettings(s, { settingsShared: payload }));
});
this.uiService.getUserSettings(app)
.subscribe(payload => {
this.next(s => updateSettings(s, { settingsUser: payload }));
}); });
} }
private loadCommon() { private loadCommon() {
this.uiService.getCommonSettings() this.uiService.getCommonSettings()
.subscribe(payload => { .subscribe(payload => {
this.next(s => updateCommonSettings(s, payload)); this.next(s => updateSettings(s, { settingsCommon: payload }));
}); });
} }
@ -107,60 +137,72 @@ export class UIState extends State<Snapshot> {
}); });
} }
public set(path: string, value: any) { public set(path: string, value: any, user = false) {
const { key, current, root } = this.getContainer(path); if (user) {
this.setUser(path, value);
} else {
this.setShared(path, value);
}
}
private setUser(path: string, value: any) {
const { key, current, root } = getContainer(this.snapshot.settingsUser, path);
if (current && key) { if (current && key) {
this.uiService.putSetting(this.appName, path, value).subscribe(); this.uiService.putUserSetting(this.appName, path, value).subscribe();
current[key] = value; current[key] = value;
this.next(s => updateAppSettings(s, root)); this.next(s => updateSettings(s, { settingsUser: root }));
} }
} }
public remove(path: string) { private setShared(path: string, value: any) {
const { key, current, root } = this.getContainer(path); const { key, current, root } = getContainer(this.snapshot.settingsShared, path);
if (current && key) { if (current && key) {
this.uiService.deleteSetting(this.appName, path).subscribe(); this.uiService.putSharedSetting(this.appName, path, value).subscribe();
delete current[key]; current[key] = value;
this.next(s => updateAppSettings(s, root)); this.next(s => updateSettings(s, { settingsShared: root }));
} }
} }
private getContainer(path: string) { public remove(path: string) {
const segments = path.split('.'); return this.removeUser(path) || this.removeShared(path);
}
let current = { ...this.snapshot.settingsApp }; public removeUser(path: string) {
const { key, current, root } = getContainer(this.snapshot.settingsUser, path);
const root = current; if (current && key && current[key]) {
this.uiService.deleteUserSetting(this.appName, path).subscribe();
if (segments.length > 0) { delete current[key];
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
let temp = current[segment]; this.next(s => updateSettings(s, { settingsUser: root }));
if (!temp) { return true;
temp = {};
} else {
temp = { ...temp };
} }
current[segment] = temp; return false;
if (!Types.isObject(temp)) {
return { key: null, current: null, root: null };
} }
current = temp; public removeShared(path: string) {
} const { key, current, root } = getContainer(this.snapshot.settingsShared, path);
if (current && key && current[key]) {
this.uiService.deleteSharedSetting(this.appName, path).subscribe();
delete current[key];
this.next(s => updateSettings(s, { settingsShared: root }));
return true;
} }
return { key: segments[segments.length - 1], current, root }; return false;
} }
private getValue<T>(setting: object & UISettingsDto, path: string, defaultValue: T) { private getValue<T>(setting: object & UISettingsDto, path: string, defaultValue: T) {
@ -188,14 +230,44 @@ export class UIState extends State<Snapshot> {
} }
} }
function updateAppSettings(state: Snapshot, settingsApp: object & any) { function getContainer(settings: object, path: string) {
const { settingsCommon } = state; const segments = path.split('.');
let current = { ...settings };
const root = current;
if (segments.length > 0) {
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
let temp = current[segment];
if (!temp) {
temp = {};
} else {
temp = { ...temp };
}
current[segment] = temp;
if (!Types.isObject(temp)) {
return { key: null, current: null, root: null };
}
return { ...state, settings: { ...settingsCommon, ...settingsApp }, settingsApp, settingsCommon }; current = temp;
}
}
return { key: segments[segments.length - 1], current, root };
} }
function updateCommonSettings(state: Snapshot, settingsCommon: object & any) { function updateSettings(state: Snapshot, update: Partial<Snapshot>) {
const { settingsApp } = state; const settings = {};
mergeInto(settings, update.settingsCommon || state.settingsCommon);
mergeInto(settings, update.settingsShared || state.settingsShared);
mergeInto(settings, update.settingsUser || state.settingsUser);
return { ...state, settings: { ...settingsCommon, ...settingsApp }, settingsApp, settingsCommon }; return { ...state, settings, ...update };
} }

24
tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs

@ -22,7 +22,6 @@ namespace Squidex.Domain.Apps.Entities.Apps
public AppUISettingsGrainTests() public AppUISettingsGrainTests()
{ {
sut = new AppUISettingsGrain(grainState); sut = new AppUISettingsGrain(grainState);
sut.ActivateAsync(Guid.Empty).Wait();
} }
[Fact] [Fact]
@ -36,6 +35,9 @@ namespace Squidex.Domain.Apps.Entities.Apps
JsonValue.Object().Add("key", 15); JsonValue.Object().Add("key", 15);
Assert.Equal(expected.ToString(), actual.Value.ToString()); Assert.Equal(expected.ToString(), actual.Value.ToString());
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened();
} }
[Fact] [Fact]
@ -49,12 +51,16 @@ namespace Squidex.Domain.Apps.Entities.Apps
JsonValue.Object().Add("key", 123); JsonValue.Object().Add("key", 123);
Assert.Equal(expected.ToString(), actual.Value.ToString()); Assert.Equal(expected.ToString(), actual.Value.ToString());
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened();
} }
[Fact] [Fact]
public async Task Should_remove_root_value() public async Task Should_remove_root_value()
{ {
await sut.SetAsync("key", JsonValue.Create(123).AsJ()); await sut.SetAsync("key", JsonValue.Create(123).AsJ());
await sut.RemoveAsync("key"); await sut.RemoveAsync("key");
var actual = await sut.GetAsync(); var actual = await sut.GetAsync();
@ -62,6 +68,9 @@ namespace Squidex.Domain.Apps.Entities.Apps
var expected = JsonValue.Object(); var expected = JsonValue.Object();
Assert.Equal(expected.ToString(), actual.Value.ToString()); Assert.Equal(expected.ToString(), actual.Value.ToString());
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappenedTwiceExactly();
} }
[Fact] [Fact]
@ -76,12 +85,16 @@ namespace Squidex.Domain.Apps.Entities.Apps
JsonValue.Object().Add("nested", 123)); JsonValue.Object().Add("nested", 123));
Assert.Equal(expected.ToString(), actual.Value.ToString()); Assert.Equal(expected.ToString(), actual.Value.ToString());
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened();
} }
[Fact] [Fact]
public async Task Should_remove_nested_value() public async Task Should_remove_nested_value()
{ {
await sut.SetAsync("root.nested", JsonValue.Create(123).AsJ()); await sut.SetAsync("root.nested", JsonValue.Create(123).AsJ());
await sut.RemoveAsync("root.nested"); await sut.RemoveAsync("root.nested");
var actual = await sut.GetAsync(); var actual = await sut.GetAsync();
@ -91,6 +104,9 @@ namespace Squidex.Domain.Apps.Entities.Apps
JsonValue.Object()); JsonValue.Object());
Assert.Equal(expected.ToString(), actual.Value.ToString()); Assert.Equal(expected.ToString(), actual.Value.ToString());
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappenedTwiceExactly();
} }
[Fact] [Fact]
@ -105,12 +121,18 @@ namespace Squidex.Domain.Apps.Entities.Apps
public async Task Should_do_nothing_if_deleting_and_nested_not_found() public async Task Should_do_nothing_if_deleting_and_nested_not_found()
{ {
await sut.RemoveAsync("root.nested"); await sut.RemoveAsync("root.nested");
A.CallTo(() => grainState.WriteAsync())
.MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_do_nothing_if_deleting_and_key_not_found() public async Task Should_do_nothing_if_deleting_and_key_not_found()
{ {
await sut.RemoveAsync("root"); await sut.RemoveAsync("root");
A.CallTo(() => grainState.WriteAsync())
.MustNotHaveHappened();
} }
} }
} }

76
tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsTests.cs

@ -0,0 +1,76 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using FakeItEasy;
using Orleans;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Orleans;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Apps
{
public class AppUISettingsTests
{
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>();
private readonly IAppUISettingsGrain grain = A.Fake<IAppUISettingsGrain>();
private readonly AppUISettings sut;
public AppUISettingsTests()
{
A.CallTo(() => grainFactory.GetGrain<IAppUISettingsGrain>(A<string>.Ignored, null))
.Returns(grain);
sut = new AppUISettings(grainFactory);
}
[Fact]
public async Task Should_call_grain_when_retrieving_settings()
{
var settings = JsonValue.Object();
A.CallTo(() => grain.GetAsync())
.Returns(settings.AsJ());
var result = await sut.GetAsync(Guid.NewGuid(), "user");
Assert.Same(settings, result);
}
[Fact]
public async Task Should_call_grain_when_setting_value()
{
var value = JsonValue.Object();
await sut.SetAsync(Guid.NewGuid(), "user", "the.path", value);
A.CallTo(() => grain.SetAsync("the.path", A<J<IJsonValue>>.That.Matches(x => x.Value == value)))
.MustHaveHappened();
}
[Fact]
public async Task Should_call_grain_when_replacing_settings()
{
var value = JsonValue.Object();
await sut.SetAsync(Guid.NewGuid(), "user", value);
A.CallTo(() => grain.SetAsync(A<J<JsonObject>>.That.Matches(x => x.Value == value)))
.MustHaveHappened();
}
[Fact]
public async Task Should_call_grain_when_removing_value()
{
await sut.RemoveAsync(Guid.NewGuid(), "user", "the.path");
A.CallTo(() => grain.RemoveAsync("the.path"))
.MustHaveHappened();
}
}
}
Loading…
Cancel
Save