From 1ed86cd0fc80741463f9a617b8e2b1bb3eadbad2 Mon Sep 17 00:00:00 2001 From: Andrew Courtice Date: Thu, 5 Aug 2021 15:07:08 +1000 Subject: [PATCH] feat(history): added basic history extension --- extensions/history/README.md | 57 ++++++++++ extensions/history/package.json | 47 ++++++++ extensions/history/src/constants.ts | 15 +++ extensions/history/src/index.ts | 145 ++++++++++++++++++++++++ extensions/history/src/types.ts | 28 +++++ extensions/history/test/history.test.ts | 47 ++++++++ extensions/history/tsconfig.json | 7 ++ extensions/history/tsup.config.ts | 10 ++ 8 files changed, 356 insertions(+) create mode 100644 extensions/history/README.md create mode 100644 extensions/history/package.json create mode 100644 extensions/history/src/constants.ts create mode 100644 extensions/history/src/index.ts create mode 100644 extensions/history/src/types.ts create mode 100644 extensions/history/test/history.test.ts create mode 100644 extensions/history/tsconfig.json create mode 100644 extensions/history/tsup.config.ts diff --git a/extensions/history/README.md b/extensions/history/README.md new file mode 100644 index 00000000..7e0a6a05 --- /dev/null +++ b/extensions/history/README.md @@ -0,0 +1,57 @@ +

+ + Harlem + +

