mirror of https://github.com/Squidex/squidex.git
14 changed files with 646 additions and 50 deletions
@ -0,0 +1,111 @@ |
|||
// ==========================================================================
|
|||
// 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<JObject> persistence; |
|||
private JObject state = new JObject(); |
|||
|
|||
public AppUISettingsGrain(IStore<Guid> store) |
|||
{ |
|||
Guard.NotNull(store, nameof(store)); |
|||
|
|||
this.store = store; |
|||
} |
|||
|
|||
public override Task OnActivateAsync(Guid key) |
|||
{ |
|||
persistence = store.WithSnapshots<JObject, Guid>(GetType(), key, x => state = x); |
|||
|
|||
return persistence.ReadAsync(); |
|||
} |
|||
|
|||
public Task<J<JObject>> GetAsync() |
|||
{ |
|||
return Task.FromResult(state.AsJ()); |
|||
} |
|||
|
|||
public Task SetAsync(J<JObject> setting) |
|||
{ |
|||
state = setting; |
|||
|
|||
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; |
|||
|
|||
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> setting); |
|||
|
|||
Task RemoveAsync(string path); |
|||
} |
|||
} |
|||
@ -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,144 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { Injectable } from '@angular/core'; |
|||
import { Observable } from 'rxjs'; |
|||
import { distinctUntilChanged, map, tap } 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): Observable<any> { |
|||
if (!reset) { |
|||
this.resetState(); |
|||
} |
|||
|
|||
return this.loadInternal(); |
|||
} |
|||
|
|||
private loadInternal(): Observable<any> { |
|||
return this.uiService.getSettings(this.appName).pipe( |
|||
tap(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; |
|||
} |
|||
} |
|||
@ -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<JObject> persistence = A.Fake<IPersistence<JObject>>(); |
|||
private readonly AppUISettingsGrain sut; |
|||
|
|||
public AppUISettingsGrainTests() |
|||
{ |
|||
A.CallTo(() => store.WithSnapshots(A<Type>.Ignored, A<Guid>.Ignored, A<Func<JObject, 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