Browse Source

Continued.

pull/414/head
Sebastian 7 years ago
parent
commit
219d1df624
  1. 67
      src/Squidex.Domain.Apps.Entities/Apps/AppUISettings.cs
  2. 9
      src/Squidex.Domain.Apps.Entities/Apps/BackupApps.cs
  3. 24
      src/Squidex.Domain.Apps.Entities/Apps/IAppUISettings.cs
  4. 39
      src/Squidex/Areas/Api/Controllers/UI/UIController.cs
  5. 3
      src/Squidex/Config/Domain/EntitiesServices.cs
  6. 27
      src/Squidex/app/features/assets/pages/assets-filters-page.component.html
  7. 16
      src/Squidex/app/features/assets/pages/assets-filters-page.component.ts
  8. 2
      src/Squidex/app/features/content/pages/content/content-page.component.html
  9. 4
      src/Squidex/app/features/content/pages/content/content-page.component.ts
  10. 33
      src/Squidex/app/features/content/pages/contents/contents-filters-page.component.html
  11. 12
      src/Squidex/app/features/content/pages/contents/contents-filters-page.component.ts
  12. 2
      src/Squidex/app/features/content/shared/content-item.component.html
  13. 3
      src/Squidex/app/features/content/shared/content-item.component.ts
  14. 2
      src/Squidex/app/features/schemas/pages/schema/field.component.html
  15. 3
      src/Squidex/app/features/schemas/pages/schema/field.component.ts
  16. 2
      src/Squidex/app/features/schemas/pages/schema/schema-page.component.html
  17. 4
      src/Squidex/app/features/schemas/pages/schema/schema-page.component.ts
  18. 87
      src/Squidex/app/shared/components/saved-queries.component.ts
  19. 1
      src/Squidex/app/shared/declarations.ts
  20. 3
      src/Squidex/app/shared/module.ts
  21. 81
      src/Squidex/app/shared/state/queries.spec.ts
  22. 39
      src/Squidex/app/shared/state/queries.ts
  23. 20
      src/Squidex/app/shared/state/ui.state.ts
  24. 1
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs
  25. 76
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsTests.cs

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

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

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

@ -5,17 +5,14 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; 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;
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;
@ -26,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>
@ -70,9 +67,9 @@ 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 GetSettingsGrain(AppKey()).GetAsync(); var result = await appUISettings.GetAsync(AppId, null);
return Ok(result.Value); return Ok(result);
} }
/// <summary> /// <summary>
@ -89,9 +86,9 @@ namespace Squidex.Areas.Api.Controllers.UI
[ApiPermission] [ApiPermission]
public async Task<IActionResult> GetUserSettings(string app) public async Task<IActionResult> GetUserSettings(string app)
{ {
var result = await GetSettingsGrain(UserKey()).GetAsync(); var result = await appUISettings.GetAsync(AppId, UserId());
return Ok(result.Value); return Ok(result);
} }
/// <summary> /// <summary>
@ -109,7 +106,7 @@ 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 GetSettingsGrain(AppKey()).SetAsync(key, request.Value.AsJ()); await appUISettings.SetAsync(AppId, null, key, request.Value);
return NoContent(); return NoContent();
} }
@ -129,7 +126,7 @@ namespace Squidex.Areas.Api.Controllers.UI
[ApiPermission] [ApiPermission]
public async Task<IActionResult> PutUserSetting(string app, string key, [FromBody] UpdateSettingDto request) public async Task<IActionResult> PutUserSetting(string app, string key, [FromBody] UpdateSettingDto request)
{ {
await GetSettingsGrain(UserKey()).SetAsync(key, request.Value.AsJ()); await appUISettings.SetAsync(AppId, UserId(), key, request.Value);
return NoContent(); return NoContent();
} }
@ -148,7 +145,7 @@ 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 GetSettingsGrain(AppKey()).RemoveAsync(key); await appUISettings.RemoveAsync(AppId, null, key);
return NoContent(); return NoContent();
} }
@ -167,22 +164,12 @@ namespace Squidex.Areas.Api.Controllers.UI
[ApiPermission] [ApiPermission]
public async Task<IActionResult> DeleteUserSetting(string app, string key) public async Task<IActionResult> DeleteUserSetting(string app, string key)
{ {
await GetSettingsGrain(UserKey()).RemoveAsync(key); await appUISettings.RemoveAsync(AppId, UserId(), key);
return NoContent(); return NoContent();
} }
private IAppUISettingsGrain GetSettingsGrain(string key) private string UserId()
{
return grainFactory.GetGrain<IAppUISettingsGrain>(key);
}
private string AppKey()
{
return $"{AppId}";
}
private string UserKey()
{ {
var subject = User.OpenIdSubject(); var subject = User.OpenIdSubject();
@ -191,7 +178,7 @@ namespace Squidex.Areas.Api.Controllers.UI
throw new DomainForbiddenException("Not allowed for clients."); throw new DomainForbiddenException("Not allowed for clients.");
} }
return $"{AppId}_{subject}"; 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>();

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

@ -28,28 +28,9 @@
<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"> </sqx-shared-queries>
<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>
</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') };
} }

33
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,9 @@
<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"> </sqx-shared-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>
</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) {

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

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 queries">
<ng-container *ngIf="queries.length > 0; else noQuery">
<a class="sidebar-item" *ngFor="let saved of queries; trackBy: trackByQuery" (click)="search.emit(saved)"
[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 {{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 queries">
<ng-container *ngIf="queries.length > 0; else noQuery">
<a class="sidebar-item" *ngFor="let saved of queries; 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 queries: Queries;
@Input()
public types: string;
@Input()
public queryUsed: (saved: SavedQuery) => boolean;
@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;
}
}

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,

81
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;
@ -81,11 +126,19 @@ describe('Queries', () => {
uiState.verify(x => x.set('schemas.my-schema.queries.key3', '{"fullText":"text3"}', true), Times.once()); uiState.verify(x => x.set('schemas.my-schema.queries.key3', '{"fullText":"text3"}', true), Times.once());
}); });
it('should forward remove call to state', () => { it('should forward remove shared call to state', () => {
queries.remove({ name: 'key3' }); queries.removeShared({ name: 'key3' });
expect(true).toBeTruthy();
uiState.verify(x => x.removeShared('schemas.my-schema.queries.key3'), Times.once());
});
it('should forward remove user call to state', () => {
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());
}); });
}); });

39
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,32 @@ 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) { public add(key: string, query: Query, user = false) {
this.uiState.set(`${this.prefix}.queries.${key}`, JSON.stringify(query), user); this.uiState.set(this.getPath(key), JSON.stringify(query), user);
} }
public remove(saved: SavedQuery) { public removeShared(saved: SavedQuery) {
this.uiState.remove(`${this.prefix}.queries.${saved.name}`); this.uiState.removeShared(this.getPath(saved.name));
}
public removeUser(saved: SavedQuery) {
this.uiState.removeUser(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 +89,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;

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

@ -52,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);
@ -69,6 +75,16 @@ 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,
@ -157,7 +173,7 @@ export class UIState extends State<Snapshot> {
return this.removeUser(path) || this.removeShared(path); return this.removeUser(path) || this.removeShared(path);
} }
private removeUser(path: string) { public removeUser(path: string) {
const { key, current, root } = getContainer(this.snapshot.settingsUser, path); const { key, current, root } = getContainer(this.snapshot.settingsUser, path);
if (current && key && current[key]) { if (current && key && current[key]) {
@ -173,7 +189,7 @@ export class UIState extends State<Snapshot> {
return false; return false;
} }
private removeShared(path: string) { public removeShared(path: string) {
const { key, current, root } = getContainer(this.snapshot.settingsShared, path); const { key, current, root } = getContainer(this.snapshot.settingsShared, path);
if (current && key && current[key]) { if (current && key && current[key]) {

1
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]

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.IsSameAs(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.IsSameAs(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