mirror of https://github.com/Squidex/squidex.git
committed by
GitHub
59 changed files with 1059 additions and 390 deletions
@ -0,0 +1,337 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
import { NavigationExtras, Params, Router } from '@angular/router'; |
||||
|
import { LocalStoreService, MathHelper, Pager } from '@app/shared'; |
||||
|
import { BehaviorSubject } from 'rxjs'; |
||||
|
import { IMock, It, Mock, Times } from 'typemoq'; |
||||
|
import { State } from './../../state'; |
||||
|
import { PagerSynchronizer, Router2State, StringKeysSynchronizer, StringSynchronizer } from './router-2-state'; |
||||
|
|
||||
|
describe('Router2State', () => { |
||||
|
describe('Strings', () => { |
||||
|
const synchronizer = new StringSynchronizer('key'); |
||||
|
|
||||
|
it('should write string to route', () => { |
||||
|
const params: Params = {}; |
||||
|
|
||||
|
const value = 'my-string'; |
||||
|
|
||||
|
synchronizer.writeValue(value, params); |
||||
|
|
||||
|
expect(params['key']).toEqual('my-string'); |
||||
|
}); |
||||
|
|
||||
|
it('should not write value to route when not a string', () => { |
||||
|
const params: Params = {}; |
||||
|
|
||||
|
const value = 123; |
||||
|
|
||||
|
synchronizer.writeValue(value, params); |
||||
|
|
||||
|
expect(params).toEqual({}); |
||||
|
}); |
||||
|
|
||||
|
it('should get string from route', () => { |
||||
|
const params: Params = { |
||||
|
key: 'my-string' |
||||
|
}; |
||||
|
|
||||
|
const value = synchronizer.getValue(params); |
||||
|
|
||||
|
expect(value).toEqual('my-string'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('StringKeys', () => { |
||||
|
const synchronizer = new StringKeysSynchronizer('key'); |
||||
|
|
||||
|
it('should write object keys to route', () => { |
||||
|
const params: Params = {}; |
||||
|
|
||||
|
const value = { |
||||
|
flag1: true, |
||||
|
flag2: false |
||||
|
}; |
||||
|
|
||||
|
synchronizer.writeValue(value, params); |
||||
|
|
||||
|
expect(params['key']).toEqual('flag1,flag2'); |
||||
|
}); |
||||
|
|
||||
|
it('should write empty object to route', () => { |
||||
|
const params: Params = {}; |
||||
|
|
||||
|
const value = {}; |
||||
|
|
||||
|
synchronizer.writeValue(value, params); |
||||
|
|
||||
|
expect(params['key']).toEqual(''); |
||||
|
}); |
||||
|
|
||||
|
it('should not write value to route when not an object', () => { |
||||
|
const params: Params = {}; |
||||
|
|
||||
|
const value = 123; |
||||
|
|
||||
|
synchronizer.writeValue(value, params); |
||||
|
|
||||
|
expect(params).toEqual({}); |
||||
|
}); |
||||
|
|
||||
|
it('should get object from route', () => { |
||||
|
const params: Params = { key: 'flag1,flag2' }; |
||||
|
|
||||
|
const value = synchronizer.getValue(params); |
||||
|
|
||||
|
expect(value).toEqual({ flag1: true, flag2: true }); |
||||
|
}); |
||||
|
|
||||
|
it('should get object with empty keys from route', () => { |
||||
|
const params: Params = { key: 'flag1,,,flag2' }; |
||||
|
|
||||
|
const value = synchronizer.getValue(params); |
||||
|
|
||||
|
expect(value).toEqual({ flag1: true, flag2: true }); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('Pager', () => { |
||||
|
let synchronizer: PagerSynchronizer; |
||||
|
let localStore: IMock<LocalStoreService>; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
localStore = Mock.ofType<LocalStoreService>(); |
||||
|
|
||||
|
synchronizer = new PagerSynchronizer(localStore.object, 'contents', 30); |
||||
|
}); |
||||
|
|
||||
|
it('should write pager to route and local store', () => { |
||||
|
const params: Params = {}; |
||||
|
|
||||
|
const value = new Pager(0, 10, 20, true); |
||||
|
|
||||
|
synchronizer.writeValue(value, params); |
||||
|
|
||||
|
expect(params['page']).toEqual('10'); |
||||
|
expect(params['take']).toEqual('20'); |
||||
|
|
||||
|
localStore.verify(x => x.setInt('contents.pageSize', 20), Times.once()); |
||||
|
}); |
||||
|
|
||||
|
it('should not write page if zero', () => { |
||||
|
const params: Params = {}; |
||||
|
|
||||
|
const value = new Pager(0, 0, 20, true); |
||||
|
|
||||
|
synchronizer.writeValue(value, params); |
||||
|
|
||||
|
expect(params['page']).toBeUndefined(); |
||||
|
expect(params['take']).toEqual('20'); |
||||
|
|
||||
|
localStore.verify(x => x.setInt('contents.pageSize', 20), Times.once()); |
||||
|
}); |
||||
|
|
||||
|
it('should not write value to route when not pager', () => { |
||||
|
const params: Params = {}; |
||||
|
|
||||
|
const value = 123; |
||||
|
|
||||
|
synchronizer.writeValue(value, params); |
||||
|
|
||||
|
expect(params).toEqual({}); |
||||
|
|
||||
|
localStore.verify(x => x.setInt('contents.pageSize', 20), Times.never()); |
||||
|
}); |
||||
|
|
||||
|
it('should not write value to route when null', () => { |
||||
|
const params: Params = {}; |
||||
|
|
||||
|
const value = null; |
||||
|
|
||||
|
synchronizer.writeValue(value, params); |
||||
|
|
||||
|
expect(params).toEqual({}); |
||||
|
localStore.verify(x => x.setInt('contents.pageSize', 20), Times.never()); |
||||
|
}); |
||||
|
|
||||
|
it('should get page and size from route', () => { |
||||
|
const params: Params = { page: '10', take: '40' }; |
||||
|
|
||||
|
const value = synchronizer.getValue(params); |
||||
|
|
||||
|
expect(value).toEqual(new Pager(0, 10, 40, true)); |
||||
|
}); |
||||
|
|
||||
|
it('should get page size from local store as fallback', () => { |
||||
|
localStore.setup(x => x.getInt('contents.pageSize', It.isAny())) |
||||
|
.returns(() => 40); |
||||
|
|
||||
|
const params: Params = { page: '10' }; |
||||
|
|
||||
|
const value = synchronizer.getValue(params); |
||||
|
|
||||
|
expect(value).toEqual(new Pager(0, 10, 40, true)); |
||||
|
}); |
||||
|
|
||||
|
it('should get page size from default if local store is invalid', () => { |
||||
|
localStore.setup(x => x.getInt('contents.pageSize', It.isAny())) |
||||
|
.returns(() => -5); |
||||
|
|
||||
|
const params: Params = { page: '10' }; |
||||
|
|
||||
|
const value = synchronizer.getValue(params); |
||||
|
|
||||
|
expect(value).toEqual(new Pager(0, 10, 30, true)); |
||||
|
}); |
||||
|
|
||||
|
it('should get page size from default as last fallback', () => { |
||||
|
const params: Params = { page: '10' }; |
||||
|
|
||||
|
const value = synchronizer.getValue(params); |
||||
|
|
||||
|
expect(value).toEqual(new Pager(0, 10, 30, true)); |
||||
|
}); |
||||
|
|
||||
|
it('should fix page number if invalid', () => { |
||||
|
const params: Params = { page: '-10' }; |
||||
|
|
||||
|
const value = synchronizer.getValue(params); |
||||
|
|
||||
|
expect(value).toEqual(new Pager(0, 0, 30, true)); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('Implementation', () => { |
||||
|
let localStore: IMock<LocalStoreService>; |
||||
|
let routerQueryParams: BehaviorSubject<Params>; |
||||
|
let routeActivated: any; |
||||
|
let router: IMock<Router>; |
||||
|
let router2State: Router2State; |
||||
|
let state: State<any>; |
||||
|
let invoked = 0; |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
localStore = Mock.ofType<LocalStoreService>(); |
||||
|
|
||||
|
router = Mock.ofType<Router>(); |
||||
|
|
||||
|
state = new State<any>({}); |
||||
|
|
||||
|
routerQueryParams = new BehaviorSubject<Params>({}); |
||||
|
routeActivated = { queryParams: routerQueryParams, id: MathHelper.guid() }; |
||||
|
router2State = new Router2State(routeActivated, router.object, localStore.object); |
||||
|
|
||||
|
router2State.mapTo(state) |
||||
|
.keep('keep') |
||||
|
.withString('state1', 'key1') |
||||
|
.withString('state2', 'key2') |
||||
|
.whenSynced(() => { invoked++; }) |
||||
|
.build(); |
||||
|
|
||||
|
invoked = 0; |
||||
|
}); |
||||
|
|
||||
|
afterEach(() => { |
||||
|
router2State.ngOnDestroy(); |
||||
|
}); |
||||
|
|
||||
|
it('should unsubscribe from route and state', () => { |
||||
|
router2State.ngOnDestroy(); |
||||
|
|
||||
|
expect(state.changes['observers'].length).toBe(0); |
||||
|
expect(routeActivated.queryParams.observers.length).toBe(0); |
||||
|
}); |
||||
|
|
||||
|
it('Should sync from route', () => { |
||||
|
routerQueryParams.next({ |
||||
|
key1: 'hello', |
||||
|
key2: 'squidex' |
||||
|
}); |
||||
|
|
||||
|
expect(state.snapshot.state1).toEqual('hello'); |
||||
|
expect(state.snapshot.state2).toEqual('squidex'); |
||||
|
}); |
||||
|
|
||||
|
it('Should invoke callback after sync from route', () => { |
||||
|
routerQueryParams.next({ |
||||
|
key1: 'hello', |
||||
|
key2: 'squidex' |
||||
|
}); |
||||
|
|
||||
|
expect(invoked).toEqual(1); |
||||
|
}); |
||||
|
|
||||
|
it('Should not sync again when nothing changed', () => { |
||||
|
routerQueryParams.next({ |
||||
|
key1: 'hello', |
||||
|
key2: 'squidex' |
||||
|
}); |
||||
|
|
||||
|
routerQueryParams.next({ |
||||
|
key1: 'hello', |
||||
|
key2: 'squidex' |
||||
|
}); |
||||
|
|
||||
|
expect(invoked).toEqual(1); |
||||
|
}); |
||||
|
|
||||
|
it('Should sync again when new query changed', () => { |
||||
|
routerQueryParams.next({ |
||||
|
key1: 'hello', |
||||
|
key2: 'squidex' |
||||
|
}); |
||||
|
|
||||
|
routerQueryParams.next({ |
||||
|
key1: 'hello', |
||||
|
key2: 'squidex', |
||||
|
key3: '!' |
||||
|
}); |
||||
|
|
||||
|
expect(invoked).toEqual(2); |
||||
|
}); |
||||
|
|
||||
|
it('Should reset other values when synced from route', () => { |
||||
|
state.next({ other: 123 }); |
||||
|
|
||||
|
routerQueryParams.next({ |
||||
|
key1: 'hello', |
||||
|
key2: 'squidex' |
||||
|
}); |
||||
|
|
||||
|
expect(state.snapshot.other).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
it('Should keep configued values when synced from route', () => { |
||||
|
state.next({ keep: 123 }); |
||||
|
|
||||
|
routerQueryParams.next({ |
||||
|
key1: 'hello', |
||||
|
key2: 'squidex' |
||||
|
}); |
||||
|
|
||||
|
expect(state.snapshot.keep).toBe(123); |
||||
|
}); |
||||
|
|
||||
|
it('Should sync from state', () => { |
||||
|
let routeExtras: NavigationExtras; |
||||
|
|
||||
|
router.setup(x => x.navigate([], It.isAny())) |
||||
|
.callback((_, extras) => { routeExtras = extras; }); |
||||
|
|
||||
|
state.next({ |
||||
|
state1: 'hello', |
||||
|
state2: 'squidex' |
||||
|
}); |
||||
|
|
||||
|
expect(routeExtras!.relativeTo).toBeDefined(); |
||||
|
expect(routeExtras!.replaceUrl).toBeTrue(); |
||||
|
expect(routeExtras!.queryParamsHandling).toBe('merge'); |
||||
|
expect(routeExtras!.queryParams).toEqual({ key1: 'hello', key2: 'squidex' }); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,299 @@ |
|||||
|
/* |
||||
|
* Squidex Headless CMS |
||||
|
* |
||||
|
* @license |
||||
|
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
||||
|
*/ |
||||
|
|
||||
|
// tslint:disable: readonly-array
|
||||
|
|
||||
|
import { Injectable, OnDestroy } from '@angular/core'; |
||||
|
import { ActivatedRoute, Params, Router } from '@angular/router'; |
||||
|
import { LocalStoreService, Pager, Types } from '@app/framework/internal'; |
||||
|
import { State } from '@app/framework/state'; |
||||
|
import { Subscription } from 'rxjs'; |
||||
|
|
||||
|
export interface RouteSynchronizer { |
||||
|
getValue(params: Params): any; |
||||
|
|
||||
|
writeValue(state: any, params: Params): void; |
||||
|
} |
||||
|
|
||||
|
export class PagerSynchronizer implements RouteSynchronizer { |
||||
|
constructor( |
||||
|
private readonly localStore: LocalStoreService, |
||||
|
private readonly storeName: string, |
||||
|
private readonly defaultSize: number |
||||
|
) { |
||||
|
} |
||||
|
|
||||
|
public getValue(params: Params) { |
||||
|
let pageSize = 0; |
||||
|
|
||||
|
const pageSizeValue = params['take']; |
||||
|
|
||||
|
if (Types.isString(pageSizeValue)) { |
||||
|
pageSize = parseInt(pageSizeValue, 10); |
||||
|
} |
||||
|
|
||||
|
if (pageSize <= 0 || pageSize > 100 || isNaN(pageSize)) { |
||||
|
pageSize = this.localStore.getInt(`${this.storeName}.pageSize`, this.defaultSize); |
||||
|
} |
||||
|
|
||||
|
if (pageSize <= 0 || pageSize > 100 || isNaN(pageSize)) { |
||||
|
pageSize = this.defaultSize; |
||||
|
} |
||||
|
|
||||
|
let page = parseInt(params['page'], 10); |
||||
|
|
||||
|
if (page <= 0 || isNaN(page)) { |
||||
|
page = 0; |
||||
|
} |
||||
|
|
||||
|
return new Pager(0, page, pageSize, true); |
||||
|
} |
||||
|
|
||||
|
public writeValue(state: any, params: Params) { |
||||
|
if (Types.is(state, Pager)) { |
||||
|
if (state.page > 0) { |
||||
|
params['page'] = state.page.toString(); |
||||
|
} |
||||
|
|
||||
|
if (state.pageSize > 0) { |
||||
|
params['take'] = state.pageSize.toString(); |
||||
|
|
||||
|
this.localStore.setInt(`${this.storeName}.pageSize`, state.pageSize); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export class StringSynchronizer implements RouteSynchronizer { |
||||
|
constructor( |
||||
|
private readonly name: string |
||||
|
) { |
||||
|
} |
||||
|
|
||||
|
public getValue(params: Params) { |
||||
|
const value = params[this.name]; |
||||
|
|
||||
|
return value; |
||||
|
} |
||||
|
|
||||
|
public writeValue(state: any, params: Params) { |
||||
|
if (Types.isString(state)) { |
||||
|
params[this.name] = state; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export class StringKeysSynchronizer implements RouteSynchronizer { |
||||
|
constructor( |
||||
|
private readonly name: string |
||||
|
) { |
||||
|
} |
||||
|
|
||||
|
public getValue(params: Params) { |
||||
|
const value = params[this.name]; |
||||
|
|
||||
|
const result: { [key: string]: boolean } = {}; |
||||
|
|
||||
|
if (Types.isString(value)) { |
||||
|
for (const item of value.split(',')) { |
||||
|
if (item.length > 0) { |
||||
|
result[item] = true; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
public writeValue(state: any, params: Params) { |
||||
|
if (Types.isObject(state)) { |
||||
|
const value = Object.keys(state).join(','); |
||||
|
|
||||
|
params[this.name] = value; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export interface StateSynchronizer { |
||||
|
mapTo<T extends object>(state: State<T>): StateSynchronizerMap<T>; |
||||
|
} |
||||
|
|
||||
|
export interface StateSynchronizerMap<T> { |
||||
|
keep(key: keyof T & string): this; |
||||
|
|
||||
|
withString(key: keyof T & string, urlName: string): this; |
||||
|
|
||||
|
withStrings(key: keyof T & string, urlName: string): this; |
||||
|
|
||||
|
withPager(key: keyof T & string, storeName: string, defaultSize: number): this; |
||||
|
|
||||
|
whenSynced(action: () => void): this; |
||||
|
|
||||
|
withSynchronizer(key: keyof T & string, synchronizer: RouteSynchronizer): this; |
||||
|
|
||||
|
build(): void; |
||||
|
} |
||||
|
|
||||
|
@Injectable() |
||||
|
export class Router2State implements OnDestroy, StateSynchronizer { |
||||
|
private mapper: Router2StateMap<any>; |
||||
|
|
||||
|
constructor( |
||||
|
private readonly route: ActivatedRoute, |
||||
|
private readonly router: Router, |
||||
|
private readonly localStore: LocalStoreService |
||||
|
) { |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy() { |
||||
|
this.mapper?.ngOnDestroy(); |
||||
|
} |
||||
|
|
||||
|
public mapTo<T extends object>(state: State<T>): Router2StateMap<T> { |
||||
|
this.mapper?.ngOnDestroy(); |
||||
|
this.mapper = this.mapper || new Router2StateMap<T>(state, this.route, this.router, this.localStore); |
||||
|
|
||||
|
return this.mapper; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export class Router2StateMap<T extends object> implements OnDestroy, StateSynchronizerMap<T> { |
||||
|
private readonly syncs: { [field: string]: { synchronizer: RouteSynchronizer, value: any } } = {}; |
||||
|
private readonly keysToKeep: string[] = []; |
||||
|
private syncDone: (() => void)[] = []; |
||||
|
private lastSyncedParams: Params | undefined; |
||||
|
private subscriptionChanges: Subscription; |
||||
|
private subscriptionQueryParams: Subscription; |
||||
|
|
||||
|
constructor( |
||||
|
private readonly state: State<T>, |
||||
|
private readonly route: ActivatedRoute, |
||||
|
private readonly router: Router, |
||||
|
private readonly localStore: LocalStoreService |
||||
|
) { |
||||
|
} |
||||
|
|
||||
|
public build() { |
||||
|
this.subscriptionQueryParams = |
||||
|
this.route.queryParams |
||||
|
.subscribe(q => this.syncFromRoute(q)); |
||||
|
|
||||
|
this.subscriptionChanges = |
||||
|
this.state.changes |
||||
|
.subscribe(s => this.syncToRoute(s)); |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy() { |
||||
|
this.syncDone = []; |
||||
|
|
||||
|
this.subscriptionQueryParams?.unsubscribe(); |
||||
|
this.subscriptionChanges?.unsubscribe(); |
||||
|
} |
||||
|
|
||||
|
private syncToRoute(state: T) { |
||||
|
let isChanged = false; |
||||
|
|
||||
|
for (const key in this.syncs) { |
||||
|
if (this.syncs.hasOwnProperty(key)) { |
||||
|
const target = this.syncs[key]; |
||||
|
|
||||
|
const value = state[key]; |
||||
|
|
||||
|
if (value !== target.value) { |
||||
|
target.value = value; |
||||
|
|
||||
|
isChanged = true; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (!isChanged) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const queryParams: Params = {}; |
||||
|
|
||||
|
for (const key in this.syncs) { |
||||
|
if (this.syncs.hasOwnProperty(key)) { |
||||
|
const { synchronizer, value } = this.syncs[key]; |
||||
|
|
||||
|
synchronizer.writeValue(value, queryParams); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
this.lastSyncedParams = queryParams; |
||||
|
|
||||
|
this.router.navigate([], { |
||||
|
relativeTo: this.route, |
||||
|
queryParams, |
||||
|
queryParamsHandling: 'merge', |
||||
|
replaceUrl: true |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private syncFromRoute(query: Params) { |
||||
|
if (Types.equals(this.lastSyncedParams, query)) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
const update: Partial<T> = {}; |
||||
|
|
||||
|
for (const key in this.syncs) { |
||||
|
if (this.syncs.hasOwnProperty(key)) { |
||||
|
const target = this.syncs[key]; |
||||
|
|
||||
|
const value = target.synchronizer.getValue(query); |
||||
|
|
||||
|
if (value) { |
||||
|
update[key] = value; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
for (const key of this.keysToKeep) { |
||||
|
update[key] = this.state.snapshot[key]; |
||||
|
} |
||||
|
|
||||
|
this.state.resetState(update); |
||||
|
|
||||
|
for (const action of this.syncDone) { |
||||
|
action(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public keep(key: keyof T & string) { |
||||
|
this.keysToKeep.push(key); |
||||
|
|
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
public withString(key: keyof T & string, urlName: string) { |
||||
|
return this.withSynchronizer(key, new StringSynchronizer(urlName)); |
||||
|
} |
||||
|
|
||||
|
public withStrings(key: keyof T & string, urlName: string) { |
||||
|
return this.withSynchronizer(key, new StringKeysSynchronizer(urlName)); |
||||
|
} |
||||
|
|
||||
|
public withPager(key: keyof T & string, storeName: string, defaultSize = 10) { |
||||
|
return this.withSynchronizer(key, new PagerSynchronizer(this.localStore, storeName, defaultSize)); |
||||
|
} |
||||
|
|
||||
|
public whenSynced(action: () => void) { |
||||
|
this.syncDone.push(action); |
||||
|
|
||||
|
return this; |
||||
|
} |
||||
|
|
||||
|
public withSynchronizer(key: keyof T & string, synchronizer: RouteSynchronizer) { |
||||
|
const previous = this.syncs[key]; |
||||
|
|
||||
|
this.syncs[key] = { synchronizer, value: previous?.value }; |
||||
|
|
||||
|
return this; |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue