diff --git a/.docgeni/app/module.ts b/.docgeni/app/module.ts new file mode 100644 index 0000000..df34caf --- /dev/null +++ b/.docgeni/app/module.ts @@ -0,0 +1,10 @@ +import { isDevMode } from '@angular/core'; +import { ThyStoreModule, ReduxDevtoolsPlugin } from '@tethys/store'; + +export default { + imports: [ + ThyStoreModule.forRoot([], { + plugins: isDevMode() ? [ReduxDevtoolsPlugin] : [] + }) + ] +}; diff --git a/package.json b/package.json index cfceded..14c9356 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,8 @@ "@angular/compiler-cli": "^14.1.2", "@commitlint/cli": "^12.0.1", "@commitlint/config-angular": "^12.0.1", - "@docgeni/cli": "^1.2.0-next.25", - "@docgeni/template": "^1.2.0-next.25", + "@docgeni/cli": "^1.2.0-next.27", + "@docgeni/template": "^1.2.0-next.27", "@types/jasmine": "~3.6.0", "@types/node": "^12.11.1", "@typescript-eslint/eslint-plugin": "^5.29.0", diff --git a/packages/store/src/action-state.ts b/packages/store/src/action-state.ts deleted file mode 100644 index 7737646..0000000 --- a/packages/store/src/action-state.ts +++ /dev/null @@ -1,12 +0,0 @@ -// @dynamic -export class ActionState { - private static actionName = ''; - - public static changeAction(actionName: string) { - this.actionName = actionName; - } - - public static getActionName() { - return this.actionName; - } -} diff --git a/packages/store/src/action.ts b/packages/store/src/action.ts index 1a7f1e3..9278b7b 100644 --- a/packages/store/src/action.ts +++ b/packages/store/src/action.ts @@ -1,6 +1,5 @@ import { findAndCreateStoreMetadata } from './utils'; import { InternalDispatcher } from './internals/dispatcher'; -import { ActionState } from './action-state'; // import { Observable, of, throwError } from 'rxjs'; // import { catchError, exhaustMap, shareReplay} from 'rxjs/operators'; // import { ActionContext, ActionStatus } from './actions-stream'; @@ -57,30 +56,8 @@ export function Action(action?: DecoratorActionOptions | string) { descriptor.value = function (...args: any[]) { const storeId = this.getStoreInstanceId(); return InternalDispatcher.instance.dispatch(storeId, metadata.actions[type], () => { - ActionState.changeAction(`${target.constructor.name}-${name}`); return originalFn.call(this, ...args); }); - - // ActionState.changeAction(`${target.constructor.name}-${name}`); - // let result = originalFn.call(this, ...args); - // if (result instanceof Observable) { - // result = result.pipe( - // catchError((error) => { - // return of({ status: ActionStatus.Errored, action: action, error: error }); - // }), - // // shareReplay(), - // exhaustMap((result: ActionContext | any) => { - // if (result && result.status === ActionStatus.Errored) { - // return throwError(result.error); - // } else { - // return of(result); - // } - // }), - // shareReplay() - // ); - // result.subscribe(); - // } - // return result; }; }; } diff --git a/packages/store/src/entity-store.ts b/packages/store/src/entity-store.ts index e3dac75..d309e67 100644 --- a/packages/store/src/entity-store.ts +++ b/packages/store/src/entity-store.ts @@ -110,10 +110,11 @@ export class EntityStore, TEnti * */ initialize(entities: TEntity[], pagination?: PaginationInfo) { - const state = this.snapshot; - state.entities = entities || []; - state.pagination = pagination; - this.next(state); + this.setState({ + ...this.snapshot, + entities: entities || [], + pagination + }); } /** @@ -125,12 +126,16 @@ export class EntityStore, TEnti * */ initializeWithReferences(entities: TEntity[], references: TReferences, pagination?: PaginationInfo) { - const state = this.snapshot; - state.entities = entities || []; - state.pagination = pagination; - state.references = references; + this.snapshot.references = references; this.buildReferencesIdMap(); - this.next(state); + this.setState((state) => { + return { + ...state, + entities: entities || [], + pagination, + references + }; + }); } /** @@ -165,7 +170,7 @@ export class EntityStore, TEnti } } - this.next(state); + this.next({ ...state }); } /** diff --git a/packages/store/src/internals/dispatcher.ts b/packages/store/src/internals/dispatcher.ts index 656e0ac..176a246 100644 --- a/packages/store/src/internals/dispatcher.ts +++ b/packages/store/src/internals/dispatcher.ts @@ -16,7 +16,8 @@ import { import { CancelUncompleted } from '../action'; import { ActionContext, ActionStatus } from '../actions-stream'; import { SafeAny, ActionMetadata } from '../inner-types'; -import { PluginContext } from '../plugin'; +import { PluginContext, StorePluginFn } from '../plugin'; +import { StorePluginManager } from '../plugin-manager'; import { compose, findAndCreateStoreMetadata, generateIdWithTime } from '../utils'; import { StoreFactory } from './store-factory'; @@ -170,14 +171,9 @@ export class InternalDispatcher { public dispatch(storeId: string, action: ActionMetadata, originActionFn: () => Observable | void) { const storeInstance = StoreFactory.instance.get(storeId); const dispatchId = `${action.type}-${generateIdWithTime()}`; + let returnResult = undefined; const result$ = compose([ - // (ctx: PluginContext, next) => { - // return next(ctx).pipe( - // tap((state) => { - // console.log(`new state`, state); - // }) - // ); - // }, + ...(StorePluginManager.instance?.rootPluginHandlers || []), (ctx: PluginContext) => { const originActionResult = originActionFn(); if (originActionResult instanceof Observable) { @@ -208,24 +204,28 @@ export class InternalDispatcher { shareReplay() ); } else { - return originActionResult; + returnResult = originActionResult; + return of(originActionResult).pipe(shareReplay()); } } ])({ - state: StoreFactory.instance.get(storeId).getState(), - storeInstance: storeInstance, - action: action.type - }); - if (result$ instanceof Observable) { + state: storeInstance.getState(), + getState: () => storeInstance.getState(), + getAllState: () => StoreFactory.instance.getAllState(), + store: storeInstance, + action: `${storeInstance.getStoreInstanceId()}@${action.type}` + }).pipe(shareReplay()); + if (returnResult) { + return returnResult; + } else { result$.subscribe({ error: (error: Error) => { // this._errorHandler = this._errorHandler || this._injector.get(ErrorHandler); // this._errorHandler.handleError(error); } }); + return result$; } - - return result$; } private getActionResult$(storeId: string, dispatchId: string): Observable { diff --git a/packages/store/src/internals/store-factory.ts b/packages/store/src/internals/store-factory.ts index 4d1e2d1..23d1f63 100644 --- a/packages/store/src/internals/store-factory.ts +++ b/packages/store/src/internals/store-factory.ts @@ -12,8 +12,6 @@ export class StoreFactory implements OnDestroy { private storeInstancesMap = new Map(); - private storeInstancesMap$ = new BehaviorSubject>(this.storeInstancesMap); - public state$ = new Subject<{ storeId: string; state: unknown }>(); register(store: Store) { @@ -28,6 +26,13 @@ export class StoreFactory implements OnDestroy { return this.storeInstancesMap.get(id); } + getAllState() { + return Array.from(this.storeInstancesMap.entries()).reduce((state, [storeId, store]) => { + state[storeId] = store.getState(); + return state; + }, {}); + } + // eslint-disable-next-line @angular-eslint/no-empty-lifecycle-method ngOnDestroy(): void {} } diff --git a/packages/store/src/module.ts b/packages/store/src/module.ts index c5f888b..d854e09 100644 --- a/packages/store/src/module.ts +++ b/packages/store/src/module.ts @@ -1,30 +1,48 @@ import { NgModule, ModuleWithProviders, Type, Injector, NgModuleRef } from '@angular/core'; -import { ROOT_STATE_TOKEN, FEATURE_STATE_TOKEN } from './types'; +import { ROOT_STORES_TOKEN, FEATURE_STORES_TOKEN } from './types'; import { Store } from './store'; import { clearInjector, setInjector } from './internals/static-injector'; +import { PLUGINS_TOKEN, StorePlugin } from './plugin'; +import { StorePluginManager } from './plugin-manager'; @NgModule() export class ThyRootStoreModule { - constructor(ngModuleRef: NgModuleRef) { + constructor(ngModuleRef: NgModuleRef, private storePluginManager: StorePluginManager) { setInjector(ngModuleRef.injector); ngModuleRef.onDestroy(clearInjector); } } @NgModule() -export class ThyFeatureStoreModule {} +export class ThyFeatureStoreModule { + constructor(private storePluginManager: StorePluginManager) {} +} @NgModule({}) export class ThyStoreModule { - static forRoot(stores: Type[] = []): ModuleWithProviders { + static forRoot( + stores: Type[] = [], + options?: { + plugins: Type[]; + } + ): ModuleWithProviders { + const pluginProviders = (options?.plugins || []).map((PluginClass) => { + return { + provide: PLUGINS_TOKEN, + useClass: PluginClass, + multi: true + }; + }); return { ngModule: ThyRootStoreModule, providers: [ ...stores, { - provide: ROOT_STATE_TOKEN, + provide: ROOT_STORES_TOKEN, useValue: stores - } + }, + ...pluginProviders, + StorePluginManager ] }; } @@ -35,10 +53,11 @@ export class ThyStoreModule { providers: [ ...stores, { - provide: FEATURE_STATE_TOKEN, + provide: FEATURE_STORES_TOKEN, multi: true, useValue: stores - } + }, + StorePluginManager ] }; } diff --git a/packages/store/src/plugin-manager.ts b/packages/store/src/plugin-manager.ts new file mode 100644 index 0000000..e542c69 --- /dev/null +++ b/packages/store/src/plugin-manager.ts @@ -0,0 +1,41 @@ +import { Inject, Injectable, Optional, SkipSelf, Type } from '@angular/core'; +import { PLUGINS_TOKEN, StorePlugin, StorePluginFn } from './plugin'; + +@Injectable() +export class StorePluginManager { + private static pluginManager: StorePluginManager; + + private pluginHandlers: StorePluginFn[] = []; + + static get instance() { + return this.pluginManager; + } + + get rootPluginHandlers(): StorePluginFn[] { + return (this.parentManager && this.parentManager.pluginHandlers) || this.pluginHandlers; + } + + constructor( + @Optional() + @SkipSelf() + private parentManager: StorePluginManager, + @Optional() + @Inject(PLUGINS_TOKEN) + private plugins: StorePlugin[] + ) { + StorePluginManager.pluginManager = this.parentManager || this; + if (!this.parentManager) { + this.registerPlugins(); + } + } + + private registerPlugins(): void { + const pluginHandlers: StorePluginFn[] = this.getPluginHandlers(); + this.rootPluginHandlers.push(...pluginHandlers); + } + + private getPluginHandlers(): StorePluginFn[] { + const plugins: StorePlugin[] = this.plugins || []; + return plugins.map((plugin: StorePlugin) => (plugin.handle ? plugin.handle.bind(plugin) : plugin) as StorePluginFn); + } +} diff --git a/packages/store/src/plugin.ts b/packages/store/src/plugin.ts index 166fa3b..e6e1ea5 100644 --- a/packages/store/src/plugin.ts +++ b/packages/store/src/plugin.ts @@ -1,5 +1,36 @@ -export interface PluginContext { - store: string; +import { Store } from './store'; +import { Observable } from 'rxjs'; +import { InjectionToken } from '@angular/core'; + +export interface PluginContext { + /** + * 调用此 Action 的 Store + */ + store: Store; + /** + * Action 名称 + */ action: string; - state: unknown; + /** + * 当前 Store 调用此 Action 之前的状态 + */ + state: TState; + /** + * 获取当前 Store 的状态 + */ + getState: () => TState; + /** + * 获取当前所有 Store 实例的状态 + */ + getAllState: () => Record; } + +export type StorePluginNextFn = (ctx: PluginContext) => Observable; + +export type StorePluginFn = (ctx: PluginContext, next: StorePluginNextFn) => Observable; + +export interface StorePlugin { + handle(ctx: PluginContext, next: StorePluginNextFn): Observable; +} + +export const PLUGINS_TOKEN = new InjectionToken('TETHYS_STORE_PLUGINS'); diff --git a/packages/store/src/plugins/redux-devtools.ts b/packages/store/src/plugins/redux-devtools.ts new file mode 100644 index 0000000..4ceef65 --- /dev/null +++ b/packages/store/src/plugins/redux-devtools.ts @@ -0,0 +1,108 @@ +import { Injectable, inject, OnDestroy, NgZone, ɵglobal, Optional, SkipSelf } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; +import { catchError, tap } from 'rxjs/operators'; +import { SafeAny } from '../inner-types'; +import { PluginContext, StorePlugin, StorePluginNextFn } from '../plugin'; + +const enum ReduxDevtoolsActionType { + Dispatch = 'DISPATCH', + Action = 'ACTION' +} + +const enum ReduxDevtoolsPayloadType { + JumpToAction = 'JUMP_TO_ACTION', + JumpToState = 'JUMP_TO_STATE', + ToggleAction = 'TOGGLE_ACTION', + ImportState = 'IMPORT_STATE' +} + +interface ReduxDevtoolsAction { + type: string; + payload?: SafeAny; +} +/** + * @internal + */ +interface DevtoolsExtension { + send(action: ReduxDevtoolsAction, state: object): void; + subscribe(callback: Function): VoidFunction; +} + +@Injectable() +export class ReduxDevtoolsPlugin implements StorePlugin, OnDestroy { + private devtoolsExtension: DevtoolsExtension | null = null; + + private unsubscribe: VoidFunction | null = null; + + private readonly globalDevtools: { connect(config: any): DevtoolsExtension; disconnect: () => void } = + ɵglobal['__REDUX_DEVTOOLS_EXTENSION__'] || ɵglobal['devToolsExtension']; + + private ngZone = inject(NgZone); + + constructor(@Optional() @SkipSelf() reduxDevtoolsPlugin: ReduxDevtoolsPlugin) { + if (!reduxDevtoolsPlugin) { + this.connect(); + } else { + this.devtoolsExtension = reduxDevtoolsPlugin.devtoolsExtension; + } + } + + handle(ctx: PluginContext, next: StorePluginNextFn): Observable { + return next(ctx).pipe( + catchError((error) => { + this.sendToDevTools({ type: ctx.action }, ctx.getAllState()); + throw error; + }), + tap(() => { + this.sendToDevTools({ type: ctx.action }, ctx.getAllState()); + }) + ); + } + + private connect() { + if (!this.globalDevtools) { + console.log(`Redux DevTools Extensions for browser are not installed.`); + console.log(`Chrome Redux DevTools Plugin Download: https://www.chromefor.com/redux-devtools_v2-17-0/`); + return; + } + + // The `connect` method adds `message` event listener since it communicates + // with an extension through `window.postMessage` and message events. + // We handle only 2 events; thus, we don't want to run many change detections + // because the extension sends events that we don't have to handle. + this.devtoolsExtension = this.ngZone.runOutsideAngular( + () => this.globalDevtools.connect({ + name: `@tethys/store` + }) + ); + + this.unsubscribe = this.devtoolsExtension.subscribe((action) => { + if (action.type === ReduxDevtoolsActionType.Dispatch || action.type === ReduxDevtoolsActionType.Action) { + this.ngZone.run(() => { + // TODO: add dispatch + }); + } + }); + } + private sendToDevTools(action: ReduxDevtoolsAction, state: object): void { + if (this.isConnectSuccessful()) { + this.devtoolsExtension.send(action, state); + } + } + + private isConnectSuccessful(): boolean { + if (!this.devtoolsExtension) { + return false; + } + return true; + } + + ngOnDestroy(): void { + if (this.unsubscribe) { + this.unsubscribe(); + } + if (this.globalDevtools) { + this.globalDevtools.disconnect(); + } + } +} diff --git a/packages/store/src/plugins/redux_devtools.ts b/packages/store/src/plugins/redux_devtools.ts deleted file mode 100644 index 63b82d5..0000000 --- a/packages/store/src/plugins/redux_devtools.ts +++ /dev/null @@ -1,55 +0,0 @@ -export abstract class StorePlugin { - // abstract handleNewState(state: Readonly): void; - abstract handleNewState(actionName: string, state: Readonly): void; - abstract isConnectSuccessful(): boolean; -} -/** - * @internal - */ -export interface ReduxDevtoolsInstance { - send(action: string, state: object): void; -} - -export class ReduxDevtoolsPlugin implements StorePlugin { - private _devTools: ReduxDevtoolsInstance | null = null; - - _window = window; - constructor() { - if (this._window == null) { - return; - } - const globalDevtools: { connect(config: any): ReduxDevtoolsInstance } | undefined = - (this._window as any)['__REDUX_DEVTOOLS_EXTENSION__'] || (this._window as any)['devToolsExtension']; - - if (!globalDevtools) { - console.log(`未安装Chrome浏览器的拓展插件: Redux DevTools.`); - console.log(`插件下载地址: https://www.chromefor.com/redux-devtools_v2-17-0/`); - return; - } - this._devTools = globalDevtools.connect({ - name: `@tethys/store` - }); - } - - handleNewState(actionName: string, state: object): void { - if (this.isConnectSuccessful()) { - this._devTools.send(actionName, state); - } - } - - isConnectSuccessful(): boolean { - if (this._devTools === null) { - return false; - } - return true; - } -} - -function getReduxDevToolsPlugin(): any { - if (!window[`___ReduxDevtoolsPlugin___`]) { - window[`___ReduxDevtoolsPlugin___`] = new ReduxDevtoolsPlugin(); - } - return window[`___ReduxDevtoolsPlugin___`]; -} - -export default getReduxDevToolsPlugin; diff --git a/packages/store/src/public-api.ts b/packages/store/src/public-api.ts index abea786..d0bd18d 100644 --- a/packages/store/src/public-api.ts +++ b/packages/store/src/public-api.ts @@ -3,5 +3,6 @@ export * from './store'; export * from './entity-store'; export * from './action'; export * from './references'; +export * from './plugins/redux-devtools'; +export * from './plugin'; export { PaginationInfo, Id, StoreOptions } from './types'; -export * from './root-store'; diff --git a/packages/store/src/root-store.ts b/packages/store/src/root-store.ts deleted file mode 100644 index d00ab57..0000000 --- a/packages/store/src/root-store.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { Store } from './store'; -import { Inject, SkipSelf, Optional, OnDestroy, isDevMode, Injectable } from '@angular/core'; -import { Subscription, combineLatest } from 'rxjs'; -import { map, switchMap, tap } from 'rxjs/operators'; -import { BehaviorSubject } from 'rxjs'; -import getReduxDevToolsPlugin, { StorePlugin } from './plugins/redux_devtools'; -import { ActionState } from './action-state'; -import { SafeAny } from './inner-types'; - -export type StoreInstanceMap = Map>; // Map key:string,value:状态数据 - -let rootStore: RootStore; - -/** - * @internal - */ -@Injectable() -export class RootStore { - private connectSuccessful = false; - /** - * 数据流 数据是一个Map,k,v键值对,关键字->状态数据 - */ - private readonly _containers = new BehaviorSubject(new Map>()); - private _plugin: StorePlugin = getReduxDevToolsPlugin(); - private _combinedStateSubscription: Subscription = new Subscription(); - - constructor() { - if (this._plugin.isConnectSuccessful()) { - this.connectSuccessful = true; - this._assignCombinedState(); // 最终调用handleNewState - console.log(`是否在Angular开发环境:${isDevMode()}, 初始化root-store`); - } - } - - private _assignCombinedState() { - this._combinedStateSubscription = this._containers - .pipe(switchMap((containers) => this._getCombinedState(containers))) - .pipe( - map((states) => { - const actionName = ActionState.getActionName(); - const state = states.reduce((acc, curr) => { - acc[curr.containerName] = curr.state; - return acc; - }, <{ [key: string]: any }>{}); - return { state: state, actionName: actionName }; - }) - ) - .subscribe((c: SafeAny) => { - this._plugin.handleNewState(c.actionName, c.state); - }); - } - - /** - * 合并数据流 - * 合并状态数据,把状态数据转换为这样的数据:{ containerName: string, state: any },并且 - * 通过combineLatest合并成一个数据数据流,这样状态数据只有涉及更新,那么这边就会得到通知 - * @param containers 状态数据的Map - */ - private _getCombinedState(containers: StoreInstanceMap) { - return combineLatest( - Array.from(containers.entries()).map(([containerName, container]) => { - return container.state$.pipe(map((state) => ({ containerName, state }))); - }) - ); - } - - /** - * @internal - */ - // eslint-disable-next-line @angular-eslint/use-lifecycle-interface - ngOnDestroy() { - this._combinedStateSubscription.unsubscribe(); - } - - /** - * @internal - */ - registerStore(store: Store) { - if (!this.connectSuccessful) { - return; - } - const containers = new Map(this._containers.value); - if (containers.has(store.getStoreInstanceId())) { - throw new Error( - `Store: Store with duplicate instance ID found! ${store.getStoreInstanceId()}` + - ` is already registered. Please check your getStoreInstanceId() methods!` - ); - } - containers.set(store.getStoreInstanceId(), store); - this._containers.next(containers); - } - - existStoreInstanceId(instanceId: string): boolean { - const containers = new Map(this._containers.value); - if (containers.has(instanceId)) { - return true; - } - return false; - } - - /** - * @internal - */ - unregisterStore(store: Store) { - if (!this.connectSuccessful) { - return; - } - const containers = new Map(this._containers.value); - containers.delete(store.getStoreInstanceId()); - this._containers.next(containers); - } -} - -export function getSingletonRootStore() { - if (!rootStore) { - rootStore = new RootStore(); - } - return rootStore; -} diff --git a/packages/store/src/store.ts b/packages/store/src/store.ts index c32185f..0e7d873 100644 --- a/packages/store/src/store.ts +++ b/packages/store/src/store.ts @@ -1,9 +1,7 @@ import { Observable, Observer, BehaviorSubject, from, of, PartialObserver, Subscription } from 'rxjs'; import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators'; import { META_KEY, StoreOptions } from './types'; -import { getSingletonRootStore, RootStore } from './root-store'; import { OnDestroy, isDevMode, Injectable } from '@angular/core'; -import { ActionState } from './action-state'; import { Action } from './action'; import { isFunction, isNumber } from '@tethys/cdk/is'; import { StoreFactory } from './internals/store-factory'; @@ -19,8 +17,6 @@ export class Store implements Observer, OnDestroy { public state$: BehaviorSubject; - public reduxToolEnabled = isDevMode(); - private defaultStoreInstanceId: string; private storeOptions: StoreOptions; @@ -30,12 +26,15 @@ export class Store implements Observer, OnDestroy { this.defaultStoreInstanceId = this.createStoreInstanceId(); this.state$ = new BehaviorSubject(initialState as T); this.initialStateCache = { ...initialState } as T; - if (this.reduxToolEnabled) { - const rootStore: RootStore = getSingletonRootStore(); - ActionState.changeAction(`Add-${this.defaultStoreInstanceId}`); - rootStore.registerStore(this); - } StoreFactory.instance.register(this); + InternalDispatcher.instance.dispatch( + this.getStoreInstanceId(), + { + type: `INIT`, + originalFn: undefined + }, + () => {} + ); } get snapshot() { @@ -52,7 +51,6 @@ export class Store implements Observer, OnDestroy { * @memberof Store */ public dispatch(type: string, payload?: T): Observable { - ActionState.changeAction(`${this.defaultStoreInstanceId}-${type}`); const result = this._dispatch({ type: type, payload: payload @@ -144,10 +142,6 @@ export class Store implements Observer, OnDestroy { } ngOnDestroy() { - if (this.reduxToolEnabled) { - const rootStore: RootStore = getSingletonRootStore(); - rootStore.unregisterStore(this); - } StoreFactory.instance.unregister(this); this.cancelUncompleted(); } diff --git a/packages/store/src/store/doc/zh-cn.md b/packages/store/src/store/doc/zh-cn.md new file mode 100644 index 0000000..2858dd1 --- /dev/null +++ b/packages/store/src/store/doc/zh-cn.md @@ -0,0 +1,4 @@ +--- +title: Examples +subtitle: 示例 +--- diff --git a/packages/store/src/store/examples/module.ts b/packages/store/src/store/examples/module.ts index 51b4683..55fae8d 100644 --- a/packages/store/src/store/examples/module.ts +++ b/packages/store/src/store/examples/module.ts @@ -1,8 +1,9 @@ import { CommonModule } from '@angular/common'; +import { isDevMode } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { ThyStoreModule } from '@tethys/store'; +import { PLUGINS_TOKEN, ReduxDevtoolsPlugin, ThyStoreModule } from '@tethys/store'; import { CounterStore } from './counter/counter.store'; export default { - imports: [CommonModule, FormsModule, ThyStoreModule.forRoot([CounterStore])] + imports: [CommonModule, FormsModule, ThyStoreModule.forFeature([CounterStore])] }; diff --git a/packages/store/src/store/examples/todos/todos.component.ts b/packages/store/src/store/examples/todos/todos.component.ts index 5a6208d..f61e162 100644 --- a/packages/store/src/store/examples/todos/todos.component.ts +++ b/packages/store/src/store/examples/todos/todos.component.ts @@ -14,7 +14,7 @@ export class ThyStoreTodosExampleComponent implements OnInit { constructor(public todosStore: TodosStore) {} ngOnInit(): void { - this.todosStore.fetchTodos(); + this.todosStore.fetchTodos().subscribe(); } addTodo() { diff --git a/packages/store/src/test/devtools/create-redux-devtools.ts b/packages/store/src/test/devtools/create-redux-devtools.ts new file mode 100644 index 0000000..e72f2f5 --- /dev/null +++ b/packages/store/src/test/devtools/create-redux-devtools.ts @@ -0,0 +1,22 @@ +import { ReduxDevtoolsMockConnector } from './redux-connector'; + +export function createReduxDevtoolsExtension(connector: ReduxDevtoolsMockConnector): void { + Object.defineProperty(window, '__REDUX_DEVTOOLS_EXTENSION__', { + writable: true, + configurable: true, + value: { + connect(): ReduxDevtoolsMockConnector { + return connector; + }, + disconnect() { + connector.disconnect(); + } + } + }); +} + +export function clearReduxDevtoolsExtension() { + Object.defineProperty(window, '__REDUX_DEVTOOLS_EXTENSION__', { + value: undefined + }); +} diff --git a/packages/store/src/test/devtools/redux-connector.ts b/packages/store/src/test/devtools/redux-connector.ts new file mode 100644 index 0000000..13c3cfa --- /dev/null +++ b/packages/store/src/test/devtools/redux-connector.ts @@ -0,0 +1,74 @@ +import { Subject, Subscription } from 'rxjs'; +import { ActionContext } from '../../actions-stream'; + +import { DevtoolsCallStack, MockState } from './symbols'; + +export class ReduxDevtoolsMockConnector { + public devtoolsStack: DevtoolsCallStack[] = []; + public initialState: MockState = null!; + public currentState: MockState = null!; + private dispatcher: Subject = new Subject(); + private countId = 0; + + public subscribe(fn: Function): () => void { + const subscription = this.dispatcher.subscribe((e) => fn(e)); + return () => { + subscription.unsubscribe(); + }; + } + + public init(state: MockState): void { + this.initialState = JSON.parse(JSON.stringify(state)); + this.devtoolsStack.push({ + id: this.countId, + type: '@INIT', + payload: undefined, + state: undefined!, + newState: state, + jumped: false + }); + } + + public send(action: any, newState?: any): void { + this.countId++; + + const prevState: MockState = + this.devtoolsStack.length > 0 ? this.devtoolsStack[this.devtoolsStack.length - 1].newState : this.initialState; + + this.currentState = newState; + this.devtoolsStack.push({ + id: this.countId, + type: action.type, + payload: action.payload, + state: prevState, + newState: newState, + jumped: false + }); + } + + public jumpToActionById(id: number): void { + if (!id) { + return; // can't jump to @INIT + } + + for (let i = this.devtoolsStack.length - 1, marked = false; i >= 0; i--) { + const pointer: DevtoolsCallStack = this.devtoolsStack[i]; + + if (pointer.id === id) { + marked = true; + + // this.dispatcher.next({ + // // id: pointer.id, + // action: 'DISPATCH', + // payload: { type: 'JUMP_TO_ACTION' }, + // state: JSON.stringify(pointer.newState), + // source: null! + // }); + } + + pointer.jumped = !marked; + } + } + + public disconnect() {} +} diff --git a/packages/store/src/test/devtools/redux-devtools.spec.ts b/packages/store/src/test/devtools/redux-devtools.spec.ts new file mode 100644 index 0000000..38d9481 --- /dev/null +++ b/packages/store/src/test/devtools/redux-devtools.spec.ts @@ -0,0 +1,131 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Subject } from 'rxjs'; +import { Action } from '../../action'; +import { ThyStoreModule } from '../../module'; +import { Store } from '../../store'; +import { ReduxDevtoolsPlugin } from '../../plugins/redux-devtools'; +import { clearReduxDevtoolsExtension, createReduxDevtoolsExtension } from './create-redux-devtools'; +import { ReduxDevtoolsMockConnector } from './redux-connector'; + +@Injectable() +class CounterStore extends Store<{ count: number }> { + constructor() { + super({ count: 0 }, { name: 'counter' }); + } + @Action() + increment() { + this.setState((state) => { + return { count: state.count + 1 }; + }); + } + + @Action() + decrement(value: number = 1) { + this.setState((state) => { + return { count: state.count - value }; + }); + } +} + +describe('redux-devtools', () => { + describe('connect', () => { + let devtools: ReduxDevtoolsMockConnector; + + beforeEach(() => { + devtools = new ReduxDevtoolsMockConnector(); + createReduxDevtoolsExtension(devtools); + TestBed.configureTestingModule({ + imports: [ThyStoreModule.forRoot([CounterStore], { plugins: [ReduxDevtoolsPlugin] })], + providers: [] + }); + }); + + afterEach(() => { + clearReduxDevtoolsExtension(); + }); + + it('should catch actions correctly', () => { + const counterStore = TestBed.inject(CounterStore); + expect(devtools.currentState.counter).toEqual({ count: 0 }); + expect(devtools.devtoolsStack).toEqual([ + { + id: 1, + type: 'counter@INIT', + payload: undefined, + state: null, + newState: jasmine.objectContaining({ counter: { count: 0 } }), + jumped: false + } + ]); + counterStore.increment(); + expect(devtools.currentState.counter).toEqual({ count: 1 }); + expect(devtools.devtoolsStack).toEqual([ + { + id: 1, + type: 'counter@INIT', + payload: undefined, + state: null, + newState: jasmine.objectContaining({ counter: { count: 0 } }), + jumped: false + }, + { + id: 2, + type: 'counter@increment', + payload: undefined, + state: jasmine.objectContaining({ counter: { count: 0 } }), + newState: jasmine.objectContaining({ counter: { count: 1 } }), + jumped: false + } + ]); + counterStore.increment(); + expect(devtools.currentState.counter).toEqual({ count: 2 }); + counterStore.decrement(2); + expect(devtools.currentState.counter).toEqual({ count: 0 }); + + expect(devtools.devtoolsStack).toEqual([ + { + id: 1, + type: 'counter@INIT', + payload: undefined, + state: null, + newState: jasmine.objectContaining({ counter: { count: 0 } }), + jumped: false + }, + { + id: 2, + type: 'counter@increment', + payload: undefined, + state: jasmine.objectContaining({ counter: { count: 0 } }), + newState: jasmine.objectContaining({ counter: { count: 1 } }), + jumped: false + }, + { + id: 3, + type: 'counter@increment', + payload: undefined, + state: jasmine.objectContaining({ counter: { count: 1 } }), + newState: jasmine.objectContaining({ counter: { count: 2 } }), + jumped: false + }, + { + id: 4, + type: 'counter@decrement', + payload: undefined, + state: jasmine.objectContaining({ counter: { count: 2 } }), + newState: jasmine.objectContaining({ counter: { count: 0 } }), + jumped: false + } + ]); + }); + }); + + it('should not connect successful', () => { + TestBed.configureTestingModule({ + imports: [], + providers: [ReduxDevtoolsPlugin] + }); + const reduxDevtoolsPlugin = TestBed.inject(ReduxDevtoolsPlugin); + expect(reduxDevtoolsPlugin['isConnectSuccessful']()).toBe(false); + }); +}); diff --git a/packages/store/src/test/devtools/symbols.ts b/packages/store/src/test/devtools/symbols.ts new file mode 100644 index 0000000..705b2e1 --- /dev/null +++ b/packages/store/src/test/devtools/symbols.ts @@ -0,0 +1,12 @@ +export interface MockState { + [key: string]: any; +} + +export interface DevtoolsCallStack { + id: number; + type: string; + payload: any; + state: MockState; + newState: MockState; + jumped: boolean; +} diff --git a/packages/store/src/test/plugin-manager.spec.ts b/packages/store/src/test/plugin-manager.spec.ts new file mode 100644 index 0000000..f2bc7d3 --- /dev/null +++ b/packages/store/src/test/plugin-manager.spec.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ThyStoreModule } from '../module'; +import { PluginContext, StorePlugin, StorePluginNextFn } from '../plugin'; +import { StorePluginManager } from '../plugin-manager'; + +@Injectable() +class MyPlugin implements StorePlugin { + handle(ctx: PluginContext, next: StorePluginNextFn): Observable { + ctx['MyPlugin'] = true; + return next(ctx); + } +} + +describe('plugin-manager', () => { + it('should register plugins', () => { + StorePluginManager['pluginManager'] = null; + TestBed.configureTestingModule({ + imports: [ThyStoreModule.forRoot([], { plugins: [MyPlugin] })] + }); + const pluginManager = TestBed.inject(StorePluginManager); + expect(StorePluginManager.instance).toBe(pluginManager); + expect(pluginManager.rootPluginHandlers).toBeTruthy(); + expect(pluginManager.rootPluginHandlers.length).toBe(1); + let called = false; + pluginManager.rootPluginHandlers[0]({} as unknown as PluginContext, (ctx) => { + called = true; + expect(ctx['MyPlugin']).toBe(true); + return of(ctx); + }); + expect(called).toBe(true); + }); + + it('should use root StorePluginManager', () => { + StorePluginManager['pluginManager'] = null; + const plugins = [new MyPlugin()]; + const parentManager = new StorePluginManager(undefined, plugins); + const childManager = new StorePluginManager(parentManager, []); + expect(StorePluginManager.instance).toBe(parentManager); + expect(childManager.rootPluginHandlers).toBe(parentManager.rootPluginHandlers); + }); +}); diff --git a/packages/store/src/test/plugin.spec.ts b/packages/store/src/test/plugin.spec.ts new file mode 100644 index 0000000..4b91e72 --- /dev/null +++ b/packages/store/src/test/plugin.spec.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Action } from '../action'; +import { ThyStoreModule } from '../module'; +import { PluginContext, StorePlugin, StorePluginNextFn } from '../plugin'; +import { StorePluginManager } from '../plugin-manager'; +import { Store } from '../store'; + +@Injectable() +class CounterStore extends Store { + constructor() { + super(0, { name: 'counter' }); + } + @Action() + increment() { + this.next(this.getState() + 1); + } +} + +describe('plugin', () => { + let counterStore: CounterStore; + let pluginInvoked = 0; + + @Injectable() + class MyPlugin implements StorePlugin { + handle(ctx: PluginContext, next: StorePluginNextFn): Observable { + pluginInvoked = pluginInvoked + 1; + return next(ctx); + } + } + + beforeEach(() => { + pluginInvoked = 0; + StorePluginManager['pluginManager'] = null; + TestBed.configureTestingModule({ + imports: [ThyStoreModule.forRoot([CounterStore], { plugins: [MyPlugin] })] + }); + counterStore = TestBed.inject(CounterStore); + }); + + afterEach(() => { + StorePluginManager['pluginManager'] = null; + }); + + it('should invoked plugin when store init', () => { + expect(pluginInvoked).toBe(1); + }); + + it('should invoked plugin when store invoke', () => { + counterStore.increment(); + expect(pluginInvoked).toBe(2); + }); +}); diff --git a/packages/store/src/test/store-with-options.spec.ts b/packages/store/src/test/store-with-options.spec.ts index 27cdd8c..73b300d 100644 --- a/packages/store/src/test/store-with-options.spec.ts +++ b/packages/store/src/test/store-with-options.spec.ts @@ -74,6 +74,7 @@ describe('store-with-options', () => { it('should create store instance without limited count, instanceMaxCount = 0', () => { const destroy = createStores(500, GardenStoreWithUnlimitedCount); destroy(); + expect(true); }); function createStores(count: number, store: { new (...args: any[]): Store }) { diff --git a/packages/store/src/types.ts b/packages/store/src/types.ts index 1f7ca05..9150231 100644 --- a/packages/store/src/types.ts +++ b/packages/store/src/types.ts @@ -1,8 +1,8 @@ import { InjectionToken } from '@angular/core'; export const META_KEY = '__THY_META__'; -export const ROOT_STATE_TOKEN = new InjectionToken('ROOT_STATE_TOKEN'); -export const FEATURE_STATE_TOKEN = new InjectionToken('FEATURE_STATE_TOKEN'); +export const ROOT_STORES_TOKEN = new InjectionToken('ROOT_STORES_TOKEN'); +export const FEATURE_STORES_TOKEN = new InjectionToken('FEATURE_STORES_TOKEN'); export interface Id { toString(): string; @@ -15,8 +15,6 @@ export interface PaginationInfo { pageSize?: number; } -// export type Newable = { new (...args: any[]): T }; - /** * Store options */ diff --git a/yarn.lock b/yarn.lock index 6ff2818..c1d1ea5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1948,16 +1948,16 @@ resolved "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== -"@docgeni/cli@^1.2.0-next.25": - version "1.2.0-next.25" - resolved "https://registry.npmjs.org/@docgeni/cli/-/cli-1.2.0-next.25.tgz#a0b499db7a2be174690a8019f49c4fb56c9490b9" - integrity sha512-MJx8vBK/U+rrp2eDRlls7XQrBz2J2JQBUIP4sjKRxatXCbXnuzQgUHerEqvBu4VvM6SWtxkqlYqdiKTy/hm7bw== +"@docgeni/cli@^1.2.0-next.27": + version "1.2.0-next.27" + resolved "https://registry.npmjs.org/@docgeni/cli/-/cli-1.2.0-next.27.tgz#bf996fea95f2fb09b71e1fe48d7ed52daa02f11f" + integrity sha512-UC42eHNHOD9n2r1GlXk0QVNe6Jl88LCHyxn9ldrPRgytvFhbub5OVSfV1A5ZKMthNlF/9p6c0ljPpG9ZmOvXlQ== dependencies: "@angular-devkit/schematics" "^12.2.18" "@angular-devkit/schematics-cli" "^12.2.18" - "@docgeni/core" "^1.2.0-next.25" - "@docgeni/template" "^1.2.0-next.25" - "@docgeni/toolkit" "^1.2.0-next.25" + "@docgeni/core" "^1.2.0-next.27" + "@docgeni/template" "^1.2.0-next.27" + "@docgeni/toolkit" "^1.2.0-next.27" "@schematics/angular" "^12.2.18" chokidar "^3.3.1" cosmiconfig "^6.0.0" @@ -1970,15 +1970,15 @@ yargs "15.3.1" zone.js "^0.10.2" -"@docgeni/core@^1.2.0-next.25": - version "1.2.0-next.25" - resolved "https://registry.npmjs.org/@docgeni/core/-/core-1.2.0-next.25.tgz#2b37f75ea63e29642e53d80a6f4c192e4f4c6f6b" - integrity sha512-IYHhzi8vfOTj13mHTXQVIrQpUzoFojtOi0Ybn8ZJvNaIwQgEU7uB0a3R99zLyeACVd/r8yShxLh11pFL3jFGGQ== +"@docgeni/core@^1.2.0-next.27": + version "1.2.0-next.27" + resolved "https://registry.npmjs.org/@docgeni/core/-/core-1.2.0-next.27.tgz#75d95a1d3855358c03d618d6604f22b586bd8343" + integrity sha512-9c/J0zhadAbOMPZ4EXK7flzN1SRbKcETIPgpbR3ZuYWxWin9ljxEtwmwd0ShanScn59ilvtlFjfWRs7fYuLzCg== dependencies: "@angular-devkit/core" "^12.2.18" "@angular-devkit/schematics" "^12.2.18" - "@docgeni/ngdoc" "^1.2.0-next.25" - "@docgeni/toolkit" "^1.2.0-next.25" + "@docgeni/ngdoc" "^1.2.0-next.27" + "@docgeni/toolkit" "^1.2.0-next.27" "@schematics/angular" "^11.2.15" "@types/text-table" "^0.2.2" ansi-colors "^4.1.1" @@ -1999,19 +1999,19 @@ vinyl "^2.2.0" vinyl-fs "^3.0.3" -"@docgeni/ngdoc@^1.2.0-next.25": - version "1.2.0-next.25" - resolved "https://registry.npmjs.org/@docgeni/ngdoc/-/ngdoc-1.2.0-next.25.tgz#37232c7c9dac67e9209d861ce933c9469e0140fe" - integrity sha512-2/ZK0m7aj/ujaMRGyTC31zMxP2gIFYSC0KIJbVGlFSlVyKD/z+lHYB8pqf903oEygEMu+rNtrMaRE68PpB8LoQ== +"@docgeni/ngdoc@^1.2.0-next.27": + version "1.2.0-next.27" + resolved "https://registry.npmjs.org/@docgeni/ngdoc/-/ngdoc-1.2.0-next.27.tgz#b95501a7ebac720f528699a4858ebc74c6fd317c" + integrity sha512-hXB39p4/qItGBnMz4TsMxDAnpOcTdp7M6GhQPrxTCfZx3hw8vgfOQfRHavjxfHwI6MB9qpFC5/dExws2K2Tdbg== dependencies: - "@docgeni/toolkit" "^1.2.0-next.25" + "@docgeni/toolkit" "^1.2.0-next.27" ts-morph "12.1.0" typescript "4.3.5" -"@docgeni/template@^1.2.0-next.25": - version "1.2.0-next.25" - resolved "https://registry.npmjs.org/@docgeni/template/-/template-1.2.0-next.25.tgz#cbad1c65046cb94d4a95ef6d1030301d229dc826" - integrity sha512-s7+TAMxFvDn/SgKBOSsmI0LgewsdWO0gM05ikMFPiub9HZqBs0wWNqEyqy352Q81gT3rSbNnUn/M7MdfXlVteA== +"@docgeni/template@^1.2.0-next.27": + version "1.2.0-next.27" + resolved "https://registry.npmjs.org/@docgeni/template/-/template-1.2.0-next.27.tgz#656b9f8fdf8a178f2f1fe9bf75aa4e37347d1db0" + integrity sha512-DeipBhOO1ED9+XR7+a85s/ooicMP6FzamBGmzKkvZMlrx0YQxzarPS7ofL0W8eU7We3x5WOlW4HXZ8gDIvI8Cg== dependencies: docsearch.js "2.6.3" tslib "^2.2.0" @@ -2039,10 +2039,10 @@ time-stamp "^2.2.0" title-case "^3.0.2" -"@docgeni/toolkit@^1.2.0-next.25": - version "1.2.0-next.25" - resolved "https://registry.npmjs.org/@docgeni/toolkit/-/toolkit-1.2.0-next.25.tgz#ebf46ea25c2d5fde563665db72779fcc27ea476f" - integrity sha512-kzSu3IrwPkRHn7GqYvcw/vh8VxUvCKNw7zJ60RxTxOMcOUdsx8Fu/Gd8UMUXyCeSfJyBpp9gp7D9w0pkAp+OmQ== +"@docgeni/toolkit@^1.2.0-next.27": + version "1.2.0-next.27" + resolved "https://registry.npmjs.org/@docgeni/toolkit/-/toolkit-1.2.0-next.27.tgz#49ee8addf33408814ad545096c8422e52f1c3b57" + integrity sha512-AOU/QIKXCdaArlC2WP1fWWbGKf9HAXP6/Io+eqNAwhV+WGCQZjeFlTwCNV468UgiFwIAPfl6IUW/5PUnrcnqZA== dependencies: "@angular-devkit/core" "^12.2.18" camelcase "^6.0.0"