Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,9 @@ Let's dive into a simple use case to see how Stimulus Store works. In this examp

```js
// controllers/stores/counter.js
import { Store } from "stimulus-store"; // Import the shared Store class
import { createStore } from "stimulus-store";

export const counterStore = new Store(0); // Initialize with an initial value of 0
export const counterStore = await createStore({ name: 'counterStore', initialValue: 0, type: Number });
```

```js
Expand All @@ -93,8 +93,10 @@ import { useStore } from "stimulus-store"
import { counterStore } from "./stores/counter";

export default class extends Controller {
static stores = [counterStore];

connect() {
useStore(this, [counterStore])
useStore(this)
}

increment() {
Expand All @@ -105,7 +107,7 @@ export default class extends Controller {
decrement() {
// set will also receive a callback
// and will only notify on condition
counterStore.set((value)=>value-1, { filter: (value)=>value == 0 })
counterStore.set((value) => value - 1, { filter: (value) => value == 0 })
}
}
```
Expand All @@ -118,9 +120,10 @@ import { counterStore } from "./stores/counter"; // Import the counterStore

export default class extends Controller {
static targets = ["message"];
static stores = [counterStore];

connect() {
useStore(this, [counterStore])
useStore(this)
}

display(value) {
Expand Down
59 changes: 59 additions & 0 deletions src/createStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* The createStore function is a factory function that creates and returns a new instance of the Store class.
* It takes an options object as a parameter, which should include the name, initialValue, and type for the new store.
*
* Here's a technical breakdown of how it works:
* - The function destructures the name, type, and initialValue properties from the options object.
* - It checks if initialValue is undefined. If it is, the function throws an error because a store must be initialized with a value.
* - It checks if name is not a string. If it isn't, the function throws an error because the store's name must be a string. This name is used to create a unique Symbol which serves as the identifier for the store.
* - It creates a new Symbol using the name and assigns it to symbolName.
* - It creates a new instance of the Store class, passing symbolName, initialValue, and type to the Store constructor, and returns this new instance.
*
* The Store class encapsulates the state (the initialValue) and provides methods to get and set that state. The type is used for type checking to ensure that the store's value always matches the expected type.
*
*
* @param {StoreOptions<T>} options - The options for the store.
* @param {string} options.name - The name of the store. This will be used to create a unique Symbol.
* @param {T} options.initialValue - The initial value of the store. This must be provided.
* @param {new (...args: unknown[]) => T} options.type - The type of the store's value. This is used for type checking.
*
* @returns {Store<T>} The new store.
*
* @throws {Error} If no initial value is provided.
* @throws {Error} If the name is not a string.
*
* @example
* const countStore = createStore({ name: 'count', initialValue: 0, type: Number });
* // Now you can use countStore in your components.
*/

import { Store } from './store';
import type { StoreOptions } from './storeOptions';
import { typeMap } from './storeValuesTypeMap';

export async function createStore<T>(options: StoreOptions<T>): Promise<Store<T>>{
const { name, type, initialValue } = options;
if (typeof initialValue === "undefined") {
throw new Error("Store must be initialized with a value");
} else if (typeof name !== "string") {
throw new Error("Store name must be of Type string");
}
const symbolName = Symbol(name);

const typeConstructor = typeMap[type.name];
if (typeof typeConstructor !== 'function') {
throw new Error(`Invalid type: ${type?.name}`);
}

const store: Store<T> = new Store<T>(symbolName, type);
try {
await store.set(initialValue);
return store;
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to create store: ${error.message}`);
} else {
throw new Error('An unknown error occurred while creating the store');
}
}
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { Store } from './store'
export { useStore } from './useStore'
export { createStore } from './createStore'
65 changes: 47 additions & 18 deletions src/store.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/**
* @template T The type of the value that the store holds.
* Store Class Explanation:
*
* The `Store` class is a versatile class for managing and subscribing to data updates in JavaScript applications.
Expand All @@ -24,58 +25,86 @@
*/

export class Store<T> {
private value: T;
name: symbol;
private value!: T;
private subscribers: Set<UpdateMethod>;
name: string;
private type: new (...args: unknown[]) => unknown;

constructor(name: string, initialValue: T) {
if (typeof initialValue === "undefined") {
throw new Error("Store must be initialized with a value");
} else if (typeof name !== "string") {
throw new Error("Store name must be of Type string");
}
/**
* Creates a new store.
*
* @param {symbol} name - The name of the store.
* @param {new (...args: unknown[]) => unknown} type - The type of the store's value.
*/
constructor(name: symbol, type: new (...args: unknown[]) => unknown) {
this.name = name;
this.value = initialValue;
this.subscribers = new Set();
this.type = type;
}

/**
* Sets the value of the store and notifies subscribers.
*
* @param {T | CurrentValueCallback | Promise<T | CurrentValueCallback>} newValue - The new value.
* @param {SetOptions} [options={ filter: () => true }] - The options for setting the value.
*/
async set(newValue: T | CurrentValueCallback | Promise<T | CurrentValueCallback>, options: SetOptions = { filter: () => true }) {
// Consider enriching the store value with some kind of typing similar to Stimulus values typing.
// This would provide type safety and autocompletion benefits when working with the store value.
// It would also make the code more self-documenting, as the types would provide information about what kind of values are expected.
// This could be achieved by using TypeScript generics or by defining specific types for different kinds of store values.
if (newValue instanceof Promise) return this.resolvePromise(newValue, options);
if (newValue === this.get()) return;
this.value = typeof newValue === "function" ? (newValue as CurrentValueCallback)(this.value) : newValue;
const finalValue: T = typeof newValue === "function" ? (newValue as CurrentValueCallback)(this.get()) : newValue;
if (Object.getPrototypeOf(finalValue).constructor !== this.type) {
throw new Error(`Value '${finalValue}' must be of type ${this.type.name}`);
}
this.setValue(finalValue);
this.notifySubscribers(options);
}

/**
* Gets the current value of the store.
*
* @returns {T} The current value.
*/
get(): T {
return this.value;
}

private setValue(value: T) {
this.value = value;
}

/**
* Subscribes to the store.
*
* @param {UpdateMethod} callback - The function to call when the store's value changes.
* @returns {UnsubscribeFunction} A function that unsubscribes the callback.
*/
subscribe(callback: UpdateMethod): UnsubscribeFunction {
this.subscribers.add(callback);
callback(this.value); // Immediate call for initial value
callback(this.get()); // Immediate call for initial value
return () => this.unsubscribe(callback); // Return an unsubscribe function
}

/**
* Unsubscribes from the store.
*
* @param {UpdateMethod} callback - The function to unsubscribe.
*/
unsubscribe(callback: UpdateMethod) {
this.subscribers.delete(callback);
}

private notifySubscribers(options: NotifySubscriberOptions) {
Array.from(this.subscribers)
.filter(() => options.filter(this.value))
.forEach(callback => callback(this.value))
.filter(() => options.filter(this.get()))
.forEach(callback => callback(this.get()))
}

private async resolvePromise(newValue: Promise<T | CurrentValueCallback>, options: SetOptions) {
try {
const resolvedValue = await newValue;
this.set(resolvedValue, options);
} catch (error) {
console.error('Failed to resolve promise:', error);
throw new Error('Failed to resolve promise:\n' + error);
}
}
}
File renamed without changes.
7 changes: 7 additions & 0 deletions src/storeOptions.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { TypeKey } from './storeValuesTypeMap';

export interface StoreOptions<T> {
name: string;
type: TypeKey;
initialValue: T;
}
7 changes: 7 additions & 0 deletions src/storeValuesTypeMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const typeMap: Record<string, new (...args: unknown[]) => unknown> = {
'String': String,
'Number': Number,
'Array': Array,
'Object': Object,
'Boolean': Boolean
};
9 changes: 5 additions & 4 deletions src/useStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,17 +47,18 @@ export function useStore<T>(controller: StoreController<T>) {
const unsubscribeFunctions: (() => void)[] = [];

stores.forEach((store) => {
const storeName: string = store.name;
const camelizedName = camelize(storeName);
const onStoreUpdateMethodName = `on${camelize(storeName, true)}Update`;
const storeName: symbol = store.name;
const storeNameAsString: string = storeName.toString().slice(7, -1);
const camelizedName = camelize(storeNameAsString);
const onStoreUpdateMethodName = `on${camelize(storeNameAsString, true)}Update`;
const onStoreUpdateMethod = controller[onStoreUpdateMethodName] as (value: T) => void;

if (onStoreUpdateMethod) {
const updateMethod: (value: T) => void = value => {
onStoreUpdateMethod.call(controller, value);
};

const methodName = `update${camelize(storeName, true)}`;
const methodName = `update${camelize(storeNameAsString, true)}`;
controller[methodName] = updateMethod;

unsubscribeFunctions.push(store.subscribe(updateMethod));
Expand Down
75 changes: 75 additions & 0 deletions test/createStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { createStore } from '../src/createStore';

describe('createStore', () => {
it('should throw an error if no initial value is provided', async () => {
await expect(createStore({ name: 'testStore', type: Number } as any))
.rejects.toThrow('Store must be initialized with a value');
});

it('should throw an error if no initial name is provided', async () => {
await expect(createStore({ initialValue: 0, type: Number } as any))
.rejects.toThrow('Store name must be of Type string');
});

it('should create a new store with the provided name, initial value, and type', async () => {
const store = await createStore({ name: 'testStore', initialValue: 0, type: Number });
expect(store.get()).toBe(0);
expect(typeof store.get()).toBe('number');
expect(store.name.toString()).toBe('Symbol(testStore)');
});

it('should throw an error if an invalid type is provided', async () => {
await expect(createStore({ name: 'testStore', type: Set, initialValue: 0 } as any))
.rejects.toThrow('Invalid type: Set');
});

it('should create a store with a number type', async () => {
const store = await createStore({ name: 'testStore', initialValue: 0, type: Number });
expect(typeof store.get()).toBe('number');
});

it('should create a store with a string type', async () => {
const store = await createStore({ name: 'testStore', initialValue: 'test', type: String });
expect(typeof store.get()).toBe('string');
});

it('should create a store with a boolean type', async () => {
const store = await createStore({ name: 'testStore', initialValue: true, type: Boolean });
expect(typeof store.get()).toBe('boolean');
});

it('should create a store with an array type', async () => {
const store = await createStore({ name: 'testStore', initialValue: [1, 2, 3], type: Array });
expect(Array.isArray(store.get())).toBe(true);
});

it('should create a store with an object type', async () => {
const store = await createStore({ name: 'testStore', initialValue: { key: 'value' }, type: Object });
expect(typeof store.get()).toBe('object');
});

it('should throw an error when createStore is called with an initialValue that does not match the number type', async () => {
await expect(createStore({ name: 'testStore', initialValue: 'not a number', type: Number }))
.rejects.toThrow(`Failed to create store: Value 'not a number' must be of type Number`);
});

it('should throw an error when createStore is called with an initialValue that does not match the string type', async () => {
await expect(createStore({ name: 'testStore', initialValue: 123, type: String }))
.rejects.toThrow(`Failed to create store: Value '123' must be of type String`);
});

it('should throw an error when createStore is called with an initialValue that does not match the boolean type', async () => {
await expect(createStore({ name: 'testStore', initialValue: 'not a boolean', type: Boolean }))
.rejects.toThrow(`Failed to create store: Value 'not a boolean' must be of type Boolean`);
});

it('should throw an error when createStore is called with an initialValue that does not match the array type', async () => {
await expect(createStore({ name: 'testStore', initialValue: 'not an array', type: Array }))
.rejects.toThrow(`Failed to create store: Value 'not an array' must be of type Array`);
});

it('should throw an error when createStore is called with an initialValue that does not match the object type', async () => {
await expect(createStore({ name: 'testStore', initialValue: 'not an object', type: Object }))
.rejects.toThrow(`Failed to create store: Value 'not an object' must be of type Object`);
});
});
Loading