Skip to content

Commit

Permalink
feat(compose): started a basic compose extension
Browse files Browse the repository at this point in the history
  • Loading branch information
andrewcourtice committed Nov 5, 2021
1 parent 5a9ca86 commit aecf3e1
Show file tree
Hide file tree
Showing 10 changed files with 340 additions and 1 deletion.
83 changes: 83 additions & 0 deletions extensions/compose/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Harlem Storage Extension

![npm](https://img.shields.io/npm/v/@harlem/extension-storage)

This is the official storage extension for Harlem. The storage extension adds the ability to sync store state to/from `localStorage` or `sessionStorage`.

## Getting Started

Follow the steps below to get started using the storage extension.

### Installation

Before installing this extension make sure you have installed `@harlem/core`.

```bash
yarn add @harlem/extension-storage
# or
npm install @harlem/extension-storage
```

### Registration

To get started simply register this extension with the store you wish to extend.

```typescript
import storageExtension from '@harlem/extension-storage';

import {
createStore
} from '@harlem/core';

const STATE = {
firstName: 'Jane',
lastName: 'Smith'
};

const {
state,
getter,
mutation,
startStorageSync,
stopStorageSync,
clearStorage
} = createStore('example', STATE, {
extensions: [
storageExtension({
type: 'local',
prefix: 'harlem',
sync: true,
exclude: [],
serialiser: state => JSON.stringify(state),
parser: value => JSON.parse(value)
})
]
});
```

The storage extension adds several new methods to the store instance (highlighted above).


## Usage

### Options
The storage extension method accepts an options object with the following properties:
- **type**: `string` - The type of storage interface to use. Acceptable values are `local` or `session`. Default value is `local`.
- **prefix**: `string` - The prefix to use on the storage key. The storage value will be in the form `${prefix}:${storeName}`. Default value is `harlem`.
- **sync**: `boolean` - Whether to automatically sync changes from the storage interface back to the store. Default value is `true`.
- **exclude**: `string[]` - A list of mutation names to exclude from triggering a storage sync event.
- **serialiser**: `unknown => string` - A function to serialise the store to string. The default behaviour is `JSON.stringify`.
- **parser**: `string => unknown` - A function to serialise the storage string to a state structure. The default behaviour is `JSON.parse`.

### Manually starting/stopping sync
The `startStorageSync` and `stopStorageSync` methods can be used to start or stop sync behaviour.


### Clearing storage
Use the `clearStorage` method to clear all stored data relating to this store.


## Considerations
Please keep the following points in mind when using this extension:

- The default behaviour for serialising/parsing only supports JSON-compatible types. For non-JSON-compatible types please specify a custom serialiser/parser. See [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#description) for a list of JSON-compatible types.
53 changes: 53 additions & 0 deletions extensions/compose/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
{
"name": "@harlem/extension-compose",
"amdName": "harlemCompose",
"version": "2.1.0",
"license": "MIT",
"author": "Andrew Courtice <[email protected]>",
"description": "The official compose extension for Harlem",
"homepage": "https://harlemjs.com",
"source": "src/index.ts",
"main": "dist/index.js",
"module": "dist/esm/index.js",
"unpkg": "dist/iife/index.js",
"jsdelivr": "dist/iife/index.js",
"types": "dist/index.d.ts",
"private": true,
"exports": {
".": {
"import": "./dist/esm/index.js",
"require": "./dist/index.js"
}
},
"keywords": [
"vue",
"state",
"harlem",
"extension",
"compose"
],
"repository": {
"type": "git",
"url": "https://github.com/andrewcourtice/harlem.git",
"directory": "extensions/compose"
},
"bugs": {
"url": "https://github.com/andrewcourtice/harlem/issues"
},
"scripts": {
"dev": "tsup --watch src",
"build": "tsup",
"prepublish": "yarn build"
},
"dependencies": {
"@harlem/utilities": "^2.1.0"
},
"peerDependencies": {
"@harlem/core": "^2.0.0",
"vue": "^3.2.0"
},
"devDependencies": {
"@harlem/core": "^2.1.0",
"vue": "^3.2.0"
}
}
1 change: 1 addition & 0 deletions extensions/compose/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const SENDER = 'extension:compose';
99 changes: 99 additions & 0 deletions extensions/compose/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {
SENDER,
} from './constants';

import {
computed, DeepReadonly,
} from 'vue';

import {
BaseState,
InternalStore,
} from '@harlem/core';

import {
fromPath,
} from '@harlem/utilities';

import type {
Accessor,
Getter,
Setter,
} from './types';

export * from './types';

function traceObjectPath<TValue extends object>(onAccess: (key: PropertyKey) => void): TValue {
return new Proxy({} as TValue, {
get(target, key) {
onAccess(key);
return traceObjectPath(onAccess);
},
});
}

function getTraceObject<TValue extends object>() {
const nodes = new Set<PropertyKey>();
const value = traceObjectPath<TValue>(key => nodes.add(key));
const getNodes = () => Array.from(nodes);
const resetNodes = () => nodes.clear();

return {
value,
getNodes,
resetNodes,
};
}

export default function composeExtension<TState extends BaseState>() {

return (store: InternalStore<TState>) => {
const {
value,
getNodes,
resetNodes,
} = getTraceObject<TState>();

function useState<TValue>(accessor: Accessor<TState, TValue>, mutationName?: string): [Getter<TValue>, Setter<TValue>] {
accessor(value);

const nodes = getNodes();
const key = nodes.pop();
const name = mutationName || `compose:${String(key)}`;
const parent = (state: object) => fromPath(state, nodes) as Record<PropertyKey, unknown>;

resetNodes();

if (!key) {
throw new Error('A valid property must be used');
}

const getter = () => parent(store.state)[key] as DeepReadonly<TValue>;
const setter = (value: TValue) => store.write(name, SENDER, state => {
parent(state)[key] = value;
});

return [
getter,
setter,
];
}

function computeState<TValue>(accessor: Accessor<TState, TValue>, mutationName?: string) {
const [
getter,
setter,
] = useState(accessor, mutationName);

return computed({
get: () => getter() as TValue,
set: value => setter(value),
});
}

return {
useState,
computeState,
};
};
}
5 changes: 5 additions & 0 deletions extensions/compose/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { DeepReadonly } from '@vue/reactivity';

export type Accessor<TState, TValue> = (state: TState) => TValue;
export type Getter<TValue> = () => DeepReadonly<TValue>;
export type Setter<TValue> = (value: TValue) => void;
68 changes: 68 additions & 0 deletions extensions/compose/test/compose.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
bootstrap,
getStore,
} from '@harlem/testing';

import composeExtension from '../src';

describe('Compose Extension', () => {

beforeAll(() => bootstrap());

test('Use state with simple value', () => {
const {
store,
} = getStore({
extensions: [
composeExtension(),
],
});

const {
useState,
} = store;

const [
getFirstName,
setFirstName,
] = useState(state => state.details.firstName);

expect(getFirstName()).toBe('');
setFirstName('John');
expect(getFirstName()).toBe('John');
});

test('Use state with complex value', () => {
const {
store,
} = getStore({
extensions: [
composeExtension(),
],
});

const {
useState,
} = store;

const [
getDetails,
setDetails,
] = useState(state => state.details);

let details = getDetails();

expect(details.age).toBe(0);

setDetails({
firstName: 'Bill',
lastName: 'Smith',
age: 45,
});

details = getDetails();

expect(details.age).toBe(45);
});

});
7 changes: 7 additions & 0 deletions extensions/compose/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"include": [
"src/**/*.ts",
"../../global.d.ts"
]
}
10 changes: 10 additions & 0 deletions extensions/compose/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import base from '../../tsup.config';

import type {
Options,
} from 'tsup';

export default {
...base,
globalName: 'HarlemComposeExtension',
} as Options;
2 changes: 1 addition & 1 deletion packages/utilities/src/object/from-path.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import isArray from '../type/is-array';

export default function fromPath<TValue extends object>(value: TValue, path: string | PropertyKey[]): object | undefined {
export default function fromPath<TValue extends object>(value: TValue, path: string | PropertyKey[]): unknown {
const nodes = isArray(path) ? path : path.split('/');
return nodes.reduce((branch, node) => (branch as any)?.[node], value);
}
13 changes: 13 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -887,6 +887,19 @@ __metadata:
languageName: unknown
linkType: soft

"@harlem/extension-compose@workspace:extensions/compose":
version: 0.0.0-use.local
resolution: "@harlem/extension-compose@workspace:extensions/compose"
dependencies:
"@harlem/core": ^2.1.0
"@harlem/utilities": ^2.1.0
vue: ^3.2.0
peerDependencies:
"@harlem/core": ^2.0.0
vue: ^3.2.0
languageName: unknown
linkType: soft

"@harlem/extension-history@^2.1.0, @harlem/extension-history@workspace:extensions/history":
version: 0.0.0-use.local
resolution: "@harlem/extension-history@workspace:extensions/history"
Expand Down

0 comments on commit aecf3e1

Please sign in to comment.