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")
# 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.

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
{
public sealed class AppUISettingsGrain : GrainOfGuid, IAppUISettingsGrain
public sealed class AppUISettingsGrain : GrainOfString, IAppUISettingsGrain
{
private readonly IGrainState<GrainState> state;
@ -46,7 +46,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
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)
{
@ -58,19 +58,19 @@ namespace Squidex.Domain.Apps.Entities.Apps
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);
}
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));
@ -85,11 +85,18 @@ namespace Squidex.Domain.Apps.Entities.Apps
foreach (var segment in segments.Take(segments.Length - 1))
{
if (!current.TryGetValue(segment, out var temp))
{
if (add)
{
temp = JsonValue.Object();
current[segment] = temp;
}
else
{
return null;
}
}
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 SettingsFile = "Settings.json";
private readonly IGrainFactory grainFactory;
private readonly IAppUISettings appUISettings;
private readonly IUserResolver userResolver;
private readonly IAppsByNameIndex appsByNameIndex;
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 BackupApps(IGrainFactory grainFactory, IUserResolver userResolver)
public BackupApps(IGrainFactory grainFactory, IAppUISettings appUISettings, IUserResolver userResolver)
{
Guard.NotNull(grainFactory, nameof(grainFactory));
Guard.NotNull(userResolver, nameof(userResolver));
Guard.NotNull(appUISettings, nameof(appUISettings));
this.grainFactory = grainFactory;
this.appUISettings = appUISettings;
this.userResolver = userResolver;
appsByNameIndex = grainFactory.GetGrain<IAppsByNameIndex>(SingleGrain.Id);
@ -182,7 +185,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
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);
}
@ -191,7 +194,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
{
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)

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
{
public interface IAppUISettingsGrain : IGrainWithGuidKey
public interface IAppUISettingsGrain : IGrainWithStringKey
{
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 Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Orleans;
using Squidex.Areas.Api.Controllers.UI.Models;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Security;
using Squidex.Shared;
using Squidex.Web;
@ -24,14 +23,14 @@ namespace Squidex.Areas.Api.Controllers.UI
{
private static readonly Permission CreateAppPermission = new Permission(Permissions.AdminAppCreate);
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)
{
this.uiOptions = uiOptions.Value;
this.grainFactory = grainFactory;
this.appUISettings = appUISettings;
}
/// <summary>
@ -68,9 +67,28 @@ namespace Squidex.Areas.Api.Controllers.UI
[ApiPermission]
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>
@ -88,7 +106,27 @@ namespace Squidex.Areas.Api.Controllers.UI
[ApiPermission]
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();
}
@ -107,9 +145,40 @@ namespace Squidex.Areas.Api.Controllers.UI
[ApiPermission]
public async Task<IActionResult> DeleteSetting(string app, string key)
{
await grainFactory.GetGrain<IAppUISettingsGrain>(AppId).RemoveAsync(key);
await appUISettings.RemoveAsync(AppId, null, key);
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>()
.As<IAppProvider>();
services.AddSingletonAs<AppUISettings>()
.As<IAppUISettings>();
services.AddSingletonAs<AssetEnricher>()
.As<IAssetEnricher>();

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

@ -28,28 +28,10 @@
<hr />
<div class="sidebar-section">
<h3>Saved queries</h3>
<ng-container *ngIf="queries.queries | async; let assetQueries">
<ng-container *ngIf="assetQueries.length > 0; else noQuery">
<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>
<sqx-shared-queries types="contents"
[queries]="assetsQueries"
[queryUsed]="isQueryUsed"
(search)="search($event.query)">
</sqx-shared-queries>
</ng-container>
</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'
})
export class AssetsFiltersPageComponent {
public queries = new Queries(this.uiState, 'assets');
public assetsQueries = new Queries(this.uiState, 'assets');
constructor(
public readonly assetsState: AssetsState,
@ -29,6 +29,10 @@ export class AssetsFiltersPageComponent {
) {
}
public isQueryUsed = (query: SavedQuery) => {
return this.assetsState.isQueryUsed(query);
}
public search(query: Query) {
this.assetsState.search(query);
}
@ -45,15 +49,7 @@ export class AssetsFiltersPageComponent {
this.assetsState.resetTags();
}
public isSelectedQuery(saved: SavedQuery) {
return this.assetsState.isQueryUsed(saved);
}
public trackByTag(index: number, tag: { name: string }) {
public trackByTag(tag: { name: string }) {
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>.
</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
[form]="contentForm"
[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 languages: ImmutableArray<AppLanguageDto>;
public trackByFieldFn: Function;
@ViewChild('dueTimeSelector', { static: false })
public dueTimeSelector: DueTimeSelectorComponent;
@ -80,6 +82,8 @@ export class ContentPageComponent extends ResourceOwner implements CanComponentD
) {
super();
this.trackByFieldFn = this.trackByField.bind(this);
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 content>
<a class="sidebar-item" *ngFor="let default of schemaQueries.defaultQueries; trackBy: trackByTag" (click)="search(default.query)"
[class.active]="isSelectedQuery(default)">
<a class="sidebar-item" *ngFor="let default of schemaQueries.defaultQueries; trackBy: trackByQuery" (click)="search(default.query)"
[class.active]="isQueryUsed(default)">
{{default.name}}
</a>
@ -15,7 +15,7 @@
<h3>Status Queries</h3>
<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}}
</a>
@ -23,28 +23,10 @@
<hr />
<div class="sidebar-section">
<h3>Saved queries</h3>
<ng-container *ngIf="schemaQueries.queries | async; let queries">
<ng-container *ngIf="queries.length > 0; else noQuery">
<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>
<sqx-shared-queries types="contents"
[queries]="schemaQueries"
[queryUsed]="isQueryUsed"
(search)="search($event.query)">
</sqx-shared-queries>
</ng-container>
</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) {
this.contentsState.search(query);
}
public isSelectedQuery(saved: SavedQuery) {
return this.contentsState.isQueryUsed(saved);
public isQueryUsed = (query: SavedQuery) => {
return this.contentsState.isQueryUsed(query);
}
public trackByTag(index: number, tag: { name: string }) {
return tag.name;
public search(query: Query) {
this.contentsState.search(query);
}
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" />
</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">
<sqx-content-value-editor [form]="patchForm.form" [field]="field"></sqx-content-value-editor>
</ng-container>

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

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

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

@ -6,14 +6,14 @@
<ng-container content>
<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">
Set to {{dueTimeAction}} immediately.
</label>
</div>
<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">
Set to {{dueTimeAction}} at a later point date and time.
</label>

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

@ -98,7 +98,7 @@
[sqxSortDisabled]="!isEditable"
[sqxSortModel]="nested"
(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>
<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 trackByFieldFn: Function;
public isEditing = false;
public isEditable = false;
@ -58,6 +60,7 @@ export class FieldComponent implements OnChanges {
private readonly formBuilder: FormBuilder,
private readonly schemasState: SchemasState
) {
this.trackByFieldFn = this.trackByField.bind(this);
}
public ngOnChanges(changes: SimpleChanges) {

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

@ -79,7 +79,7 @@
[sqxSortDisabled]="!schema.canOrderFields"
[sqxSortModel]="schema.fields"
(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>
</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 exportDialog = new DialogModel();
public trackByFieldFn: Function;
constructor(
public readonly appsState: AppsState,
public readonly schemasState: SchemasState,
@ -57,6 +59,8 @@ export class SchemaPageComponent extends ResourceOwner implements OnInit {
private readonly messageBus: MessageBus
) {
super();
this.trackByFieldFn = this.trackByField.bind(this);
}
public ngOnInit() {

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

@ -28,6 +28,39 @@
</ng-container>
<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">
<table class="table table-items table-fixed" *ngIf="rolesState.roles | async; let roles">
<tbody *ngFor="let contributor of contributors; trackBy: trackByContributor">
@ -56,7 +89,7 @@
</tbody>
</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-template #noContributors>
@ -64,37 +97,6 @@
No contributors found.
</div>
</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>

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

@ -10,3 +10,13 @@
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%;
}
.white {
background: #fff;
.light {
background: #fcfeff;
}
`],
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>
</div>`,
changeDetection: ChangeDetectionStrategy.OnPush
@ -37,5 +37,5 @@ export class FormAlertComponent {
public marginBottom = 4;
@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.
*/
import { Types } from './types';
import { mergeInto, Types } from './types';
describe('Types', () => {
it('should calculate hash string', () => {
@ -152,6 +152,39 @@ describe('Types', () => {
it('should treat object of empty values as empty', () => {
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 {

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

@ -128,3 +128,24 @@ export module Types {
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 />
</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 footer>

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

@ -93,7 +93,7 @@ export class SearchFormComponent implements OnChanges {
if (value) {
if (this.queries && this.query) {
this.queries.add(value.name, this.query);
this.queries.add(value.name, this.query, value.user);
}
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/rich-editor.component';
export * from './components/schema-category.component';
export * from './components/saved-queries.component';
export * from './components/search-form.component';
export * from './components/table-header.component';

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

@ -78,6 +78,7 @@ import {
RuleEventsState,
RulesService,
RulesState,
SavedQueriesComponent,
SchemaCategoryComponent,
SchemaMustExistGuard,
SchemaMustExistPublishedGuard,
@ -136,6 +137,7 @@ import {
MarkdownEditorComponent,
QueryComponent,
ReferencesDropdownComponent,
SavedQueriesComponent,
SchemaCategoryComponent,
SortingComponent,
UserDtoPicture,
@ -171,6 +173,7 @@ import {
ReferencesDropdownComponent,
RichEditorComponent,
RouterModule,
SavedQueriesComponent,
SchemaCategoryComponent,
SearchFormComponent,
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 });
}));
it('should make get request to get settings',
it('should make get request to get shared settings',
inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => {
let settings: object;
uiService.getSettings('my-app').subscribe(result => {
uiService.getSharedSettings('my-app').subscribe(result => {
settings = result;
});
@ -92,12 +92,12 @@ describe('UIService', () => {
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) => {
let settings: object;
uiService.getSettings('my-app').subscribe(result => {
uiService.getSharedSettings('my-app').subscribe(result => {
settings = result;
});
@ -111,10 +111,50 @@ describe('UIService', () => {
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) => {
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');
@ -122,14 +162,36 @@ describe('UIService', () => {
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) => {
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');
expect(req.request.method).toEqual('DELETE');
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`);
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}`);
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}`);
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
]
]
],
user: false
}));
}
}

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

@ -21,25 +21,38 @@ describe('Queries', () => {
let uiState: IMock<UIState>;
let queries$ = new BehaviorSubject({});
let queries: Queries;
beforeEach(() => {
uiState = Mock.ofType<UIState>();
uiState.setup(x => x.get('schemas.my-schema.queries', {}))
.returns(() => queries$);
const shared$ = new BehaviorSubject({
key1: '{ "fullText": "shared1" }'
});
queries$.next({
key1: '{ "fullText": "text1" }',
key2: 'text2',
const user$ = new BehaviorSubject({
key1: '{ "fullText": "user1" }'
});
const merged$ = new BehaviorSubject({
key1: '{ "fullText": "merged1" }',
key2: 'merged2',
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);
});
it('should load queries', () => {
it('should load merged queries', () => {
let converted: SavedQuery[];
queries.queries.subscribe(x => {
@ -49,12 +62,12 @@ describe('Queries', () => {
expect(converted!).toEqual([
{
name: 'key1',
query: { fullText: 'text1' },
queryJson: encodeQuery({ fullText: 'text1' })
query: { fullText: 'merged1' },
queryJson: encodeQuery({ fullText: 'merged1' })
}, {
name: 'key2',
query: { fullText: 'text2' },
queryJson: encodeQuery({ fullText: 'text2' })
query: { fullText: 'merged2' },
queryJson: encodeQuery({ fullText: 'merged2' })
}, {
name: 'key3',
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', () => {
let key: string;
@ -74,18 +119,26 @@ describe('Queries', () => {
});
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();
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', () => {
queries.remove({ name: 'key3' });
it('should forward remove user call to state', () => {
queries.removeUser({ name: 'key3' });
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 { map } from 'rxjs/operators';
import { map, shareReplay } from 'rxjs/operators';
import { compareStringsAsc, Types } from '@app/framework';
@ -33,6 +33,8 @@ const OLDEST_FIRST: Query = {
export class Queries {
public queries: Observable<SavedQuery[]>;
public queriesShared: Observable<SavedQuery[]>;
public queriesUser: Observable<SavedQuery[]>;
public defaultQueries: SavedQuery[] = [
{ name: 'All (newest first)', queryJson: '' },
@ -43,21 +45,36 @@ export class Queries {
private readonly uiState: UIState,
private readonly prefix: string
) {
this.queries = this.uiState.get(`${this.prefix}.queries`, {}).pipe(
map(settings => {
let queries = Object.keys(settings).map(name => parseStored(name, settings[name]));
const path = `${prefix}.queries`;
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) {
this.uiState.set(`${this.prefix}.queries.${key}`, JSON.stringify(query));
public removeUser(saved: SavedQuery) {
this.uiState.removeUser(this.getPath(saved.name));
}
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> {
@ -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) {
if (Types.isString(raw)) {
let query: Query;

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

@ -6,7 +6,7 @@
*/
import { of } from 'rxjs';
import { IMock, It, Mock, Times } from 'typemoq';
import { IMock, Mock } from 'typemoq';
import {
ResourceLinks,
@ -24,18 +24,30 @@ describe('UIState', () => {
appsState
} = TestValues;
const appSettings = {
mapType: 'GM',
mapSize: 1024,
const common = {
key: 'xx',
map: {
type: 'GSM',
sizeX: 800,
sizeY: 600
},
canCreateApps: true
};
const commonSettings = {
mapType: 'OSM',
mapKey: 'Key',
const shared = {
map: {
type: 'GM', key: 'xyz'
},
canCreateApps: true
};
const user = {
map: {
sizeX: 1000
},
canCustomize: true
};
const resources: ResourceLinks = {
['admin/events']: { method: 'GET', href: '/api/events' },
['admin/restore']: { method: 'GET', href: '/api/restore' },
@ -49,17 +61,14 @@ describe('UIState', () => {
beforeEach(() => {
uiService = Mock.ofType<UIService>();
uiService.setup(x => x.getSettings(app))
.returns(() => of(appSettings));
uiService.setup(x => x.getCommonSettings())
.returns(() => of(commonSettings));
.returns(() => of(common));
uiService.setup(x => x.putSetting(app, It.isAnyString(), It.isAny()))
.returns(() => of({}));
uiService.setup(x => x.getSharedSettings(app))
.returns(() => of(shared));
uiService.setup(x => x.deleteSetting(app, It.isAnyString()))
.returns(() => of({}));
uiService.setup(x => x.getUserSettings(app))
.returns(() => of(user));
usersService = Mock.ofType<UsersService>();
@ -71,10 +80,15 @@ describe('UIState', () => {
it('should load settings', () => {
expect(uiState.snapshot.settings).toEqual({
mapType: 'GM',
mapKey: 'Key',
mapSize: 1024,
canCreateApps: true
key: 'xx',
map: {
type: 'GM',
sizeX: 1000,
sizeY: 600,
key: 'xyz'
},
canCreateApps: true,
canCustomize: true
});
expect(uiState.snapshot.canReadEvents).toBeTruthy();
@ -82,17 +96,25 @@ describe('UIState', () => {
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);
expect(uiState.snapshot.settings).toEqual({
mapType: 'GM',
mapKey: 'Key',
mapSize: 1024,
key: 'xx',
map: {
type: 'GM',
sizeX: 1000,
sizeY: 600,
key: 'xyz'
},
canCreateApps: true,
canCustomize: true,
root: {
nested: 123
},
canCreateApps: true
}
});
uiState.get('root', {}).subscribe(x => {
@ -107,36 +129,83 @@ describe('UIState', () => {
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', () => {
uiState.set('root.nested1', 123);
uiState.set('root.nested2', 123);
uiState.remove('root.nested1');
it('should add value to snapshot when set as user', () => {
uiService.setup(x => x.putUserSetting(app, 'root.nested', 123))
.returns(() => of({})).verifiable();
uiState.set('root.nested', 123, true);
expect(uiState.snapshot.settings).toEqual({
mapType: 'GM',
mapKey: 'Key',
mapSize: 1024,
root: {
nested2: 123
key: 'xx',
map: {
type: 'GM',
sizeX: 1000,
sizeY: 600,
key: 'xyz'
},
canCreateApps: true
canCreateApps: true,
canCustomize: true,
root: {
nested: 123
}
});
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);
});
uiState.get('root.nested1', 1337).subscribe(x => {
uiState.get('root.notfound', 1337).subscribe(x => {
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 {
hasAnyLink,
mergeInto,
State,
Types
} from '@app/framework';
@ -24,8 +25,11 @@ interface Snapshot {
// All common settings.
settingsCommon: object & any;
// All app settings.
settingsApp: object & any;
// All shared app settings.
settingsShared: object & any;
// All user app settings.
settingsUser: object & any;
// The merged settings of app and common settings.
settings: object & any;
@ -48,6 +52,12 @@ export class UIState extends State<Snapshot> {
public settings =
this.project(x => x.settings);
public settingsShared =
this.project(x => x.settingsShared);
public settingsUser =
this.project(x => x.settingsUser);
public canReadEvents =
this.project(x => x.canReadEvents === true);
@ -65,34 +75,54 @@ export class UIState extends State<Snapshot> {
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(
private readonly appsState: AppsState,
private readonly uiService: UIService,
private readonly usersService: UsersService
) {
super({ settings: {}, settingsCommon: {}, settingsApp: {} });
super({
settings: {},
settingsCommon: {},
settingsShared: {},
settingsUser: {}
});
this.loadResources();
this.loadCommon();
appsState.selectedValidApp.subscribe(app => {
this.load();
this.load(app.name);
});
}
private load() {
this.next(s => updateAppSettings(s, {}));
private load(app: string) {
this.next(s => updateSettings(s, {}));
this.uiService.getSettings(this.appName)
this.uiService.getSharedSettings(app)
.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() {
this.uiService.getCommonSettings()
.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) {
const { key, current, root } = this.getContainer(path);
public set(path: string, value: any, user = false) {
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) {
this.uiService.putSetting(this.appName, path, value).subscribe();
this.uiService.putUserSetting(this.appName, path, value).subscribe();
current[key] = value;
this.next(s => updateAppSettings(s, root));
this.next(s => updateSettings(s, { settingsUser: root }));
}
}
public remove(path: string) {
const { key, current, root } = this.getContainer(path);
private setShared(path: string, value: any) {
const { key, current, root } = getContainer(this.snapshot.settingsShared, path);
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) {
const segments = path.split('.');
public remove(path: string) {
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) {
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
delete current[key];
let temp = current[segment];
this.next(s => updateSettings(s, { settingsUser: root }));
if (!temp) {
temp = {};
} else {
temp = { ...temp };
return true;
}
current[segment] = temp;
if (!Types.isObject(temp)) {
return { key: null, current: null, root: null };
return false;
}
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) {
@ -188,14 +230,44 @@ export class UIState extends State<Snapshot> {
}
}
function updateAppSettings(state: Snapshot, settingsApp: object & any) {
const { settingsCommon } = state;
function getContainer(settings: object, path: string) {
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 };
}
current = temp;
}
}
return { ...state, settings: { ...settingsCommon, ...settingsApp }, settingsApp, settingsCommon };
return { key: segments[segments.length - 1], current, root };
}
function updateCommonSettings(state: Snapshot, settingsCommon: object & any) {
const { settingsApp } = state;
function updateSettings(state: Snapshot, update: Partial<Snapshot>) {
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()
{
sut = new AppUISettingsGrain(grainState);
sut.ActivateAsync(Guid.Empty).Wait();
}
[Fact]
@ -36,6 +35,9 @@ namespace Squidex.Domain.Apps.Entities.Apps
JsonValue.Object().Add("key", 15);
Assert.Equal(expected.ToString(), actual.Value.ToString());
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened();
}
[Fact]
@ -49,12 +51,16 @@ namespace Squidex.Domain.Apps.Entities.Apps
JsonValue.Object().Add("key", 123);
Assert.Equal(expected.ToString(), actual.Value.ToString());
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened();
}
[Fact]
public async Task Should_remove_root_value()
{
await sut.SetAsync("key", JsonValue.Create(123).AsJ());
await sut.RemoveAsync("key");
var actual = await sut.GetAsync();
@ -62,6 +68,9 @@ namespace Squidex.Domain.Apps.Entities.Apps
var expected = JsonValue.Object();
Assert.Equal(expected.ToString(), actual.Value.ToString());
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappenedTwiceExactly();
}
[Fact]
@ -76,12 +85,16 @@ namespace Squidex.Domain.Apps.Entities.Apps
JsonValue.Object().Add("nested", 123));
Assert.Equal(expected.ToString(), actual.Value.ToString());
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened();
}
[Fact]
public async Task Should_remove_nested_value()
{
await sut.SetAsync("root.nested", JsonValue.Create(123).AsJ());
await sut.RemoveAsync("root.nested");
var actual = await sut.GetAsync();
@ -91,6 +104,9 @@ namespace Squidex.Domain.Apps.Entities.Apps
JsonValue.Object());
Assert.Equal(expected.ToString(), actual.Value.ToString());
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappenedTwiceExactly();
}
[Fact]
@ -105,12 +121,18 @@ namespace Squidex.Domain.Apps.Entities.Apps
public async Task Should_do_nothing_if_deleting_and_nested_not_found()
{
await sut.RemoveAsync("root.nested");
A.CallTo(() => grainState.WriteAsync())
.MustNotHaveHappened();
}
[Fact]
public async Task Should_do_nothing_if_deleting_and_key_not_found()
{
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