From c5defdb8c7408c40c45e71971e9c121f69c66299 Mon Sep 17 00:00:00 2001 From: Snickbit Date: Mon, 7 Aug 2023 12:10:17 -0400 Subject: [PATCH] feat(state): Add multiple state types for more use cases --- packages/state/__tests__/ReactiveState.ts | 37 +++++ packages/state/__tests__/SimpleState.test.ts | 21 +++ packages/state/__tests__/State.test.ts | 43 ++++++ packages/state/__tests__/index.test.ts | 27 ---- packages/state/indexer.config.json | 2 +- packages/state/src/ReactiveState.ts | 67 ++++++++ packages/state/src/SimpleState.ts | 23 +++ packages/state/src/State.ts | 73 +++++++++ packages/state/src/Store.ts | 153 ++++--------------- packages/state/src/factory.ts | 23 ++- packages/state/src/index.ts | 8 +- packages/state/src/lib/make-state-proxy.ts | 53 +++++++ 12 files changed, 372 insertions(+), 158 deletions(-) create mode 100644 packages/state/__tests__/ReactiveState.ts create mode 100644 packages/state/__tests__/SimpleState.test.ts create mode 100644 packages/state/__tests__/State.test.ts delete mode 100644 packages/state/__tests__/index.test.ts create mode 100644 packages/state/src/ReactiveState.ts create mode 100644 packages/state/src/SimpleState.ts create mode 100644 packages/state/src/State.ts create mode 100644 packages/state/src/lib/make-state-proxy.ts diff --git a/packages/state/__tests__/ReactiveState.ts b/packages/state/__tests__/ReactiveState.ts new file mode 100644 index 00000000..d77c3955 --- /dev/null +++ b/packages/state/__tests__/ReactiveState.ts @@ -0,0 +1,37 @@ +import {ReactiveState} from '../src' + +describe('ReactiveState', () => { + it('should get and set state properties', () => { + const state = new ReactiveState({foo: 'bar'}) + expect(state.foo).toBe('bar') + state.foo = 'baz' + expect(state.foo).toBe('baz') + }) + + it('should get and set proxy properties', () => { + const state = new ReactiveState({foo: 'bar'}) + state.baz = 'qux' + expect(state.baz).toBe('qux') + }) + + it('should return undefined for non-existent properties', () => { + const state = new ReactiveState() + expect(state.foo).toBeUndefined() + }) + + it('should call watchers when state properties change', () => { + const state = new ReactiveState({foo: 'bar'}) + const callback = jest.fn() + state.$watch('foo', callback) + state.foo = 'baz' + expect(callback).toHaveBeenCalledWith('baz') + }) + + it('should emit events when $emit is called', () => { + const state = new ReactiveState() + const callback = jest.fn() + state.$on('test', callback) + state.$emit('test', 'data') + expect(callback).toHaveBeenCalledWith('data') + }) +}) diff --git a/packages/state/__tests__/SimpleState.test.ts b/packages/state/__tests__/SimpleState.test.ts new file mode 100644 index 00000000..7d6514f6 --- /dev/null +++ b/packages/state/__tests__/SimpleState.test.ts @@ -0,0 +1,21 @@ +import {SimpleState} from '../src' + +describe('SimpleState', () => { + it('should get and set state properties', () => { + const state = new SimpleState({foo: 'bar'}) + expect(state.foo).toBe('bar') + state.foo = 'baz' + expect(state.foo).toBe('baz') + }) + + it('should get and set proxy properties', () => { + const state = new SimpleState({foo: 'bar'}) + state.baz = 'qux' + expect(state.baz).toBe('qux') + }) + + it('should return undefined for non-existent properties', () => { + const state = new SimpleState() + expect(state.foo).toBeUndefined() + }) +}) diff --git a/packages/state/__tests__/State.test.ts b/packages/state/__tests__/State.test.ts new file mode 100644 index 00000000..3e05d633 --- /dev/null +++ b/packages/state/__tests__/State.test.ts @@ -0,0 +1,43 @@ +import {State} from '../src' + +describe('State', () => { + it('should get and set state properties', () => { + const state = new State({foo: 'bar'}) + expect(state.foo).toBe('bar') + state.foo = 'baz' + expect(state.foo).toBe('baz') + }) + + it('should get and set proxy properties', () => { + const state = new State({foo: 'bar'}) + state.baz = 'qux' + expect(state.baz).toBe('qux') + }) + + it('should return undefined for non-existent properties', () => { + const state = new State() + expect(state.foo).toBeUndefined() + }) + + it('should have a unique ID', () => { + const state1 = new State() + const state2 = new State() + expect(state1.$id).not.toBe(state2.$id) + }) + + it('should have a name if provided', () => { + const state = new State('test') + expect(state.$name).toBe('State.test') + }) + + it('should get state properties with $get', () => { + const state = new State({foo: 'bar'}) + expect(state.$get('foo')).toBe('bar') + }) + + it('should set state properties with $set', () => { + const state = new State({foo: 'bar'}) + state.$set('foo', 'baz') + expect(state.foo).toBe('baz') + }) +}) diff --git a/packages/state/__tests__/index.test.ts b/packages/state/__tests__/index.test.ts deleted file mode 100644 index efb284f2..00000000 --- a/packages/state/__tests__/index.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {createStore, Store, UseStore} from '../src' - -describe('createStore', () => { - let instance: Store - let useStoreInstance: UseStore - beforeEach(() => { - useStoreInstance = createStore() - instance = useStoreInstance() - }) - - it('should return a function', () => { - expect(useStoreInstance).toBeInstanceOf(Function) - }) - - it('should return a store instance', () => { - expect(instance).toBeInstanceOf(Store) - }) - - it('should return the same store instance', () => { - expect(useStoreInstance()).toBe(instance) - }) - - it('should return a different store instance', () => { - expect(createStore()()).not.toBe(instance) - }) -}) - diff --git a/packages/state/indexer.config.json b/packages/state/indexer.config.json index 94f0b92c..286c115c 100644 --- a/packages/state/indexer.config.json +++ b/packages/state/indexer.config.json @@ -1,5 +1,5 @@ { - "source": "src/**/*", + "source": "src/*", "output": "src/index.ts", "type": "wildcard" } diff --git a/packages/state/src/ReactiveState.ts b/packages/state/src/ReactiveState.ts new file mode 100644 index 00000000..99f5cff8 --- /dev/null +++ b/packages/state/src/ReactiveState.ts @@ -0,0 +1,67 @@ +import {uuid} from '@snickbit/utilities' +import {State} from './State' +import {makeStateProxy} from './lib/make-state-proxy' +import mitt, {Handler} from 'mitt' + +export type WatchCallback = (value: any) => any +export type WatchStop = () => void +export type Watchers = Record + +export class ReactiveState extends State { + declare protected proxy: ReactiveState & T + + protected emitter = mitt() + protected watchers = {} as Record + + constructor(data: Partial = {}) { + super(data) + + console.log(`ReactiveState ${this.id} constructor`, {data, state: this.$state}) + + if (!this.proxy) { + throw new Error('No ReactiveState proxy') + } + + this.proxy = makeStateProxy.apply(this) + return this.proxy + } + + $set(key: keyof T, value: any) { + console.log({key, value}) + super.$set(key, value) + this.callWatchers(key, value) + } + + $on(event: string, callback: Handler) { + this.emitter.on(this.makeId(event), callback) + } + + $off(event: string, callback: Handler) { + this.emitter.off(this.makeId(event), callback) + } + + $emit(event: string, data: any) { + this.emitter.emit(this.makeId(event), data) + } + + $watch(key: keyof T, callback: WatchCallback) { + const watchers: Watchers = this.watchers[key] || {} + + const id = uuid() as string + watchers[id] = callback + + this.watchers[key] = watchers + + return () => { + delete this.watchers[key][id] + } + } + + private callWatchers(key: keyof T, value: any) { + if (this.watchers[key]) { + for (const id in this.watchers[key]) { + this.watchers[key][id](value) + } + } + } +} diff --git a/packages/state/src/SimpleState.ts b/packages/state/src/SimpleState.ts new file mode 100644 index 00000000..8d59ea70 --- /dev/null +++ b/packages/state/src/SimpleState.ts @@ -0,0 +1,23 @@ +import {uuid} from '@snickbit/utilities' +import {makeStateProxy} from './lib/make-state-proxy' + +export interface SimpleState { + [key: string | symbol]: any +} + +export class SimpleState { + protected proxy: SimpleState & T + protected state = {} as T + protected id?: string + + constructor(data?: Partial) { + this.id = uuid() + this.state = {...data} as T + + this.proxy = makeStateProxy.apply(this) + + console.log(`SimpleState ${this.id} constructor`, {data, state: this.state}) + + return this.proxy + } +} diff --git a/packages/state/src/State.ts b/packages/state/src/State.ts new file mode 100644 index 00000000..a6279845 --- /dev/null +++ b/packages/state/src/State.ts @@ -0,0 +1,73 @@ +import {isString, objectClone} from '@snickbit/utilities' +import {SimpleState} from './SimpleState' +import {makeStateProxy} from './lib/make-state-proxy' + +export class State extends SimpleState { + declare protected proxy: State & T + protected original: T + protected name?: string + + constructor(name?: string) + constructor(data?: Partial) + constructor(name: string, data?: Partial) + constructor(nameOrData: Partial | string, optionalData?: Partial) { + let name: string + let data: Partial + if (isString(nameOrData)) { + name = nameOrData + data = optionalData + } else { + data = nameOrData + } + + super(data) + + this.proxy = makeStateProxy.apply(this) + + this.name = this.constructor.name + (name ? `.${name}` : '') + this.original = objectClone(this.state) + return this.proxy + } + + get $id() { + return this.id + } + + get $name() { + return this.name || this.constructor.name + } + + get $state() { + return this.state + } + + $get(key: keyof T) { + return this.state[key] + } + + $set(key: keyof T, value: any) { + this.state[key as keyof T] = value + } + + $has(key: string) { + return key in this.state + } + + $keys() { + return Object.keys(this.state) + } + + $patch(data: Partial) { + for (const key in data) { + this.$set(key, data[key]) + } + } + + $reset() { + this.state = objectClone(this.original) + } + + protected makeId(...keys: string[]) { + return [this.$name, ...keys].join('.') + } +} diff --git a/packages/state/src/Store.ts b/packages/state/src/Store.ts index dcafed88..35245ccb 100644 --- a/packages/state/src/Store.ts +++ b/packages/state/src/Store.ts @@ -1,5 +1,6 @@ import {objectClone, uuid} from '@snickbit/utilities' -import mitt, {Handler} from 'mitt' +import {ReactiveState} from './ReactiveState' +import {makeStateProxy} from './lib/make-state-proxy' export interface StoreOptions { name: string @@ -14,73 +15,32 @@ export type StoreGetter = (this: Store) => any export type StoreActions = Record export type StoreGetters = Record -export class Store { - protected state: State = {} as State - private readonly $state: ProxyHandler - - protected originalState: State = {} as State - - protected proxy: Store - - protected actions: StoreActions = {} - private readonly $actions: ProxyHandler - - protected getters: StoreGetters = {} - private readonly $getters: ProxyHandler - - protected ready = false - - private emitter = mitt() - +export class Store extends ReactiveState { + declare protected proxy: Store & T options: StoreOptions = { name: 'default', persist: [] } - protected id = (...keys: string[]) => [ - 'state-store', - this.$id, - ...keys - ].join('.') + persistable: string[] = [] - constructor(hydration?: State, options?: Partial) { - this.$config(options, hydration) - - this.proxy = new Proxy(this, { - get(target: Store, prop: string, receiver?: any): any { - if (prop in target) { - return target[prop] - } - - if (target.$has(prop)) { - return target.$get(prop) - } - - if (prop in target.actions) { - return target.callAction.bind(target, prop) - } + protected actions: StoreActions = {} + protected getters: StoreGetters = {} + protected ready = false + private readonly $actions: ProxyHandler + private readonly $getters: ProxyHandler - if (prop in target.getters) { - return target.callGetter.call(target, prop) - } + constructor(data?: T, options?: Partial) { + super(data) + this.$config(options, data) - return Reflect.get(target, prop, receiver) - }, - set(target: Store, prop: string, value?: any) { - target.$set(prop, value) - return true + this.proxy = makeStateProxy.apply(this, (target: Store, prop) => { + if (prop in target.actions) { + return target.callAction.bind(target, prop) } - }) - this.$state = new Proxy(this.state, { - get: (target: State, prop: string) => { - if (this.$has(prop)) { - return this.$get(prop) - } - }, - set: (target: State, prop: string, value: any) => { - this.$set(prop, value) - return true + if (prop in target.getters) { + return target.callGetter.call(target, prop) } }) @@ -105,26 +65,14 @@ export class Store { return this.proxy } - get $id() { - return this.options.name - } - get $ready() { return this.ready } - protected callAction(name: string, ...args: any[]) { - return this.actions[name].call(this, ...args) - } - - protected callGetter(name: string) { - return this.getters[name] - } - - $config(options?: Partial, hydration?: State) { - const isPending = !options && !hydration + $config(options?: Partial, data?: T) { + const isPending = !options && !data options ||= {} - hydration ||= {} as State + data ||= {} as T const { actions, getters, @@ -137,7 +85,7 @@ export class Store { } if (!isPending) { - this.$hydrate(hydration) + this.$hydrate(data) } this.actions = actions || {} @@ -149,63 +97,18 @@ export class Store { this.ready = !isPending } - $hydrate(hydration: State) { - this.originalState = objectClone(hydration) + $hydrate(hydration: T) { + this.original = objectClone(hydration) for (const key in hydration) { this.state[key] = hydration[key] } } - $get(key: string) { - return this.state[key] - } - - $set(key: string, value: any) { - this.state[key] = value - } - - $has(key: string) { - return key in this.state - } - - $keys() { - return Object.keys(this.state) - } - - $patch(data: Partial) { - for (const key in data) { - this.$set(key, data[key]) - } - } - - $reset() { - this.state = this.originalState - } - - private on(...args: any[]): void { - const callback = args.pop() - this.emitter.on(args.join('.'), callback) - } - - private off(...args: any[]): void { - const callback = args.pop() - this.emitter.off(args.join('.'), callback) - } - - private emit(...args: any[]): void { - const data = args.pop() - this.emitter.emit(args.join('.'), data) - } - - $on(event: string, callback: Handler) { - this.on(this.id(event), callback) - } - - $off(event: string, callback: Handler) { - this.off(this.id(event), callback) + protected callAction(name: string, ...args: any[]) { + return this.actions[name].call(this, ...args) } - $emit(event: string, data: any) { - this.emit(this.id(event), data) + protected callGetter(name: string) { + return this.getters[name] } } diff --git a/packages/state/src/factory.ts b/packages/state/src/factory.ts index 4fc937f5..a6d00e44 100644 --- a/packages/state/src/factory.ts +++ b/packages/state/src/factory.ts @@ -1,10 +1,25 @@ import {Store, StoreOptions} from './Store' +import {State} from './State' +import {ReactiveState} from './ReactiveState' -const _states: Record = {} -export type UseStore = () => Store & T +const _stores: Record = {} +const _states: Record = {} +const _reactive_states: Record = {} -export function createStore(hydration: State = {} as State, options?: Partial): UseStore { - const state = new Store(hydration, options) +export function createStore(hydration: T = {} as T, options?: Partial): () => Store { + const store = new Store(hydration, options) + _stores[store.$id] = store + return () => _stores[store.$id] +} + +export function createState(hydration: Partial = {}): () => State { + const state = new State(hydration) _states[state.$id] = state return () => _states[state.$id] } + +export function createReactiveState(hydration: Partial = {}): () => ReactiveState { + const reactive_state = new ReactiveState(hydration) + _reactive_states[reactive_state.$id] = reactive_state + return () => _reactive_states[reactive_state.$id] +} diff --git a/packages/state/src/index.ts b/packages/state/src/index.ts index b51a1b79..b90d03ca 100644 --- a/packages/state/src/index.ts +++ b/packages/state/src/index.ts @@ -1,2 +1,8 @@ -export * from './factory' +// WARNING: This file is automatically generated. Any changes will be lost the next time the generator is run. + +export * from './ReactiveState' +export * from './SimpleState' +export * from '../__tests__/SimpleState.test' +export * from './State' export * from './Store' +export * from './factory' diff --git a/packages/state/src/lib/make-state-proxy.ts b/packages/state/src/lib/make-state-proxy.ts new file mode 100644 index 00000000..c2b69764 --- /dev/null +++ b/packages/state/src/lib/make-state-proxy.ts @@ -0,0 +1,53 @@ +import {SimpleState} from '../SimpleState' + +export function makeStateProxy(this: C, + getHook?: (target: C, prop: string | symbol, receiver: any) => any, + setHook?: (target: C, prop: string | symbol, value: any, receiver: any) => any) { + return new Proxy(this, { + get: (target, prop, receiver) => { + if (prop in target) { + return target[prop] + } + + if (prop in this.state) { + if ('$get' in this) { + return this.$get(prop as keyof T) + } + return this.state[prop] + } + + if (getHook) { + const result = getHook(target, prop, receiver) + if (result) { + return result + } + } + + return Reflect.get(target, prop, receiver) + }, + set: (target, prop, value, receiver) => { + if (prop in target) { + target[prop as keyof typeof target] = value + return true + } + + if (prop in this.state) { + if ('$set' in this) { + this.$set(prop as keyof T, value) + } else { + this.state[prop] = value + } + return true + } + + if (setHook) { + const result = setHook(target, prop, value, receiver) + if (result) { + return result + } + } + + return Reflect.set(target, prop, value, receiver) + } + }) as C & T +}