+ +# Harlem Reset Plugin + +![npm](https://img.shields.io/npm/v/@harlem/plugin-reset) + +This is the official Harlem plugin for resetting stores to their initial state. + + + +- [Getting started](#getting-started) + + + +## Getting started + +Before installing the reset plugin make sure you have installed `@harlem/core`. + +1. Install `@harlem/plugin-reset`: +``` +npm install @harlem/plugin-reset +``` +Or if you're using Yarn: +``` +yarn add @harlem/plugin-reset +``` + +2. Create an instance of the plugin and register it with Harlem: +```typescript +import App from './app.vue'; + +import harlem from '@harlem/core'; +import createResetPlugin from '@harlem/plugin-reset'; + +createApp(App) + .use(harlem, { + plugins: [ + createResetPlugin() + ] + }) + .mount('#app'); +``` + +3. Call the reset method with the name of the store you wish to reset: +```typescript +import { + reset +} from '@harlem/plugin-reset'; + +export default function() { + reset('my-store'); +} +``` diff --git a/extensions/history/package.json b/extensions/history/package.json new file mode 100644 index 00000000..743633ba --- /dev/null +++ b/extensions/history/package.json @@ -0,0 +1,47 @@ +{ + "name": "@harlem/extension-history", + "amdName": "harlemHistory", + "version": "1.3.2", + "license": "MIT", + "author": "Andrew Courtice ", + "description": "The official history extension for Harlem", + "homepage": "https://harlemjs.com", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "exports": "./dist/index.js", + "unpkg": "./dist/index.global.js", + "types": "./dist/index.d.ts", + "source": "./src/index.ts", + "keywords": [ + "vue", + "state", + "harlem", + "extension", + "history" + ], + "repository": { + "type": "git", + "url": "https://github.com/andrewcourtice/harlem.git", + "directory": "extensions/history" + }, + "bugs": { + "url": "https://github.com/andrewcourtice/harlem/issues" + }, + "scripts": { + "dev": "tsup --watch src", + "build": "tsup", + "prepublish": "yarn build" + }, + "peerDependencies": { + "@harlem/core": "^1.1.2" + }, + "dependencies": { + "@harlem/extension-trace": "^1.3.2", + "@harlem/utilities": "^1.3.2" + }, + "devDependencies": { + "@harlem/core": "^1.3.2", + "@harlem/testing": "^1.3.2" + } +} \ No newline at end of file diff --git a/extensions/history/src/constants.ts b/extensions/history/src/constants.ts new file mode 100644 index 00000000..8def99a5 --- /dev/null +++ b/extensions/history/src/constants.ts @@ -0,0 +1,15 @@ +import type { + CommandType, + CommandTasks, +} from './types'; + +export const COMMAND_MAP = { + exec: { + set: (target, prop, newValue) => target[prop] = newValue, + deleteProperty: (target, prop) => delete target[prop], + }, + undo: { + set: (target, prop, newValue, oldValue) => target[prop] = oldValue, + deleteProperty: (target, prop, newValue, oldValue) => target[prop] = oldValue, + }, +} as Record>; \ No newline at end of file diff --git a/extensions/history/src/index.ts b/extensions/history/src/index.ts new file mode 100644 index 00000000..34ff067a --- /dev/null +++ b/extensions/history/src/index.ts @@ -0,0 +1,145 @@ +import { + COMMAND_MAP, +} from './constants'; + +import { + EventPayload, + EVENTS, + MutationEventData, + InternalStore, + BaseState, +} from '@harlem/core'; + +import traceExtension, { + TraceResult, +} from '@harlem/extension-trace'; + +import { + fromPath, +} from '@harlem/utilities'; + +import type { + CommandType, + HistoryCommand, + MutationPayload, + Options, +} from './types'; + +export * from './types'; + +export default function historyExtension(options?: Partial) { + const { + max, + mutations, + } = { + max: 50, + mutations: [], + ...options, + } as Options; + + const mutationLookup = new Map(mutations.map(({ name, description }) => [name, description])); + const createTraceExtension = traceExtension({ + autoStart: true, + }); + + return (store: InternalStore) => { + const { + startTrace, + stopTrace, + onTraceResult, + } = createTraceExtension(store); + + let position = 0; + let commands = [] as HistoryCommand[]; + let results = [] as TraceResult[]; + + const mutation = store.mutation('$history', (state, { type, command }: MutationPayload) => { + const tasks = COMMAND_MAP[type]; + + const { + results, + } = command; + + results.forEach(({ gate, nodes, prop, newValue, oldValue }) => { + const target = fromPath(state, nodes); + + if (target && prop) { + tasks[gate]?.(target, prop, newValue, oldValue); + } + }); + }); + + function processResults(name: string) { + if (results.length === 0) { + return; + } + + if (commands.length >= max) { + commands.shift(); + } + + commands.push({ + name, + results: Array.from(results), + }); + + results = []; + position = commands.length - 1; + } + + store.on(EVENTS.mutation.before, (event?: EventPayload) => { + if (!event || event.data.mutation.startsWith('$') || (mutationLookup.size > 0 && !mutationLookup.has(event.data.mutation))) { + return; + } + + startTrace([ + 'set', + 'deleteProperty', + ]); + + const listener = onTraceResult(result => results.push(result)); + + store.once(EVENTS.mutation.after, () => { + stopTrace(); + processResults(event.data.mutation); + + listener.dispose(); + }); + }); + + function run(type: CommandType, offset: number) { + const command = commands[position]; + + if (!command) { + return; + } + + mutation({ + type, + command, + }); + + position = Math.max(0, Math.min(commands.length - 1, position + offset)); + } + + function undo() { + run('undo', -1); + } + + function redo() { + run('exec', 1); + } + + function clearHistory() { + position = 0; + commands = []; + results = []; + } + + return { + undo, + redo, + clearHistory, + }; + }; +} \ No newline at end of file diff --git a/extensions/history/src/types.ts b/extensions/history/src/types.ts new file mode 100644 index 00000000..7664f129 --- /dev/null +++ b/extensions/history/src/types.ts @@ -0,0 +1,28 @@ +import { + TraceGate, + TraceResult, +} from '@harlem/extension-trace'; + +export type CommandType = 'exec' | 'undo'; +export type CommandTask = (target: any, prop: PropertyKey, newValue: unknown, oldValue: unknown) => void; +export type CommandTasks = Record, CommandTask>; + +export interface MutationPayload { + type: CommandType; + command: HistoryCommand; +} + +export interface HistoryCommand { + name: string; + results: TraceResult[]; +} + +export interface HistoryMutation { + name: string; + description?: string; +} + +export interface Options { + max: number; + mutations: HistoryMutation[]; +} \ No newline at end of file diff --git a/extensions/history/test/history.test.ts b/extensions/history/test/history.test.ts new file mode 100644 index 00000000..066db346 --- /dev/null +++ b/extensions/history/test/history.test.ts @@ -0,0 +1,47 @@ +import { + getStore, + bootstrap, +} from '@harlem/testing'; + +import historyExtension from '../src'; + +describe('History Extension', () => { + + const getInstance = () => getStore({ + extensions: [ + historyExtension(), + ], + }); + + let instance = getInstance(); + + beforeAll(() => bootstrap()); + beforeEach(() => instance = getInstance()); + afterEach(() => instance.store.destroy()); + + test('Performs an undo/redo operation', () => { + const { + store, + setUserID, + setUserDetails, + } = instance; + + const { + state, + undo, + redo, + } = store; + + setUserID(5); + setUserDetails({ + firstName: 'John', + }); + + expect(state.details.firstName).toBe('John'); + undo(); + expect(state.details.firstName).toBe(''); + // redo(); + // expect(state.details.firstName).toBe('John'); + }); + +}); \ No newline at end of file diff --git a/extensions/history/tsconfig.json b/extensions/history/tsconfig.json new file mode 100644 index 00000000..7d5ec66e --- /dev/null +++ b/extensions/history/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "include": [ + "src/**/*.ts", + "../../global.d.ts" + ] +} \ No newline at end of file diff --git a/extensions/history/tsup.config.ts b/extensions/history/tsup.config.ts new file mode 100644 index 00000000..cd5365aa --- /dev/null +++ b/extensions/history/tsup.config.ts @@ -0,0 +1,10 @@ +import base from '../../tsup.config'; + +import type { + Options, +} from 'tsup'; + +export default { + ...base, + globalName: 'HarlemHistoryExtension', +} as Options; \ No newline at end of file