diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs index b6b01582a..a9945b61f 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppUISettingsGrain.cs @@ -15,7 +15,7 @@ using Squidex.Infrastructure.States; namespace Squidex.Domain.Apps.Entities.Apps { - public sealed class AppUISettingsGrain : GrainOfGuid, IAppUISettingsGrain + public sealed class AppUISettingsGrain : GrainOfString, IAppUISettingsGrain { private readonly IGrainState state; @@ -46,7 +46,7 @@ namespace Squidex.Domain.Apps.Entities.Apps public Task SetAsync(string path, J value) { - var container = GetContainer(path, out var key); + var container = GetContainer(path, true, out var key); if (container == null) { @@ -58,19 +58,19 @@ namespace Squidex.Domain.Apps.Entities.Apps return state.WriteAsync(); } - public Task RemoveAsync(string path) + public async Task RemoveAsync(string path) { - var container = GetContainer(path, out var key); + var container = GetContainer(path, false, out var key); - if (container != null) + if (container?.ContainsKey(path) == true) { container.Remove(key); - } - return state.WriteAsync(); + await state.WriteAsync(); + } } - private JsonObject GetContainer(string path, out string key) + private JsonObject GetContainer(string path, bool add, out string key) { Guard.NotNullOrEmpty(path, nameof(path)); @@ -86,9 +86,16 @@ namespace Squidex.Domain.Apps.Entities.Apps { if (!current.TryGetValue(segment, out var temp)) { - temp = JsonValue.Object(); - - current[segment] = temp; + if (add) + { + temp = JsonValue.Object(); + + current[segment] = temp; + } + else + { + return null; + } } if (temp is JsonObject next) diff --git a/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs index 69583ff25..4ce221196 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/IAppUISettingsGrain.cs @@ -12,7 +12,7 @@ using Squidex.Infrastructure.Orleans; namespace Squidex.Domain.Apps.Entities.Apps { - public interface IAppUISettingsGrain : IGrainWithGuidKey + public interface IAppUISettingsGrain : IGrainWithStringKey { Task> GetAsync(); diff --git a/src/Squidex/Areas/Api/Controllers/UI/UIController.cs b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs index dd12c5be5..c33c1775e 100644 --- a/src/Squidex/Areas/Api/Controllers/UI/UIController.cs +++ b/src/Squidex/Areas/Api/Controllers/UI/UIController.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; @@ -12,6 +13,7 @@ using Microsoft.Extensions.Options; using Orleans; using Squidex.Areas.Api.Controllers.UI.Models; using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Security; @@ -68,7 +70,26 @@ namespace Squidex.Areas.Api.Controllers.UI [ApiPermission] public async Task GetSettings(string app) { - var result = await grainFactory.GetGrain(AppId).GetAsync(); + var result = await GetSettingsGrain(AppKey()).GetAsync(); + + return Ok(result.Value); + } + + /// + /// Get my ui settings. + /// + /// The name of the app. + /// + /// 200 => UI settings returned. + /// 404 => App not found. + /// + [HttpGet] + [Route("apps/{app}/ui/settings/me")] + [ProducesResponseType(typeof(Dictionary), 200)] + [ApiPermission] + public async Task GetUserSettings(string app) + { + var result = await GetSettingsGrain(UserKey()).GetAsync(); return Ok(result.Value); } @@ -88,7 +109,27 @@ namespace Squidex.Areas.Api.Controllers.UI [ApiPermission] public async Task PutSetting(string app, string key, [FromBody] UpdateSettingDto request) { - await grainFactory.GetGrain(AppId).SetAsync(key, request.Value.AsJ()); + await GetSettingsGrain(AppKey()).SetAsync(key, request.Value.AsJ()); + + return NoContent(); + } + + /// + /// Set my ui settings. + /// + /// The name of the app. + /// The name of the setting. + /// The request with the value to update. + /// + /// 200 => UI setting set. + /// 404 => App not found. + /// + [HttpPut] + [Route("apps/{app}/ui/settings/me/{key}")] + [ApiPermission] + public async Task PutUserSetting(string app, string key, [FromBody] UpdateSettingDto request) + { + await GetSettingsGrain(UserKey()).SetAsync(key, request.Value.AsJ()); return NoContent(); } @@ -107,9 +148,50 @@ namespace Squidex.Areas.Api.Controllers.UI [ApiPermission] public async Task DeleteSetting(string app, string key) { - await grainFactory.GetGrain(AppId).RemoveAsync(key); + await GetSettingsGrain(AppKey()).RemoveAsync(key); return NoContent(); } + + /// + /// Remove my ui settings. + /// + /// The name of the app. + /// The name of the setting. + /// + /// 200 => UI setting removed. + /// 404 => App not found. + /// + [HttpDelete] + [Route("apps/{app}/ui/settings/me/{key}")] + [ApiPermission] + public async Task DeleteUserSetting(string app, string key) + { + await GetSettingsGrain(UserKey()).RemoveAsync(key); + + return NoContent(); + } + + private IAppUISettingsGrain GetSettingsGrain(string key) + { + return grainFactory.GetGrain(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}"; + } } } diff --git a/src/Squidex/app/features/content/shared/due-time-selector.component.html b/src/Squidex/app/features/content/shared/due-time-selector.component.html index 08d41205e..ad6bcb08d 100644 --- a/src/Squidex/app/features/content/shared/due-time-selector.component.html +++ b/src/Squidex/app/features/content/shared/due-time-selector.component.html @@ -6,14 +6,14 @@
- +
- + diff --git a/src/Squidex/app/framework/utils/types.ts b/src/Squidex/app/framework/utils/types.ts index 22841eb4f..2a6dcde38 100644 --- a/src/Squidex/app/framework/utils/types.ts +++ b/src/Squidex/app/framework/utils/types.ts @@ -127,4 +127,25 @@ export module Types { return Types.isUndefined(value) === true || Types.isNull(value) === true; } +} + +export function mergeInto(target: object, source: object) { + if (!Types.isObject(target) || !Types.isObject(source)) { + return source; + } + + Object.keys(source).forEach(key => { + const targetValue = target[key]; + const sourceValue = source[key]; + + if (Types.isArray(targetValue) && Types.isArray(sourceValue)) { + target[key] = targetValue.concat(sourceValue); + } else if (Types.isObject(targetValue) && Types.isObject(sourceValue)) { + target[key] = mergeInto({ ...targetValue }, sourceValue); + } else { + target[key] = sourceValue; + } + }); + + return target; } \ No newline at end of file diff --git a/src/Squidex/app/shared/components/search-form.component.html b/src/Squidex/app/shared/components/search-form.component.html index d1b467070..cdd552fc6 100644 --- a/src/Squidex/app/shared/components/search-form.component.html +++ b/src/Squidex/app/shared/components/search-form.component.html @@ -78,6 +78,13 @@
+ +
+ + +
diff --git a/src/Squidex/app/shared/services/ui.service.spec.ts b/src/Squidex/app/shared/services/ui.service.spec.ts index 5a5f9236d..a6029c94a 100644 --- a/src/Squidex/app/shared/services/ui.service.spec.ts +++ b/src/Squidex/app/shared/services/ui.service.spec.ts @@ -71,12 +71,12 @@ describe('UIService', () => { expect(settings!).toEqual({ mapType: 'OSM', mapKey: '', canCreateApps: true }); })); - it('should make get request to get settings', + it('should make get request to get shared settings', inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { let settings: object; - uiService.getSettings('my-app').subscribe(result => { + uiService.getSharedSettings('my-app').subscribe(result => { settings = result; }); @@ -92,12 +92,12 @@ describe('UIService', () => { expect(settings!).toEqual(response); })); - it('should return default settings when error occurs', + it('should return default shared settings when error occurs', inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { let settings: object; - uiService.getSettings('my-app').subscribe(result => { + uiService.getSharedSettings('my-app').subscribe(result => { settings = result; }); @@ -111,10 +111,50 @@ describe('UIService', () => { expect(settings!).toBeDefined(); })); - it('should make put request to set value', + it('should make get request to get user settings', inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { - uiService.putSetting('my-app', 'root.nested', 123).subscribe(); + let settings: object; + + uiService.getUserSettings('my-app').subscribe(result => { + settings = result; + }); + + const response = { mapType: 'OSM', mapKey: '' }; + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/me'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.flush(response); + + expect(settings!).toEqual(response); + })); + + it('should return default user settings when error occurs', + inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { + + let settings: object; + + uiService.getUserSettings('my-app').subscribe(result => { + settings = result; + }); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/me'); + + expect(req.request.method).toEqual('GET'); + expect(req.request.headers.get('If-Match')).toBeNull(); + + req.error(new ErrorEvent('500')); + + expect(settings!).toBeDefined(); + })); + + it('should make put request to set shared value', + inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { + + uiService.putSharedSetting('my-app', 'root.nested', 123).subscribe(); const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/root.nested'); @@ -122,14 +162,36 @@ describe('UIService', () => { expect(req.request.headers.get('If-Match')).toBeNull(); })); - it('should make delete request to remove value', + it('should make put request to set user value', inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { - uiService.deleteSetting('my-app', 'root.nested').subscribe(); + uiService.putUserSetting('my-app', 'root.nested', 123).subscribe(); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/me/root.nested'); + + expect(req.request.method).toEqual('PUT'); + expect(req.request.headers.get('If-Match')).toBeNull(); + })); + + it('should make delete request to remove shared value', + inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { + + uiService.deleteSharedSetting('my-app', 'root.nested').subscribe(); const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/root.nested'); expect(req.request.method).toEqual('DELETE'); expect(req.request.headers.get('If-Match')).toBeNull(); })); + + it('should make delete request to remove user value', + inject([UIService, HttpTestingController], (uiService: UIService, httpMock: HttpTestingController) => { + + uiService.deleteUserSetting('my-app', 'root.nested').subscribe(); + + const req = httpMock.expectOne('http://service/p/api/apps/my-app/ui/settings/me/root.nested'); + + expect(req.request.method).toEqual('DELETE'); + expect(req.request.headers.get('If-Match')).toBeNull(); + })); }); \ No newline at end of file diff --git a/src/Squidex/app/shared/services/ui.service.ts b/src/Squidex/app/shared/services/ui.service.ts index 285ace324..f620af4a2 100644 --- a/src/Squidex/app/shared/services/ui.service.ts +++ b/src/Squidex/app/shared/services/ui.service.ts @@ -33,7 +33,7 @@ export class UIService { })); } - public getSettings(appName: string): Observable { + public getSharedSettings(appName: string): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings`); return this.http.get(url).pipe( @@ -42,15 +42,36 @@ export class UIService { })); } - public putSetting(appName: string, key: string, value: any): Observable { + public getUserSettings(appName: string): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/me`); + + return this.http.get(url).pipe( + catchError(() => { + return of({}); + })); + } + + public putSharedSetting(appName: string, key: string, value: any): Observable { const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/${key}`); return this.http.put(url, { value }); } - public deleteSetting(appName: string, key: string): Observable { + public putUserSetting(appName: string, key: string, value: any): Observable { + 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 { const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/${key}`); return this.http.delete(url); } + + public deleteUserSetting(appName: string, key: string): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/ui/settings/me/${key}`); + + return this.http.delete(url); + } } \ No newline at end of file diff --git a/src/Squidex/app/shared/state/contents.forms.ts b/src/Squidex/app/shared/state/contents.forms.ts index 4fa104e40..3a2154cdd 100644 --- a/src/Squidex/app/shared/state/contents.forms.ts +++ b/src/Squidex/app/shared/state/contents.forms.ts @@ -53,7 +53,8 @@ export class SaveQueryForm extends Form { [ Validators.required ] - ] + ], + user: false })); } } diff --git a/src/Squidex/app/shared/state/queries.spec.ts b/src/Squidex/app/shared/state/queries.spec.ts index b197dfeaa..3c8d98840 100644 --- a/src/Squidex/app/shared/state/queries.spec.ts +++ b/src/Squidex/app/shared/state/queries.spec.ts @@ -74,11 +74,11 @@ describe('Queries', () => { }); it('should forward add call to state', () => { - queries.add('key3', { fullText: 'text3' }); + queries.add('key3', { fullText: 'text3' }, true); expect(true).toBeTruthy(); - uiState.verify(x => x.set('schemas.my-schema.queries.key3', '{"fullText":"text3"}'), Times.once()); + uiState.verify(x => x.set('schemas.my-schema.queries.key3', '{"fullText":"text3"}', true), Times.once()); }); it('should forward remove call to state', () => { diff --git a/src/Squidex/app/shared/state/queries.ts b/src/Squidex/app/shared/state/queries.ts index 888c9d316..d3f50047e 100644 --- a/src/Squidex/app/shared/state/queries.ts +++ b/src/Squidex/app/shared/state/queries.ts @@ -52,8 +52,8 @@ export class Queries { ); } - public add(key: string, query: Query) { - this.uiState.set(`${this.prefix}.queries.${key}`, JSON.stringify(query)); + public add(key: string, query: Query, user = false) { + this.uiState.set(`${this.prefix}.queries.${key}`, JSON.stringify(query), user); } public remove(saved: SavedQuery) { diff --git a/src/Squidex/app/shared/state/ui.state.spec.ts b/src/Squidex/app/shared/state/ui.state.spec.ts index 0e7e75de3..a2bd1423d 100644 --- a/src/Squidex/app/shared/state/ui.state.spec.ts +++ b/src/Squidex/app/shared/state/ui.state.spec.ts @@ -6,7 +6,7 @@ */ import { of } from 'rxjs'; -import { IMock, It, Mock, Times } from 'typemoq'; +import { IMock, Mock } from 'typemoq'; import { ResourceLinks, @@ -24,18 +24,30 @@ describe('UIState', () => { appsState } = TestValues; - const appSettings = { - mapType: 'GM', - mapSize: 1024, + const common = { + key: 'xx', + map: { + type: 'GSM', + sizeX: 800, + sizeY: 600 + }, canCreateApps: true }; - const commonSettings = { - mapType: 'OSM', - mapKey: 'Key', + const shared = { + map: { + type: 'GM', key: 'xyz' + }, canCreateApps: true }; + const user = { + map: { + sizeX: 1000 + }, + canCustomize: true + }; + const resources: ResourceLinks = { ['admin/events']: { method: 'GET', href: '/api/events' }, ['admin/restore']: { method: 'GET', href: '/api/restore' }, @@ -49,17 +61,14 @@ describe('UIState', () => { beforeEach(() => { uiService = Mock.ofType(); - uiService.setup(x => x.getSettings(app)) - .returns(() => of(appSettings)); - uiService.setup(x => x.getCommonSettings()) - .returns(() => of(commonSettings)); + .returns(() => of(common)); - uiService.setup(x => x.putSetting(app, It.isAnyString(), It.isAny())) - .returns(() => of({})); + uiService.setup(x => x.getSharedSettings(app)) + .returns(() => of(shared)); - uiService.setup(x => x.deleteSetting(app, It.isAnyString())) - .returns(() => of({})); + uiService.setup(x => x.getUserSettings(app)) + .returns(() => of(user)); usersService = Mock.ofType(); @@ -71,10 +80,15 @@ describe('UIState', () => { it('should load settings', () => { expect(uiState.snapshot.settings).toEqual({ - mapType: 'GM', - mapKey: 'Key', - mapSize: 1024, - canCreateApps: true + key: 'xx', + map: { + type: 'GM', + sizeX: 1000, + sizeY: 600, + key: 'xyz' + }, + canCreateApps: true, + canCustomize: true }); expect(uiState.snapshot.canReadEvents).toBeTruthy(); @@ -82,17 +96,25 @@ describe('UIState', () => { expect(uiState.snapshot.canRestore).toBeTruthy(); }); - it('should add value to snapshot when set', () => { + it('should add value to snapshot when set as shared', () => { + uiService.setup(x => x.putSharedSetting(app, 'root.nested', 123)) + .returns(() => of({})).verifiable(); + uiState.set('root.nested', 123); expect(uiState.snapshot.settings).toEqual({ - mapType: 'GM', - mapKey: 'Key', - mapSize: 1024, + key: 'xx', + map: { + type: 'GM', + sizeX: 1000, + sizeY: 600, + key: 'xyz' + }, + canCreateApps: true, + canCustomize: true, root: { nested: 123 - }, - canCreateApps: true + } }); uiState.get('root', {}).subscribe(x => { @@ -107,36 +129,83 @@ describe('UIState', () => { expect(x).toEqual(1337); }); - uiService.verify(x => x.putSetting(app, 'root.nested', 123), Times.once()); + uiService.verifyAll(); }); - it('should remove value from snapshot when removed', () => { - uiState.set('root.nested1', 123); - uiState.set('root.nested2', 123); - uiState.remove('root.nested1'); + it('should add value to snapshot when set as user', () => { + uiService.setup(x => x.putUserSetting(app, 'root.nested', 123)) + .returns(() => of({})).verifiable(); + + uiState.set('root.nested', 123, true); expect(uiState.snapshot.settings).toEqual({ - mapType: 'GM', - mapKey: 'Key', - mapSize: 1024, - root: { - nested2: 123 + key: 'xx', + map: { + type: 'GM', + sizeX: 1000, + sizeY: 600, + key: 'xyz' }, - canCreateApps: true + canCreateApps: true, + canCustomize: true, + root: { + nested: 123 + } }); uiState.get('root', {}).subscribe(x => { - expect(x).toEqual({ nested2: 123 }); + expect(x).toEqual({ nested: 123 }); }); - uiState.get('root.nested2', 0).subscribe(x => { + uiState.get('root.nested', 0).subscribe(x => { expect(x).toEqual(123); }); - uiState.get('root.nested1', 1337).subscribe(x => { + uiState.get('root.notfound', 1337).subscribe(x => { expect(x).toEqual(1337); }); - uiService.verify(x => x.deleteSetting(app, 'root.nested1'), Times.once()); + uiService.verifyAll(); + }); + + it('should remove value from snapshot and shared settings when removed', () => { + uiService.setup(x => x.deleteSharedSetting(app, 'map.key')) + .returns(() => of({})).verifiable(); + + uiState.remove('map.key'); + + expect(uiState.snapshot.settings).toEqual({ + key: 'xx', + map: { + type: 'GM', + sizeX: 1000, + sizeY: 600 + }, + canCreateApps: true, + canCustomize: true + }); + + uiService.verifyAll(); + }); + + it('should remove value from snapshot and user settings when removed', () => { + uiService.setup(x => x.deleteUserSetting(app, 'map.sizeX')) + .returns(() => of({})).verifiable(); + + uiState.remove('map.sizeX'); + + expect(uiState.snapshot.settings).toEqual({ + key: 'xx', + map: { + type: 'GM', + sizeX: 800, + sizeY: 600, + key: 'xyz' + }, + canCreateApps: true, + canCustomize: true + }); + + uiService.verifyAll(); }); }); \ No newline at end of file diff --git a/src/Squidex/app/shared/state/ui.state.ts b/src/Squidex/app/shared/state/ui.state.ts index 17508dc0f..1bcd288f3 100644 --- a/src/Squidex/app/shared/state/ui.state.ts +++ b/src/Squidex/app/shared/state/ui.state.ts @@ -10,6 +10,7 @@ import { distinctUntilChanged, map } from 'rxjs/operators'; import { hasAnyLink, + mergeInto, State, Types } from '@app/framework'; @@ -24,8 +25,11 @@ interface Snapshot { // All common settings. settingsCommon: object & any; - // All app settings. - settingsApp: object & any; + // All shared app settings. + settingsShared: object & any; + + // All user app settings. + settingsUser: object & any; // The merged settings of app and common settings. settings: object & any; @@ -70,29 +74,39 @@ export class UIState extends State { private readonly uiService: UIService, private readonly usersService: UsersService ) { - super({ settings: {}, settingsCommon: {}, settingsApp: {} }); + super({ + settings: {}, + settingsCommon: {}, + settingsShared: {}, + settingsUser: {} + }); this.loadResources(); this.loadCommon(); appsState.selectedValidApp.subscribe(app => { - this.load(); + this.load(app.name); }); } - private load() { - this.next(s => updateAppSettings(s, {})); + private load(app: string) { + this.next(s => updateSettings(s, {})); + + this.uiService.getSharedSettings(app) + .subscribe(payload => { + this.next(s => updateSettings(s, { settingsShared: payload })); + }); - this.uiService.getSettings(this.appName) + this.uiService.getUserSettings(app) .subscribe(payload => { - this.next(s => updateAppSettings(s, payload)); + this.next(s => updateSettings(s, { settingsUser: payload })); }); } private loadCommon() { this.uiService.getCommonSettings() .subscribe(payload => { - this.next(s => updateCommonSettings(s, payload)); + this.next(s => updateSettings(s, { settingsCommon: payload })); }); } @@ -107,60 +121,72 @@ export class UIState extends State { }); } - public set(path: string, value: any) { - const { key, current, root } = this.getContainer(path); + public set(path: string, value: any, user = false) { + if (user) { + this.setUser(path, value); + } else { + this.setShared(path, value); + } + } + + private setUser(path: string, value: any) { + const { key, current, root } = getContainer(this.snapshot.settingsUser, path); if (current && key) { - this.uiService.putSetting(this.appName, path, value).subscribe(); + this.uiService.putUserSetting(this.appName, path, value).subscribe(); current[key] = value; - this.next(s => updateAppSettings(s, root)); + this.next(s => updateSettings(s, { settingsUser: root })); } } - public remove(path: string) { - const { key, current, root } = this.getContainer(path); + private setShared(path: string, value: any) { + const { key, current, root } = getContainer(this.snapshot.settingsShared, path); if (current && key) { - this.uiService.deleteSetting(this.appName, path).subscribe(); + this.uiService.putSharedSetting(this.appName, path, value).subscribe(); - delete current[key]; + current[key] = value; - this.next(s => updateAppSettings(s, root)); + this.next(s => updateSettings(s, { settingsShared: root })); } } - private getContainer(path: string) { - const segments = path.split('.'); + public remove(path: string) { + return this.removeUser(path) || this.removeShared(path); + } + + 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) { - for (let i = 0; i < segments.length - 1; i++) { - const segment = segments[i]; + this.next(s => updateSettings(s, { settingsUser: root })); - let temp = current[segment]; + return true; + } - if (!temp) { - temp = {}; - } else { - temp = { ...temp }; - } + return false; + } - current[segment] = temp; + private removeShared(path: string) { + const { key, current, root } = getContainer(this.snapshot.settingsShared, path); - if (!Types.isObject(temp)) { - return { key: null, current: null, root: null }; - } + if (current && key && current[key]) { + 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(setting: object & UISettingsDto, path: string, defaultValue: T) { @@ -188,14 +214,44 @@ export class UIState extends State { } } -function updateAppSettings(state: Snapshot, settingsApp: object & any) { - const { settingsCommon } = state; +function getContainer(settings: object, path: string) { + const segments = path.split('.'); + + let current = { ...settings }; + + const root = current; - 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) { - const { settingsApp } = state; +function updateSettings(state: Snapshot, update: Partial) { + 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 }; } \ No newline at end of file diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs index ea4aea955..03b406e53 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs @@ -36,6 +36,9 @@ namespace Squidex.Domain.Apps.Entities.Apps JsonValue.Object().Add("key", 15); Assert.Equal(expected.ToString(), actual.Value.ToString()); + + A.CallTo(() => grainState.WriteAsync()) + .MustHaveHappened(); } [Fact] @@ -49,6 +52,9 @@ namespace Squidex.Domain.Apps.Entities.Apps JsonValue.Object().Add("key", 123); Assert.Equal(expected.ToString(), actual.Value.ToString()); + + A.CallTo(() => grainState.WriteAsync()) + .MustHaveHappened(); } [Fact] @@ -62,6 +68,9 @@ namespace Squidex.Domain.Apps.Entities.Apps var expected = JsonValue.Object(); Assert.Equal(expected.ToString(), actual.Value.ToString()); + + A.CallTo(() => grainState.WriteAsync()) + .MustHaveHappenedTwiceExactly(); } [Fact] @@ -76,6 +85,9 @@ namespace Squidex.Domain.Apps.Entities.Apps JsonValue.Object().Add("nested", 123)); Assert.Equal(expected.ToString(), actual.Value.ToString()); + + A.CallTo(() => grainState.WriteAsync()) + .MustHaveHappened(); } [Fact] @@ -91,6 +103,9 @@ namespace Squidex.Domain.Apps.Entities.Apps JsonValue.Object()); Assert.Equal(expected.ToString(), actual.Value.ToString()); + + A.CallTo(() => grainState.WriteAsync()) + .MustHaveHappenedTwiceExactly(); } [Fact] @@ -105,12 +120,18 @@ namespace Squidex.Domain.Apps.Entities.Apps public async Task Should_do_nothing_if_deleting_and_nested_not_found() { await sut.RemoveAsync("root.nested"); + + A.CallTo(() => grainState.WriteAsync()) + .MustNotHaveHappened(); } [Fact] public async Task Should_do_nothing_if_deleting_and_key_not_found() { await sut.RemoveAsync("root"); + + A.CallTo(() => grainState.WriteAsync()) + .MustNotHaveHappened(); } } }