diff --git a/CHANGELOG.md b/CHANGELOG.md index fef797c..18673f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.13.0 + +### Feature +* Adds new `onAction` method that triggers callback whenever specific action is called. + ## 0.12.4 ### Bugfixes diff --git a/README.md b/README.md index e80cafc..5c3cbc9 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,45 @@ function App() { ``` [__Open in Codesandbox__](https://codesandbox.io/s/exome-counter-96qfq) +### `onAction` +Function that calls callback whenever specific action on Exome is called. + +```ts +function onAction(store: typeof Exome): Unsubscribe +``` + +__Arguments__ +1. `store` _([Exome](#exome) constructor)_: Store that has desired action to listen to. +2. `action` _(string)_: method (action) name on store instance. +3. `callback` _(Function)_: Callback that will be triggered before or after action.
+ __Arguments__ + - `instance` _([Exome](#exome))_: Instance where action is taking place. + - `action` _(String)_: Action name. + - `payload` _(any[])_: Array of arguments passed in action.
+4. `type` _("before" | "after")_: when to run callback - before or after action, default is `"after"`. + +__Returns__ + +- _Function_: Unsubscribes this action listener + +__Example__ + +```tsx +import { onAction } from "exome" + +const unsubscribe = onAction( + Person, + 'rename', + (instance, action, payload) => { + console.log(`Person ${instance} was renamed to ${payload[0]}`); + + // Unsubscribe is no longer needed + unsubscribe(); + }, + 'before' +) +``` + ### `saveState` Function that saves snapshot of current state for any Exome and returns string. diff --git a/package-lock.json b/package-lock.json index 3f9d51c..7401e5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "exome", - "version": "0.12.4", + "version": "0.13.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "exome", - "version": "0.12.4", + "version": "0.13.0", "license": "MIT", "devDependencies": { "@types/node": "^14.14.41", diff --git a/package.json b/package.json index 77c132b..e6851ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "exome", - "version": "0.12.4", + "version": "0.13.0", "description": "Proxy based store manager for deeply nested states", "main": "exome.js", "module": "exome.esm.js", diff --git a/src/index.ts b/src/index.ts index 7b72c11..564d6a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,3 +7,4 @@ export { updateView } from './utils/update-view' export { saveState } from './utils/save-state' export { loadState, registerLoadable } from './utils/load-state' export { addMiddleware, Middleware } from './middleware' +export { onAction } from './utils/on-action' diff --git a/src/middleware.test.ts b/src/middleware.test.ts index dd710ab..92b8faf 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -70,4 +70,56 @@ test('runs middleware unsubscribe method', () => { assert.equal(unsubscribe.args[0], []) }) +test('removes middleware correctly', () => { + const instance = new Exome() + const middleware = fake() + + const unsubscribe = addMiddleware(middleware) + + runMiddleware(instance, 'actionName', []) + + assert.equal(middleware.callCount, 1) + assert.equal(middleware.args[0], [instance, 'actionName', []]) + + runMiddleware(instance, 'actionName', []) + + assert.equal(middleware.callCount, 2) + assert.equal(middleware.args[1], [instance, 'actionName', []]) + + unsubscribe() + runMiddleware(instance, 'actionName', []) + + assert.equal(middleware.callCount, 2) +}) + +test('removes multiple middleware correctly', () => { + const instance = new Exome() + const middleware1 = fake() + const middleware2 = fake() + + const unsubscribe1 = addMiddleware(middleware1) + const unsubscribe2 = addMiddleware(middleware2) + + runMiddleware(instance, 'actionName', []) + + assert.equal(middleware1.callCount, 1) + assert.equal(middleware1.args[0], [instance, 'actionName', []]) + assert.equal(middleware2.callCount, 1) + assert.equal(middleware2.args[0], [instance, 'actionName', []]) + + runMiddleware(instance, 'actionName', []) + + assert.equal(middleware1.callCount, 2) + assert.equal(middleware1.args[1], [instance, 'actionName', []]) + assert.equal(middleware2.callCount, 2) + assert.equal(middleware2.args[1], [instance, 'actionName', []]) + + unsubscribe1() + unsubscribe2() + runMiddleware(instance, 'actionName', []) + + assert.equal(middleware1.callCount, 2) + assert.equal(middleware2.callCount, 2) +}) + test.run() diff --git a/src/middleware.ts b/src/middleware.ts index cd36a6d..7eb550d 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,10 +3,15 @@ import { Exome } from './exome' // eslint-disable-next-line @typescript-eslint/no-invalid-void-type export type Middleware = (instance: Exome, action: string, payload: any[]) => (void | (() => void)) -const middleware: Middleware[] = [] +export const middleware: Middleware[] = [] export function addMiddleware(fn: Middleware) { middleware.push(fn) + + return () => { + const index = middleware.indexOf(fn) + middleware.splice(index, 1) + } } export function runMiddleware(...params: Parameters) { diff --git a/src/utils/on-action.test.ts b/src/utils/on-action.test.ts new file mode 100644 index 0000000..e0c9fd9 --- /dev/null +++ b/src/utils/on-action.test.ts @@ -0,0 +1,203 @@ +import { fake } from 'sinon' +import { test } from 'uvu' +import assert from 'uvu/assert' + +import { Exome } from '../exome' +import { middleware, runMiddleware } from '../middleware' + +import { onAction } from './on-action' + +test.before.each(() => { + middleware.splice(0, 100) +}) + +test('exports `onAction`', () => { + assert.ok(onAction) +}) + +test('that `onAction` is function', () => { + assert.instance(onAction, Function) +}) + +test('adds before middleware without errors', () => { + class Person extends Exome { + constructor( + public name?: string + ) { + super() + } + + public rename(name: string) { + this.name = name + } + } + + const person = new Person('John') + const handler = fake() + + onAction(Person, 'rename', handler, 'before') + + const after = runMiddleware(person, 'rename', [1]) + + assert.equal(handler.callCount, 1) + assert.equal(handler.args[0], [person, 'rename', [1]]) + + after() + + assert.equal(handler.callCount, 1) +}) + +test('adds after middleware without errors', () => { + class Person extends Exome { + constructor( + public name?: string + ) { + super() + } + + public rename(name: string) { + this.name = name + } + } + + const person = new Person('John') + const handler = fake() + + onAction(Person, 'rename', handler, 'after') + + const after = runMiddleware(person, 'rename', [1]) + + assert.equal(handler.callCount, 0) + + after() + + assert.equal(handler.callCount, 1) + assert.equal(handler.args[0], [person, 'rename', [1]]) +}) + +test('calls NEW action correctly', async() => { + class Person extends Exome { + constructor( + public name?: string + ) { + super() + } + + public rename(name: string) { + this.name = name + } + } + + const handler = fake() + onAction(Person, 'NEW', handler) + + assert.equal(handler.callCount, 0) + + // eslint-disable-next-line no-new + new Person('John') + + await new Promise((resolve) => setTimeout(resolve, 10)) + + assert.equal(handler.callCount, 1) + assert.instance(handler.args[0][0], Person) + assert.equal(handler.args[0][1], 'NEW') + assert.equal(handler.args[0][2].length, 0) +}) + +test('calls any action correctly', async() => { + class Person extends Exome { + constructor( + public name?: string + ) { + super() + } + + public rename(name: string) { + this.name = name + } + } + + const handler = fake() + onAction(Person, null, handler) + + assert.equal(handler.callCount, 0) + + const person = new Person('John') + + await new Promise((resolve) => setTimeout(resolve, 10)) + + assert.equal(handler.callCount, 1) + assert.instance(handler.args[0][0], Person) + assert.equal(handler.args[0][1], 'NEW') + assert.equal(handler.args[0][2].length, 0) + + person.rename('Jane') + + assert.equal(handler.callCount, 2) + assert.equal(handler.args[1], [person, 'rename', ['Jane']]) +}) + +test('calls custom action correctly', () => { + class Person extends Exome { + constructor( + public name?: string + ) { + super() + } + + public rename(name: string) { + this.name = name + } + } + + const handler = fake() + onAction(Person, 'rename', handler) + + assert.equal(handler.callCount, 0) + + const person = new Person('John') + + assert.equal(handler.callCount, 0) + + person.rename('Jane') + + assert.equal(handler.callCount, 1) + assert.equal(handler.args[0], [person, 'rename', ['Jane']]) +}) + +test('unsubscribes correctly', () => { + class Person extends Exome { + constructor( + public name?: string + ) { + super() + } + + public rename(name: string) { + this.name = name + } + } + + const handler = fake() + const unsubscribe = onAction(Person, 'rename', handler) + + assert.equal(handler.callCount, 0) + + const person = new Person('John') + + assert.equal(handler.callCount, 0) + + person.rename('Jane') + + assert.equal(handler.callCount, 1) + + person.rename('Janine') + + assert.equal(handler.callCount, 2) + + unsubscribe() + + assert.equal(handler.callCount, 2) +}) + +test.run() diff --git a/src/utils/on-action.ts b/src/utils/on-action.ts new file mode 100644 index 0000000..55b8abe --- /dev/null +++ b/src/utils/on-action.ts @@ -0,0 +1,26 @@ +import { Exome } from '../exome' +import { addMiddleware } from '../middleware' + +type Unsubscribe = () => void + +export function onAction( + Parent: new (...args: any[]) => T, + action: null | 'NEW' | keyof T, + callback: (instance: T, action: 'NEW' | keyof T, payload: any[]) => void, + type: 'before' | 'after' = 'after' +): Unsubscribe { + return addMiddleware((instance, targetAction, payload) => { + if (!(instance instanceof Parent && (targetAction === action || action === null))) { + return + } + + if (targetAction === 'NEW' || type === 'before') { + callback(instance, targetAction as any, payload) + return + } + + return () => { + callback(instance, targetAction as any, payload) + } + }) +}