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