mirror of https://github.com/Squidex/squidex.git
Browse Source
# Conflicts: # src/Squidex/Areas/Api/Controllers/UI/UIController.cs # src/Squidex/app/theme/icomoon/demo.html # src/Squidex/app/theme/icomoon/fonts/icomoon.eot # src/Squidex/app/theme/icomoon/fonts/icomoon.svg # src/Squidex/app/theme/icomoon/fonts/icomoon.ttf # src/Squidex/app/theme/icomoon/fonts/icomoon.woff # src/Squidex/app/theme/icomoon/selection.json # src/Squidex/app/theme/icomoon/style.css # src/Squidex/appsettings.jsonpull/314/head
67 changed files with 1573 additions and 585 deletions
@ -0,0 +1,117 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System; |
||||
|
using System.Linq; |
||||
|
using System.Threading.Tasks; |
||||
|
using Newtonsoft.Json.Linq; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Orleans; |
||||
|
using Squidex.Infrastructure.States; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Apps |
||||
|
{ |
||||
|
public sealed class AppUISettingsGrain : GrainOfGuid, IAppUISettingsGrain |
||||
|
{ |
||||
|
private readonly IStore<Guid> store; |
||||
|
private IPersistence<State> persistence; |
||||
|
private State state = new State(); |
||||
|
|
||||
|
[CollectionName("UISettings")] |
||||
|
public sealed class State |
||||
|
{ |
||||
|
public JObject Settings { get; set; } = new JObject(); |
||||
|
} |
||||
|
|
||||
|
public AppUISettingsGrain(IStore<Guid> store) |
||||
|
{ |
||||
|
Guard.NotNull(store, nameof(store)); |
||||
|
|
||||
|
this.store = store; |
||||
|
} |
||||
|
|
||||
|
public override Task OnActivateAsync(Guid key) |
||||
|
{ |
||||
|
persistence = store.WithSnapshots<State, Guid>(GetType(), key, x => state = x); |
||||
|
|
||||
|
return persistence.ReadAsync(); |
||||
|
} |
||||
|
|
||||
|
public Task<J<JObject>> GetAsync() |
||||
|
{ |
||||
|
return Task.FromResult(state.Settings.AsJ()); |
||||
|
} |
||||
|
|
||||
|
public Task SetAsync(J<JObject> settings) |
||||
|
{ |
||||
|
state.Settings = settings; |
||||
|
|
||||
|
return persistence.WriteSnapshotAsync(state); |
||||
|
} |
||||
|
|
||||
|
public Task SetAsync(string path, J<JToken> value) |
||||
|
{ |
||||
|
var container = GetContainer(path, out var key); |
||||
|
|
||||
|
if (container == null) |
||||
|
{ |
||||
|
throw new InvalidOperationException("Path does not lead to an object."); |
||||
|
} |
||||
|
|
||||
|
container[key] = value; |
||||
|
|
||||
|
return persistence.WriteSnapshotAsync(state); |
||||
|
} |
||||
|
|
||||
|
public Task RemoveAsync(string path) |
||||
|
{ |
||||
|
var container = GetContainer(path, out var key); |
||||
|
|
||||
|
if (container != null) |
||||
|
{ |
||||
|
container.Remove(key); |
||||
|
} |
||||
|
|
||||
|
return persistence.WriteSnapshotAsync(state); |
||||
|
} |
||||
|
|
||||
|
private JObject GetContainer(string path, out string key) |
||||
|
{ |
||||
|
Guard.NotNullOrEmpty(path, nameof(path)); |
||||
|
|
||||
|
var segments = path.Split('.'); |
||||
|
|
||||
|
key = segments[segments.Length - 1]; |
||||
|
|
||||
|
var current = state.Settings; |
||||
|
|
||||
|
if (segments.Length > 1) |
||||
|
{ |
||||
|
foreach (var segment in segments.Take(segments.Length - 1)) |
||||
|
{ |
||||
|
if (!current.TryGetValue(segment, out var temp)) |
||||
|
{ |
||||
|
temp = new JObject(); |
||||
|
|
||||
|
current[segment] = temp; |
||||
|
} |
||||
|
|
||||
|
if (temp is JObject next) |
||||
|
{ |
||||
|
current = next; |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return current; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Threading.Tasks; |
||||
|
using Newtonsoft.Json.Linq; |
||||
|
using Orleans; |
||||
|
using Squidex.Infrastructure.Orleans; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Apps |
||||
|
{ |
||||
|
public interface IAppUISettingsGrain : IGrainWithGuidKey |
||||
|
{ |
||||
|
Task<J<JObject>> GetAsync(); |
||||
|
|
||||
|
Task SetAsync(string path, J<JToken> value); |
||||
|
|
||||
|
Task SetAsync(J<JObject> settings); |
||||
|
|
||||
|
Task RemoveAsync(string path); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Newtonsoft.Json.Linq; |
||||
|
|
||||
|
namespace Squidex.Areas.Api.Controllers.UI.Models |
||||
|
{ |
||||
|
public sealed class UpdateSettingDto |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// The value for the setting.
|
||||
|
/// </summary>
|
||||
|
public JToken Value { get; set; } |
||||
|
} |
||||
|
} |
||||
@ -1,60 +0,0 @@ |
|||||
<ng-container *ngIf="enableShortcut"> |
|
||||
<sqx-shortcut keys="ctrl+shift+f" (trigger)="inputFind.focus()"></sqx-shortcut> |
|
||||
</ng-container> |
|
||||
|
|
||||
<form [class]="formClass" (ngSubmit)="search()"> |
|
||||
<input class="form-control form-control-expandable" #inputFind [formControl]="contentsFilter" placeholder="Search for content" /> |
|
||||
|
|
||||
<a class="expand-search" (click)="searchModal.toggle()" #archive> |
|
||||
<i class="icon-caret-down"></i> |
|
||||
</a> |
|
||||
</form> |
|
||||
|
|
||||
<sqx-onboarding-tooltip id="contentArchive" [for]="archive" position="bottomRight" after="60000"> |
|
||||
Click this icon to show the advanced search menu and to show the archive! |
|
||||
</sqx-onboarding-tooltip> |
|
||||
|
|
||||
<sqx-onboarding-tooltip id="contentFind" [for]="inputFind" position="bottomRight" after="120000"> |
|
||||
Search for content using full text search over all fields and languages! |
|
||||
</sqx-onboarding-tooltip> |
|
||||
|
|
||||
<div class="dropdown-menu" *sqxModalView="searchModal" [sqxModalTarget]="inputFind"> |
|
||||
<div class="form-horizontal"> |
|
||||
<div [formGroup]="searchForm"> |
|
||||
<div class="form-group row"> |
|
||||
<label class="col col-2 col-form-label" for="odataSearch">Text</label> |
|
||||
|
|
||||
<div class="col col-10"> |
|
||||
<input type="input" class="form-control" id="search" (blur)="updateQuery()" formControlName="odataSearch" placeholder="Fulltext search" /> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div class="form-group row"> |
|
||||
<label class="col col-2 col-form-label" for="filter">Filter</label> |
|
||||
|
|
||||
<div class="col col-10"> |
|
||||
<input type="input" class="form-control" id="filter" (blur)="updateQuery()" formControlName="odataFilter" placeholder="data/[MY_FIELD]/iv eq 100" /> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div class="form-group row"> |
|
||||
<label class="col col-2 col-form-label" for="orderBy">Order</label> |
|
||||
|
|
||||
<div class="col col-10"> |
|
||||
<input type="input" class="form-control" id="orderBy" (blur)="updateQuery()" formControlName="odataOrderBy" placeholder="data/[MY_FIELD]/iv desc" /> |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
|
||||
<div class="form-check" *ngIf="canArchive"> |
|
||||
<input class="form-check-input" type="checkbox" id="archivedItems" [ngModel]="archived" (ngModelChange)="archivedChanged.emit($event)" /> |
|
||||
<label class="form-check-label" for="archivedItems"> |
|
||||
Archived items |
|
||||
</label> |
|
||||
</div> |
|
||||
|
|
||||
<div class="link"> |
|
||||
Read more about filtering in the <a href="https://docs.squidex.io/04-guides/02-api.html" target="_blank">Documentation</a>. |
|
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
@ -0,0 +1,99 @@ |
|||||
|
<ng-container *ngIf="enableShortcut"> |
||||
|
<sqx-shortcut keys="ctrl+shift+f" (trigger)="inputFind.focus()"></sqx-shortcut> |
||||
|
</ng-container> |
||||
|
|
||||
|
<form [class]="formClass" (ngSubmit)="search()"> |
||||
|
<input class="form-control form-control-expandable" #inputFind [formControl]="contentsFilter" [placeholder]="placeholder" /> |
||||
|
|
||||
|
<ng-container *ngIf="expandable"> |
||||
|
<a class="expand-search" (click)="searchModal.toggle()" #expand> |
||||
|
<i class="icon-caret-down"></i> |
||||
|
</a> |
||||
|
|
||||
|
<sqx-onboarding-tooltip id="contentArchive" [for]="expand" position="bottomRight" after="60000"> |
||||
|
Click this icon to show the advanced search menu and to show the archive! |
||||
|
</sqx-onboarding-tooltip> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container *ngIf="queries"> |
||||
|
<ng-container *ngIf="saveKey | async; else notBookmarked; let key"> |
||||
|
<a class="save-search" (click)="queries.remove(key)"> |
||||
|
<i class="icon-star-full"></i> |
||||
|
</a> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-template #notBookmarked> |
||||
|
<a class="save-search" (click)="saveQuery()" *ngIf="contentsFilterValue | async"> |
||||
|
<i class="icon-star-empty"></i> |
||||
|
</a> |
||||
|
</ng-template> |
||||
|
</ng-container> |
||||
|
</form> |
||||
|
|
||||
|
<sqx-onboarding-tooltip id="contentFind" [for]="inputFind" position="bottomRight" after="120000"> |
||||
|
Search for content using full text search over all fields and languages! |
||||
|
</sqx-onboarding-tooltip> |
||||
|
|
||||
|
<div class="dropdown-menu" *sqxModalView="searchModal" [sqxModalTarget]="inputFind"> |
||||
|
<div class="form-horizontal"> |
||||
|
<div [formGroup]="searchForm"> |
||||
|
<div class="form-group row"> |
||||
|
<label class="col col-2 col-form-label" for="odataSearch">Text</label> |
||||
|
|
||||
|
<div class="col col-10"> |
||||
|
<input type="input" class="form-control" id="search" (blur)="updateQuery()" formControlName="odataSearch" placeholder="Fulltext search" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="form-group row"> |
||||
|
<label class="col col-2 col-form-label" for="filter">Filter</label> |
||||
|
|
||||
|
<div class="col col-10"> |
||||
|
<input type="input" class="form-control" id="filter" (blur)="updateQuery()" formControlName="odataFilter" placeholder="{{fieldExample}} eq [VALUE]" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="form-group row"> |
||||
|
<label class="col col-2 col-form-label" for="orderBy">Order</label> |
||||
|
|
||||
|
<div class="col col-10"> |
||||
|
<input type="input" class="form-control" id="orderBy" (blur)="updateQuery()" formControlName="odataOrderBy" placeholder="{{fieldExample}} desc" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div class="form-check" *ngIf="enableArchive"> |
||||
|
<input class="form-check-input" type="checkbox" id="archivedItems" [ngModel]="archived" (ngModelChange)="archivedChanged.emit($event)" /> |
||||
|
<label class="form-check-label" for="archivedItems"> |
||||
|
Archived items |
||||
|
</label> |
||||
|
</div> |
||||
|
|
||||
|
<div class="link"> |
||||
|
Read more about filtering in the <a href="https://docs.squidex.io/04-guides/02-api.html" target="_blank">Documentation</a>. |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<ng-container *sqxModalView="saveQueryDialog;onRoot:true"> |
||||
|
<form [formGroup]="saveQueryForm.form" (ngSubmit)="saveQueryComplete()"> |
||||
|
<sqx-modal-dialog (closed)="saveQueryDialog.hide()"> |
||||
|
<ng-container title> |
||||
|
Name your query |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container content> |
||||
|
<div class="form-group"> |
||||
|
<sqx-control-errors for="name" submitOnly="true" [submitted]="saveQueryForm.submitted | async"></sqx-control-errors> |
||||
|
|
||||
|
<input type="text" class="form-control" id="appName" formControlName="name" autocomplete="off" sqxFocusOnInit /> |
||||
|
</div> |
||||
|
</ng-container> |
||||
|
|
||||
|
<ng-container footer> |
||||
|
<button type="reset" class="float-left btn btn-secondary" (click)="saveQueryComplete()" [disabled]="saveQueryForm.submitted | async">Cancel</button> |
||||
|
<button type="submit" class="float-right btn btn-success">Create</button> |
||||
|
</ng-container> |
||||
|
</sqx-modal-dialog> |
||||
|
</form> |
||||
|
</ng-container> |
||||
@ -0,0 +1,79 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { BehaviorSubject } from 'rxjs'; |
||||
|
import { IMock, Mock, Times } from 'typemoq'; |
||||
|
|
||||
|
import { Queries, Query } from './queries'; |
||||
|
import { UIState } from './ui.state'; |
||||
|
|
||||
|
describe('Queries', () => { |
||||
|
const prefix = 'schemas.my-schema'; |
||||
|
|
||||
|
let uiState: IMock<UIState>; |
||||
|
let filter = new BehaviorSubject(''); |
||||
|
let queries$ = new BehaviorSubject({}); |
||||
|
let queries: Queries; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
uiState = Mock.ofType<UIState>(); |
||||
|
|
||||
|
uiState.setup(x => x.get('schemas.my-schema.queries', {})) |
||||
|
.returns(() => queries$); |
||||
|
|
||||
|
queries$.next({ |
||||
|
key1: 'query1', |
||||
|
key2: 'query2' |
||||
|
}); |
||||
|
|
||||
|
queries = new Queries(uiState.object, prefix); |
||||
|
}); |
||||
|
|
||||
|
it('should load queries', () => { |
||||
|
let converted: Query[]; |
||||
|
|
||||
|
queries.queries.subscribe(x => { |
||||
|
converted = x; |
||||
|
}); |
||||
|
|
||||
|
expect(converted!).toEqual([ |
||||
|
{ |
||||
|
name: 'key1', |
||||
|
nameSortable: 'KEY1', |
||||
|
filter: 'query1' |
||||
|
}, { |
||||
|
name: 'key2', |
||||
|
nameSortable: 'KEY2', |
||||
|
filter: 'query2' |
||||
|
} |
||||
|
]); |
||||
|
}); |
||||
|
|
||||
|
it('should provide key', () => { |
||||
|
let key: string; |
||||
|
|
||||
|
queries.getSaveKey(filter).subscribe(x => { |
||||
|
key = x!; |
||||
|
}); |
||||
|
|
||||
|
filter.next('query2'); |
||||
|
|
||||
|
expect(key!).toEqual('key2'); |
||||
|
}); |
||||
|
|
||||
|
it('should forward add call to state', () => { |
||||
|
queries.add('key3', 'filter3'); |
||||
|
|
||||
|
uiState.verify(x => x.set('schemas.my-schema.queries.key3', 'filter3'), Times.once()); |
||||
|
}); |
||||
|
|
||||
|
it('should forward remove call to state', () => { |
||||
|
queries.remove('key3'); |
||||
|
|
||||
|
uiState.verify(x => x.remove('schemas.my-schema.queries.key3'), Times.once()); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,78 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { combineLatest, Observable } from 'rxjs'; |
||||
|
import { map } from 'rxjs/operators'; |
||||
|
|
||||
|
import { UIState } from './ui.state'; |
||||
|
|
||||
|
export interface Query { |
||||
|
name: string; |
||||
|
nameSortable?: string; |
||||
|
filter: string; |
||||
|
} |
||||
|
|
||||
|
export class Queries { |
||||
|
public queries: Observable<Query[]>; |
||||
|
|
||||
|
public defaultQueries: Query[] = [{ |
||||
|
name: 'All (newest first)', filter: '' |
||||
|
}, { |
||||
|
name: 'All (oldest first)', filter: '$orderby=lastModified asc' |
||||
|
}]; |
||||
|
|
||||
|
constructor( |
||||
|
private readonly uiState: UIState, |
||||
|
private readonly prefix: string |
||||
|
) { |
||||
|
this.queries = this.uiState.get(`${this.prefix}.queries`, {}).pipe( |
||||
|
map(x => { |
||||
|
let queries: Query[] = Object.keys(x).map(y => ({ name: y, filter: x[y] })); |
||||
|
|
||||
|
for (let query of queries) { |
||||
|
query.nameSortable = query.name.toUpperCase(); |
||||
|
} |
||||
|
|
||||
|
queries = queries.sort((a, b) => { |
||||
|
if (a.nameSortable! < b.nameSortable!) { |
||||
|
return -1; |
||||
|
} |
||||
|
if (a.nameSortable! > b.nameSortable!) { |
||||
|
return 1; |
||||
|
} |
||||
|
return 0; |
||||
|
}); |
||||
|
|
||||
|
return queries; |
||||
|
}) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public add(key: string, filter: string) { |
||||
|
this.uiState.set(`${this.prefix}.queries.${key}`, filter); |
||||
|
} |
||||
|
|
||||
|
public remove(key: string) { |
||||
|
this.uiState.remove(`${this.prefix}.queries.${key}`); |
||||
|
} |
||||
|
|
||||
|
public getSaveKey(filter$: Observable<string>): Observable<string | null> { |
||||
|
return combineLatest(this.queries, filter$).pipe( |
||||
|
map(project => { |
||||
|
const filter = project[1]; |
||||
|
|
||||
|
if (filter) { |
||||
|
for (let query of project[0]) { |
||||
|
if (query.filter === filter) { |
||||
|
return query.name; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return null; |
||||
|
})); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,102 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { of } from 'rxjs'; |
||||
|
import { IMock, It, Mock, Times } from 'typemoq'; |
||||
|
|
||||
|
import { AppsState } from '@app/shared'; |
||||
|
|
||||
|
import { UIService } from './../services/ui.service'; |
||||
|
import { UIState } from './ui.state'; |
||||
|
|
||||
|
describe('UIState', () => { |
||||
|
const app = 'my-app'; |
||||
|
|
||||
|
const oldSettings = { |
||||
|
mapType: 'OSM' |
||||
|
}; |
||||
|
|
||||
|
let appsState: IMock<AppsState>; |
||||
|
let uiService: IMock<UIService>; |
||||
|
let uiState: UIState; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
appsState = Mock.ofType<AppsState>(); |
||||
|
|
||||
|
appsState.setup(x => x.appName) |
||||
|
.returns(() => app); |
||||
|
|
||||
|
uiService = Mock.ofType<UIService>(); |
||||
|
|
||||
|
uiService.setup(x => x.getSettings(app)) |
||||
|
.returns(() => of(oldSettings)); |
||||
|
|
||||
|
uiService.setup(x => x.putSetting(app, It.isAnyString(), It.isAny())) |
||||
|
.returns(() => of({})); |
||||
|
|
||||
|
uiService.setup(x => x.deleteSetting(app, It.isAnyString())) |
||||
|
.returns(() => of({})); |
||||
|
|
||||
|
uiState = new UIState(appsState.object, uiService.object); |
||||
|
}); |
||||
|
|
||||
|
it('should load settings', () => { |
||||
|
expect(uiState.snapshot.settings).toEqual(oldSettings); |
||||
|
}); |
||||
|
|
||||
|
it('should add value to snapshot when set', () => { |
||||
|
uiState.set('root.nested', 123); |
||||
|
|
||||
|
expect(uiState.snapshot.settings).toEqual({ |
||||
|
mapType: 'OSM', |
||||
|
root: { |
||||
|
nested: 123 |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
uiState.get('root', {}).subscribe(x => { |
||||
|
expect(x).toEqual({ nested: 123 }); |
||||
|
}); |
||||
|
|
||||
|
uiState.get('root.nested', 0).subscribe(x => { |
||||
|
expect(x).toEqual(123); |
||||
|
}); |
||||
|
|
||||
|
uiState.get('root.notfound', 1337).subscribe(x => { |
||||
|
expect(x).toEqual(1337); |
||||
|
}); |
||||
|
|
||||
|
uiService.verify(x => x.putSetting(app, 'root.nested', 123), Times.once()); |
||||
|
}); |
||||
|
|
||||
|
it('should remove value from snapshot when removed', () => { |
||||
|
uiState.set('root.nested1', 123); |
||||
|
uiState.set('root.nested2', 123); |
||||
|
uiState.remove('root.nested1'); |
||||
|
|
||||
|
expect(uiState.snapshot.settings).toEqual({ |
||||
|
mapType: 'OSM', |
||||
|
root: { |
||||
|
nested2: 123 |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
uiState.get('root', {}).subscribe(x => { |
||||
|
expect(x).toEqual({ nested2: 123 }); |
||||
|
}); |
||||
|
|
||||
|
uiState.get('root.nested2', 0).subscribe(x => { |
||||
|
expect(x).toEqual(123); |
||||
|
}); |
||||
|
|
||||
|
uiState.get('root.nested1', 1337).subscribe(x => { |
||||
|
expect(x).toEqual(1337); |
||||
|
}); |
||||
|
|
||||
|
uiService.verify(x => x.deleteSetting(app, 'root.nested1'), Times.once()); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,139 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { Injectable } from '@angular/core'; |
||||
|
import { distinctUntilChanged, map } from 'rxjs/operators'; |
||||
|
|
||||
|
import { State, Types } from '@app/framework'; |
||||
|
|
||||
|
import { AppsState } from './apps.state'; |
||||
|
|
||||
|
import { UIService, UISettingsDto } from './../services/ui.service'; |
||||
|
|
||||
|
interface Snapshot { |
||||
|
settings: object & any; |
||||
|
} |
||||
|
|
||||
|
@Injectable() |
||||
|
export class UIState extends State<Snapshot> { |
||||
|
public settings = |
||||
|
this.changes.pipe(map(x => x.settings), |
||||
|
distinctUntilChanged()); |
||||
|
|
||||
|
public get<T>(path: string, defaultValue: T) { |
||||
|
return this.settings.pipe(map(x => this.getValue(x, path, defaultValue)), |
||||
|
distinctUntilChanged()); |
||||
|
} |
||||
|
|
||||
|
constructor( |
||||
|
private readonly appsState: AppsState, |
||||
|
private readonly uiService: UIService |
||||
|
) { |
||||
|
super({ settings: { mapType: 'OSM' } }); |
||||
|
|
||||
|
if (appsState.selectedApp && Types.isFunction(appsState.selectedApp.subscribe)) { |
||||
|
appsState.selectedApp.subscribe(app => { |
||||
|
if (app) { |
||||
|
this.load(true); |
||||
|
} |
||||
|
}); |
||||
|
} else { |
||||
|
this.load(true); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public load(reset = false) { |
||||
|
if (!reset) { |
||||
|
this.resetState(); |
||||
|
} |
||||
|
|
||||
|
this.uiService.getSettings(this.appName) |
||||
|
.subscribe(dtos => { |
||||
|
return this.next({ settings: dtos }); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public set(path: string, value: any) { |
||||
|
const { key, current, root } = this.getContainer(path); |
||||
|
|
||||
|
if (current && key) { |
||||
|
this.uiService.putSetting(this.appName, path, value).subscribe(); |
||||
|
|
||||
|
current[key] = value; |
||||
|
|
||||
|
this.next({ settings: root }); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public remove(path: string) { |
||||
|
const { key, current, root } = this.getContainer(path); |
||||
|
|
||||
|
if (current && key) { |
||||
|
this.uiService.deleteSetting(this.appName, path).subscribe(); |
||||
|
|
||||
|
delete current[key]; |
||||
|
|
||||
|
this.next({ settings: root }); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private getContainer(path: string) { |
||||
|
const segments = path.split('.'); |
||||
|
|
||||
|
let current = { ...this.snapshot.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 { key: segments[segments.length - 1], current, root }; |
||||
|
} |
||||
|
|
||||
|
private getValue<T>(setting: object & UISettingsDto, path: string, defaultValue: T) { |
||||
|
const segments = path.split('.'); |
||||
|
|
||||
|
let current = setting; |
||||
|
|
||||
|
for (let segment of segments) { |
||||
|
let temp = current[segment]; |
||||
|
|
||||
|
if (temp) { |
||||
|
current[segment] = temp; |
||||
|
} else { |
||||
|
return defaultValue; |
||||
|
} |
||||
|
|
||||
|
current = temp; |
||||
|
} |
||||
|
|
||||
|
return <T><any>current; |
||||
|
} |
||||
|
|
||||
|
private get appName() { |
||||
|
return this.appsState.appName; |
||||
|
} |
||||
|
} |
||||
Binary file not shown.
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 80 KiB |
Binary file not shown.
Binary file not shown.
@ -0,0 +1,125 @@ |
|||||
|
// ==========================================================================
|
||||
|
// 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 Newtonsoft.Json.Linq; |
||||
|
using Squidex.Infrastructure.Orleans; |
||||
|
using Squidex.Infrastructure.States; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Apps |
||||
|
{ |
||||
|
public sealed class AppUISettingsGrainTests |
||||
|
{ |
||||
|
private readonly IStore<Guid> store = A.Fake<IStore<Guid>>(); |
||||
|
private readonly IPersistence<AppUISettingsGrain.State> persistence = A.Fake<IPersistence<AppUISettingsGrain.State>>(); |
||||
|
private readonly AppUISettingsGrain sut; |
||||
|
|
||||
|
public AppUISettingsGrainTests() |
||||
|
{ |
||||
|
A.CallTo(() => store.WithSnapshots(A<Type>.Ignored, A<Guid>.Ignored, A<Func<AppUISettingsGrain.State, Task>>.Ignored)) |
||||
|
.Returns(persistence); |
||||
|
|
||||
|
sut = new AppUISettingsGrain(store); |
||||
|
sut.OnActivateAsync(Guid.Empty).Wait(); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_set_setting() |
||||
|
{ |
||||
|
await sut.SetAsync(new JObject(new JProperty("key", 15)).AsJ()); |
||||
|
|
||||
|
var actual = await sut.GetAsync(); |
||||
|
|
||||
|
var expected = |
||||
|
new JObject( |
||||
|
new JProperty("key", 15)); |
||||
|
|
||||
|
Assert.Equal(expected.ToString(), actual.Value.ToString()); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_set_root_value() |
||||
|
{ |
||||
|
await sut.SetAsync("key", ((JToken)123).AsJ()); |
||||
|
|
||||
|
var actual = await sut.GetAsync(); |
||||
|
|
||||
|
var expected = |
||||
|
new JObject( |
||||
|
new JProperty("key", 123)); |
||||
|
|
||||
|
Assert.Equal(expected.ToString(), actual.Value.ToString()); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_remove_root_value() |
||||
|
{ |
||||
|
await sut.SetAsync("key", ((JToken)123).AsJ()); |
||||
|
await sut.RemoveAsync("key"); |
||||
|
|
||||
|
var actual = await sut.GetAsync(); |
||||
|
|
||||
|
var expected = new JObject(); |
||||
|
|
||||
|
Assert.Equal(expected.ToString(), actual.Value.ToString()); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_set_nested_value() |
||||
|
{ |
||||
|
await sut.SetAsync("root.nested", ((JToken)123).AsJ()); |
||||
|
|
||||
|
var actual = await sut.GetAsync(); |
||||
|
|
||||
|
var expected = |
||||
|
new JObject( |
||||
|
new JProperty("root", |
||||
|
new JObject( |
||||
|
new JProperty("nested", 123)))); |
||||
|
|
||||
|
Assert.Equal(expected.ToString(), actual.Value.ToString()); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_remove_nested_value() |
||||
|
{ |
||||
|
await sut.SetAsync("root.nested", ((JToken)123).AsJ()); |
||||
|
await sut.RemoveAsync("root.nested"); |
||||
|
|
||||
|
var actual = await sut.GetAsync(); |
||||
|
|
||||
|
var expected = |
||||
|
new JObject( |
||||
|
new JProperty("root", new JObject())); |
||||
|
|
||||
|
Assert.Equal(expected.ToString(), actual.Value.ToString()); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_throw_exception_if_nested_not_an_object() |
||||
|
{ |
||||
|
await sut.SetAsync("root.nested", ((JToken)123).AsJ()); |
||||
|
|
||||
|
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.SetAsync("root.nested.value", ((JToken)123).AsJ())); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public Task Should_do_nothing_if_deleting_and_nested_not_found() |
||||
|
{ |
||||
|
return sut.RemoveAsync("root.nested"); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public Task Should_do_nothing_if_deleting_and_key_not_found() |
||||
|
{ |
||||
|
return sut.RemoveAsync("root"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue