Browse Source

User settings.

pull/414/head
Sebastian 7 years ago
parent
commit
829e84d206
  1. 29
      src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs
  2. 2
      src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs
  3. 88
      src/Squidex/Areas/Api/Controllers/UI/UIController.cs
  4. 4
      src/Squidex/app/features/content/shared/due-time-selector.component.html
  5. 21
      src/Squidex/app/framework/utils/types.ts
  6. 7
      src/Squidex/app/shared/components/search-form.component.html
  7. 78
      src/Squidex/app/shared/services/ui.service.spec.ts
  8. 27
      src/Squidex/app/shared/services/ui.service.ts
  9. 3
      src/Squidex/app/shared/state/contents.forms.ts
  10. 4
      src/Squidex/app/shared/state/queries.spec.ts
  11. 4
      src/Squidex/app/shared/state/queries.ts
  12. 149
      src/Squidex/app/shared/state/ui.state.spec.ts
  13. 144
      src/Squidex/app/shared/state/ui.state.ts
  14. 21
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs

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

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

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

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

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

@ -5,6 +5,7 @@
// 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;
@ -12,6 +13,7 @@ using Microsoft.Extensions.Options;
using Orleans; using Orleans;
using Squidex.Areas.Api.Controllers.UI.Models; using Squidex.Areas.Api.Controllers.UI.Models;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Security;
@ -68,7 +70,26 @@ namespace Squidex.Areas.Api.Controllers.UI
[ApiPermission] [ApiPermission]
public async Task<IActionResult> GetSettings(string app) public async Task<IActionResult> GetSettings(string app)
{ {
var result = await grainFactory.GetGrain<IAppUISettingsGrain>(AppId).GetAsync(); var result = await GetSettingsGrain(AppKey()).GetAsync();
return Ok(result.Value);
}
/// <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 GetSettingsGrain(UserKey()).GetAsync();
return Ok(result.Value); return Ok(result.Value);
} }
@ -88,7 +109,27 @@ namespace Squidex.Areas.Api.Controllers.UI
[ApiPermission] [ApiPermission]
public async Task<IActionResult> PutSetting(string app, string key, [FromBody] UpdateSettingDto request) public async Task<IActionResult> PutSetting(string app, string key, [FromBody] UpdateSettingDto request)
{ {
await grainFactory.GetGrain<IAppUISettingsGrain>(AppId).SetAsync(key, request.Value.AsJ()); await GetSettingsGrain(AppKey()).SetAsync(key, request.Value.AsJ());
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 GetSettingsGrain(UserKey()).SetAsync(key, request.Value.AsJ());
return NoContent(); return NoContent();
} }
@ -107,9 +148,50 @@ namespace Squidex.Areas.Api.Controllers.UI
[ApiPermission] [ApiPermission]
public async Task<IActionResult> DeleteSetting(string app, string key) public async Task<IActionResult> DeleteSetting(string app, string key)
{ {
await grainFactory.GetGrain<IAppUISettingsGrain>(AppId).RemoveAsync(key); await GetSettingsGrain(AppKey()).RemoveAsync(key);
return NoContent(); return NoContent();
} }
/// <summary>
/// Remove my ui settings.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="key">The name of the setting.</param>
/// <returns>
/// 200 => UI setting removed.
/// 404 => App not found.
/// </returns>
[HttpDelete]
[Route("apps/{app}/ui/settings/me/{key}")]
[ApiPermission]
public async Task<IActionResult> DeleteUserSetting(string app, string key)
{
await GetSettingsGrain(UserKey()).RemoveAsync(key);
return NoContent();
}
private IAppUISettingsGrain GetSettingsGrain(string key)
{
return grainFactory.GetGrain<IAppUISettingsGrain>(key);
}
private string AppKey()
{
return $"{AppId}";
}
private string UserKey()
{
var subject = User.OpenIdSubject();
if (string.IsNullOrWhiteSpace(subject))
{
throw new DomainForbiddenException("Not allowed for clients.");
}
return $"{AppId}_{subject}";
}
} }
} }

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -74,11 +74,11 @@ describe('Queries', () => {
}); });
it('should forward add call to state', () => { it('should forward add call to state', () => {
queries.add('key3', { fullText: 'text3' }); queries.add('key3', { fullText: 'text3' }, true);
expect(true).toBeTruthy(); expect(true).toBeTruthy();
uiState.verify(x => x.set('schemas.my-schema.queries.key3', '{"fullText":"text3"}'), 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 call to state', () => {

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

@ -52,8 +52,8 @@ export class Queries {
); );
} }
public add(key: string, query: Query) { public add(key: string, query: Query, user = false) {
this.uiState.set(`${this.prefix}.queries.${key}`, JSON.stringify(query)); this.uiState.set(`${this.prefix}.queries.${key}`, JSON.stringify(query), user);
} }
public remove(saved: SavedQuery) { public remove(saved: SavedQuery) {

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

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

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

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

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

@ -36,6 +36,9 @@ namespace Squidex.Domain.Apps.Entities.Apps
JsonValue.Object().Add("key", 15); JsonValue.Object().Add("key", 15);
Assert.Equal(expected.ToString(), actual.Value.ToString()); Assert.Equal(expected.ToString(), actual.Value.ToString());
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened();
} }
[Fact] [Fact]
@ -49,6 +52,9 @@ namespace Squidex.Domain.Apps.Entities.Apps
JsonValue.Object().Add("key", 123); JsonValue.Object().Add("key", 123);
Assert.Equal(expected.ToString(), actual.Value.ToString()); Assert.Equal(expected.ToString(), actual.Value.ToString());
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened();
} }
[Fact] [Fact]
@ -62,6 +68,9 @@ namespace Squidex.Domain.Apps.Entities.Apps
var expected = JsonValue.Object(); var expected = JsonValue.Object();
Assert.Equal(expected.ToString(), actual.Value.ToString()); Assert.Equal(expected.ToString(), actual.Value.ToString());
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappenedTwiceExactly();
} }
[Fact] [Fact]
@ -76,6 +85,9 @@ namespace Squidex.Domain.Apps.Entities.Apps
JsonValue.Object().Add("nested", 123)); JsonValue.Object().Add("nested", 123));
Assert.Equal(expected.ToString(), actual.Value.ToString()); Assert.Equal(expected.ToString(), actual.Value.ToString());
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappened();
} }
[Fact] [Fact]
@ -91,6 +103,9 @@ namespace Squidex.Domain.Apps.Entities.Apps
JsonValue.Object()); JsonValue.Object());
Assert.Equal(expected.ToString(), actual.Value.ToString()); Assert.Equal(expected.ToString(), actual.Value.ToString());
A.CallTo(() => grainState.WriteAsync())
.MustHaveHappenedTwiceExactly();
} }
[Fact] [Fact]
@ -105,12 +120,18 @@ namespace Squidex.Domain.Apps.Entities.Apps
public async Task Should_do_nothing_if_deleting_and_nested_not_found() public async Task Should_do_nothing_if_deleting_and_nested_not_found()
{ {
await sut.RemoveAsync("root.nested"); await sut.RemoveAsync("root.nested");
A.CallTo(() => grainState.WriteAsync())
.MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_do_nothing_if_deleting_and_key_not_found() public async Task Should_do_nothing_if_deleting_and_key_not_found()
{ {
await sut.RemoveAsync("root"); await sut.RemoveAsync("root");
A.CallTo(() => grainState.WriteAsync())
.MustNotHaveHappened();
} }
} }
} }

Loading…
Cancel
Save