mirror of
https://github.com/robonen/tools.git
synced 2026-03-20 02:44:45 +00:00
feat(web/vue): add useStorage and useStorageAsync, separate all composables by categories
This commit is contained in:
6
web/vue/src/composables/state/index.ts
Normal file
6
web/vue/src/composables/state/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './useAppSharedState';
|
||||
export * from './useAsyncState';
|
||||
export * from './useContextFactory';
|
||||
export * from './useCounter';
|
||||
export * from './useInjectionStore';
|
||||
export * from './useToggle';
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, vi, expect } from 'vitest';
|
||||
import { ref, reactive } from 'vue';
|
||||
import { useAppSharedState } from '.';
|
||||
|
||||
describe('useAppSharedState', () => {
|
||||
it('initialize state only once', () => {
|
||||
const stateFactory = (initValue?: number) => {
|
||||
const count = ref(initValue ?? 0);
|
||||
return { count };
|
||||
};
|
||||
|
||||
const useSharedState = useAppSharedState(stateFactory);
|
||||
|
||||
const state1 = useSharedState(1);
|
||||
const state2 = useSharedState(2);
|
||||
|
||||
expect(state1.count.value).toBe(1);
|
||||
expect(state2.count.value).toBe(1);
|
||||
expect(state1).toBe(state2);
|
||||
});
|
||||
|
||||
it('return the same state object across different calls', () => {
|
||||
const stateFactory = () => {
|
||||
const state = reactive({ count: 0 });
|
||||
const increment = () => state.count++;
|
||||
return { state, increment };
|
||||
};
|
||||
|
||||
const useSharedState = useAppSharedState(stateFactory);
|
||||
|
||||
const sharedState1 = useSharedState();
|
||||
const sharedState2 = useSharedState();
|
||||
|
||||
expect(sharedState1.state.count).toBe(0);
|
||||
sharedState1.increment();
|
||||
expect(sharedState1.state.count).toBe(1);
|
||||
expect(sharedState2.state.count).toBe(1);
|
||||
expect(sharedState1).toBe(sharedState2);
|
||||
});
|
||||
});
|
||||
42
web/vue/src/composables/state/useAppSharedState/index.ts
Normal file
42
web/vue/src/composables/state/useAppSharedState/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { AnyFunction } from '@robonen/stdlib';
|
||||
import { effectScope } from 'vue';
|
||||
|
||||
// TODO: maybe we should control subscriptions and dispose them when the child scope is disposed
|
||||
|
||||
/**
|
||||
* @name useAppSharedState
|
||||
* @category State
|
||||
* @description Provides a shared state object for use across Vue instances
|
||||
*
|
||||
* @param {Function} stateFactory A factory function that returns the shared state object
|
||||
* @returns {Function} A function that returns the shared state object
|
||||
*
|
||||
* @example
|
||||
* const useSharedState = useAppSharedState((initValue?: number) => {
|
||||
* const count = ref(initValue ?? 0);
|
||||
* return { count };
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* const useSharedState = useAppSharedState(() => {
|
||||
* const state = reactive({ count: 0 });
|
||||
* const increment = () => state.count++;
|
||||
* return { state, increment };
|
||||
* });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useAppSharedState<Fn extends AnyFunction>(stateFactory: Fn) {
|
||||
let initialized = false;
|
||||
let state: ReturnType<Fn>;
|
||||
const scope = effectScope(true);
|
||||
|
||||
return ((...args: Parameters<Fn>) => {
|
||||
if (!initialized) {
|
||||
state = scope.run(() => stateFactory(...args));
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
return state;
|
||||
});
|
||||
}
|
||||
209
web/vue/src/composables/state/useAsyncState/index.test.ts
Normal file
209
web/vue/src/composables/state/useAsyncState/index.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { isShallow, nextTick, ref } from 'vue';
|
||||
import { it, expect, describe, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { useAsyncState } from '.';
|
||||
|
||||
describe('useAsyncState', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('works with a promise', async () => {
|
||||
const { state, isReady, isLoading, error } = useAsyncState(
|
||||
Promise.resolve('data'),
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
expect(isReady.value).toBe(false);
|
||||
expect(isLoading.value).toBe(true);
|
||||
expect(error.value).toBe(null);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('data');
|
||||
expect(isReady.value).toBe(true);
|
||||
expect(isLoading.value).toBe(false);
|
||||
expect(error.value).toBe(null);
|
||||
});
|
||||
|
||||
it('works with a function returning a promise', async () => {
|
||||
const { state, isReady, isLoading, error } = useAsyncState(
|
||||
() => Promise.resolve('data'),
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
expect(isReady.value).toBe(false);
|
||||
expect(isLoading.value).toBe(true);
|
||||
expect(error.value).toBe(null);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('data');
|
||||
expect(isReady.value).toBe(true);
|
||||
expect(isLoading.value).toBe(false);
|
||||
expect(error.value).toBe(null);
|
||||
});
|
||||
|
||||
it('handles errors', async () => {
|
||||
const { state, isReady, isLoading, error } = useAsyncState(
|
||||
Promise.reject(new Error('test-error')),
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
expect(isReady.value).toBe(false);
|
||||
expect(isLoading.value).toBe(true);
|
||||
expect(error.value).toBe(null);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
expect(isReady.value).toBe(false);
|
||||
expect(isLoading.value).toBe(false);
|
||||
expect(error.value).toEqual(new Error('test-error'));
|
||||
});
|
||||
|
||||
it('calls onSuccess callback', async () => {
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
useAsyncState(
|
||||
Promise.resolve('data'),
|
||||
'initial',
|
||||
{ onSuccess },
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith('data');
|
||||
});
|
||||
|
||||
it('calls onError callback', async () => {
|
||||
const onError = vi.fn();
|
||||
const error = new Error('test-error');
|
||||
|
||||
useAsyncState(
|
||||
Promise.reject(error),
|
||||
'initial',
|
||||
{ onError },
|
||||
);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(onError).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it('throws error if throwError is true', async () => {
|
||||
const error = new Error('test-error');
|
||||
|
||||
const { executeImmediately } = useAsyncState(
|
||||
Promise.reject(error),
|
||||
'initial',
|
||||
{ immediate: false, throwError: true },
|
||||
);
|
||||
|
||||
await expect(() => executeImmediately()).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
it('resets state on execute if resetOnExecute is true', async () => {
|
||||
const { state, executeImmediately } = useAsyncState(
|
||||
(data: string) => Promise.resolve(data),
|
||||
'initial',
|
||||
{ immediate: false, resetOnExecute: true },
|
||||
);
|
||||
|
||||
await executeImmediately('new data');
|
||||
expect(state.value).toBe('new data');
|
||||
|
||||
executeImmediately('another data');
|
||||
expect(state.value).toBe('initial');
|
||||
});
|
||||
|
||||
it('delays execution with default delay', async () => {
|
||||
const { isLoading, execute } = useAsyncState(
|
||||
() => Promise.resolve('data'),
|
||||
'initial',
|
||||
{ delay: 100, immediate: false },
|
||||
);
|
||||
|
||||
const promise = execute();
|
||||
expect(isLoading.value).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(isLoading.value).toBe(true);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
await promise;
|
||||
expect(isLoading.value).toBe(false);
|
||||
});
|
||||
|
||||
it('is awaitable', async () => {
|
||||
const { state } = await useAsyncState(
|
||||
Promise.resolve('data'),
|
||||
'initial',
|
||||
);
|
||||
|
||||
expect(state.value).toBe('data');
|
||||
});
|
||||
|
||||
it('works with executeImmediately', async () => {
|
||||
const { state, isReady, isLoading, error, executeImmediately } = useAsyncState(
|
||||
() => Promise.resolve('data'),
|
||||
'initial',
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
executeImmediately();
|
||||
|
||||
expect(state.value).toBe('initial');
|
||||
expect(isLoading.value).toBe(true);
|
||||
expect(isReady.value).toBe(false);
|
||||
expect(error.value).toBe(null);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(state.value).toBe('data');
|
||||
expect(isReady.value).toBe(true);
|
||||
expect(isLoading.value).toBe(false);
|
||||
expect(error.value).toBe(null);
|
||||
});
|
||||
|
||||
it('passes params to the function', async () => {
|
||||
const promiseFn = vi.fn((...args: any[]) => Promise.resolve(args.join(' ')));
|
||||
|
||||
const { executeImmediately } = useAsyncState(
|
||||
promiseFn,
|
||||
'initial',
|
||||
{ immediate: false },
|
||||
);
|
||||
|
||||
await executeImmediately('hello', 'world');
|
||||
|
||||
expect(promiseFn).toHaveBeenCalledWith('hello', 'world');
|
||||
});
|
||||
|
||||
it('uses shallowRef by default', async () => {
|
||||
const { state } = await useAsyncState(
|
||||
Promise.resolve({ a: 1 }),
|
||||
{ a: 0 },
|
||||
);
|
||||
|
||||
expect(state.value.a).toBe(1);
|
||||
expect(isShallow(state)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses ref when shallow is false', async () => {
|
||||
const { state } = await useAsyncState(
|
||||
Promise.resolve({ a: ref(1) }),
|
||||
{ a: ref(0) },
|
||||
{ shallow: false },
|
||||
);
|
||||
|
||||
expect(state.value.a).toBe(1);
|
||||
expect(isShallow(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
124
web/vue/src/composables/state/useAsyncState/index.ts
Normal file
124
web/vue/src/composables/state/useAsyncState/index.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { ref, shallowRef, watch, type Ref, type ShallowRef, type UnwrapRef } from 'vue';
|
||||
import { isFunction, sleep } from '@robonen/stdlib';
|
||||
|
||||
export interface UseAsyncStateOptions<Shallow extends boolean, Data = any> {
|
||||
delay?: number;
|
||||
shallow?: Shallow;
|
||||
immediate?: boolean;
|
||||
resetOnExecute?: boolean;
|
||||
throwError?: boolean;
|
||||
onError?: (error: unknown) => void;
|
||||
onSuccess?: (data: Data) => void;
|
||||
}
|
||||
|
||||
export interface UseAsyncStateReturnBase<Data, Params extends any[], Shallow extends boolean> {
|
||||
state: Shallow extends true ? ShallowRef<Data> : Ref<UnwrapRef<Data>>;
|
||||
isLoading: Ref<boolean>;
|
||||
isReady: Ref<boolean>;
|
||||
error: Ref<unknown | null>;
|
||||
execute: (delay?: number, ...params: Params) => Promise<Data>;
|
||||
executeImmediately: (...params: Params) => Promise<Data>;
|
||||
}
|
||||
|
||||
export type UseAsyncStateReturn<Data, Params extends any[], Shallow extends boolean> =
|
||||
& UseAsyncStateReturnBase<Data, Params, Shallow>
|
||||
& PromiseLike<UseAsyncStateReturnBase<Data, Params, Shallow>>;
|
||||
|
||||
/**
|
||||
* @name useAsyncState
|
||||
* @category State
|
||||
* @description A composable that provides a state for async operations without setup blocking
|
||||
*/
|
||||
export function useAsyncState<Data, Params extends any[] = [], Shallow extends boolean = true>(
|
||||
maybePromise: Promise<Data> | ((...args: Params) => Promise<Data>),
|
||||
initialState: Data,
|
||||
options?: UseAsyncStateOptions<Shallow, Data>,
|
||||
): UseAsyncStateReturn<Data, Params, Shallow> {
|
||||
const {
|
||||
delay = 0,
|
||||
shallow = true,
|
||||
immediate = true,
|
||||
resetOnExecute = false,
|
||||
throwError = false,
|
||||
onError,
|
||||
onSuccess,
|
||||
} = options ?? {};
|
||||
|
||||
const state = shallow ? shallowRef(initialState) : ref(initialState);
|
||||
const error = ref<unknown | null>(null);
|
||||
const isLoading = ref(false);
|
||||
const isReady = ref(false);
|
||||
|
||||
const execute = async (actualDelay = delay, ...params: any[]) => {
|
||||
if (resetOnExecute)
|
||||
state.value = initialState;
|
||||
|
||||
isLoading.value = true;
|
||||
isReady.value = false;
|
||||
error.value = null;
|
||||
|
||||
if (actualDelay > 0)
|
||||
await sleep(actualDelay);
|
||||
|
||||
const promise = isFunction(maybePromise) ? maybePromise(...params as Params) : maybePromise;
|
||||
|
||||
try {
|
||||
const data = await promise;
|
||||
state.value = data;
|
||||
isReady.value = true;
|
||||
onSuccess?.(data);
|
||||
}
|
||||
catch (e: unknown) {
|
||||
error.value = e;
|
||||
onError?.(e);
|
||||
|
||||
if (throwError)
|
||||
throw e;
|
||||
}
|
||||
finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
|
||||
return state.value as Data;
|
||||
};
|
||||
|
||||
const executeImmediately = (...params: Params) => {
|
||||
return execute(0, ...params);
|
||||
};
|
||||
|
||||
if (immediate)
|
||||
execute();
|
||||
|
||||
const shell = {
|
||||
state: state as Shallow extends true ? ShallowRef<Data> : Ref<UnwrapRef<Data>>,
|
||||
isLoading,
|
||||
isReady,
|
||||
error,
|
||||
execute,
|
||||
executeImmediately,
|
||||
};
|
||||
|
||||
function waitResolve() {
|
||||
return new Promise<UseAsyncStateReturnBase<Data, Params, Shallow>>((resolve, reject) => {
|
||||
watch(
|
||||
isLoading,
|
||||
(loading) => {
|
||||
if (loading === false)
|
||||
error.value ? reject(error.value) : resolve(shell);
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
once: true,
|
||||
flush: 'sync',
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...shell,
|
||||
then(onFulfilled, onRejected) {
|
||||
return waitResolve().then(onFulfilled, onRejected);
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { defineComponent } from 'vue';
|
||||
import { useContextFactory } from '.';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { VueToolsError } from '@/utils';
|
||||
|
||||
function testFactory<Data>(
|
||||
data: Data,
|
||||
context: ReturnType<typeof useContextFactory<Data>>,
|
||||
fallback?: Data,
|
||||
) {
|
||||
const { inject, provide } = context;
|
||||
|
||||
const Child = defineComponent({
|
||||
setup() {
|
||||
const value = inject(fallback);
|
||||
return { value };
|
||||
},
|
||||
template: `{{ value }}`,
|
||||
});
|
||||
|
||||
const Parent = defineComponent({
|
||||
components: { Child },
|
||||
setup() {
|
||||
provide(data);
|
||||
},
|
||||
template: `<Child />`,
|
||||
});
|
||||
|
||||
return {
|
||||
Parent,
|
||||
Child,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: maybe replace template with passing mock functions to setup
|
||||
|
||||
describe('useContextFactory', () => {
|
||||
it('provide and inject context correctly', () => {
|
||||
const { Parent } = testFactory('test', useContextFactory('TestContext'));
|
||||
|
||||
const component = mount(Parent);
|
||||
|
||||
expect(component.text()).toBe('test');
|
||||
});
|
||||
|
||||
it('throw an error when context is not provided', () => {
|
||||
const { Child } = testFactory('test', useContextFactory('TestContext'));
|
||||
|
||||
expect(() => mount(Child)).toThrow(VueToolsError);
|
||||
});
|
||||
|
||||
it('inject a fallback value when context is not provided', () => {
|
||||
const { Child } = testFactory('test', useContextFactory('TestContext'), 'fallback');
|
||||
|
||||
const component = mount(Child);
|
||||
|
||||
expect(component.text()).toBe('fallback');
|
||||
});
|
||||
|
||||
it('correctly handle null values', () => {
|
||||
const { Parent } = testFactory(null, useContextFactory('TestContext'));
|
||||
|
||||
const component = mount(Parent);
|
||||
|
||||
expect(component.text()).toBe('');
|
||||
});
|
||||
|
||||
it('provide context globally with app', () => {
|
||||
const context = useContextFactory('TestContext');
|
||||
const { Child } = testFactory(null, context);
|
||||
|
||||
const childComponent = mount(Child, {
|
||||
global: {
|
||||
plugins: [app => context.appProvide(app)('test')],
|
||||
},
|
||||
});
|
||||
|
||||
expect(childComponent.text()).toBe('test');
|
||||
});
|
||||
});
|
||||
62
web/vue/src/composables/state/useContextFactory/index.ts
Normal file
62
web/vue/src/composables/state/useContextFactory/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { inject as vueInject, provide as vueProvide, type InjectionKey, type App } from 'vue';
|
||||
import { VueToolsError } from '@/utils';
|
||||
|
||||
/**
|
||||
* @name useContextFactory
|
||||
* @category State
|
||||
* @description A composable that provides a factory for creating context with unique key
|
||||
*
|
||||
* @param {string} name The name of the context
|
||||
* @returns {Object} An object with `inject`, `provide`, `appProvide` and `key` properties
|
||||
* @throws {VueToolsError} when the context is not provided
|
||||
*
|
||||
* @example
|
||||
* const { inject, provide } = useContextFactory('MyContext');
|
||||
*
|
||||
* provide('Hello World');
|
||||
* const value = inject();
|
||||
*
|
||||
* @example
|
||||
* const { inject: injectContext, appProvide } = useContextFactory('MyContext');
|
||||
*
|
||||
* // In a plugin
|
||||
* {
|
||||
* install(app) {
|
||||
* appProvide(app)('Hello World');
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // In a component
|
||||
* const value = injectContext();
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useContextFactory<ContextValue>(name: string) {
|
||||
const injectionKey: InjectionKey<ContextValue> = Symbol(name);
|
||||
|
||||
const inject = <Fallback extends ContextValue = ContextValue>(fallback?: Fallback) => {
|
||||
const context = vueInject(injectionKey, fallback);
|
||||
|
||||
if (context !== undefined)
|
||||
return context;
|
||||
|
||||
throw new VueToolsError(`useContextFactory: '${name}' context is not provided`);
|
||||
};
|
||||
|
||||
const provide = (context: ContextValue) => {
|
||||
vueProvide(injectionKey, context);
|
||||
return context;
|
||||
};
|
||||
|
||||
const appProvide = (app: App) => (context: ContextValue) => {
|
||||
app.provide(injectionKey, context);
|
||||
return context;
|
||||
};
|
||||
|
||||
return {
|
||||
inject,
|
||||
provide,
|
||||
appProvide,
|
||||
key: injectionKey,
|
||||
}
|
||||
}
|
||||
8
web/vue/src/composables/state/useCounter/demo.vue
Normal file
8
web/vue/src/composables/state/useCounter/demo.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
</div>
|
||||
</template>
|
||||
81
web/vue/src/composables/state/useCounter/index.test.ts
Normal file
81
web/vue/src/composables/state/useCounter/index.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { it, expect, describe } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useCounter } from '.';
|
||||
|
||||
describe('useCounter', () => {
|
||||
it('initialize count with the provided initial value', () => {
|
||||
const { count } = useCounter(5);
|
||||
expect(count.value).toBe(5);
|
||||
});
|
||||
|
||||
it('initialize count with the provided initial value from a ref', () => {
|
||||
const { count } = useCounter(ref(5));
|
||||
expect(count.value).toBe(5);
|
||||
});
|
||||
|
||||
it('initialize count with the provided initial value from a getter', () => {
|
||||
const { count } = useCounter(() => 5);
|
||||
expect(count.value).toBe(5);
|
||||
});
|
||||
|
||||
it('increment count by 1 by default', () => {
|
||||
const { count, increment } = useCounter(0);
|
||||
increment();
|
||||
expect(count.value).toBe(1);
|
||||
});
|
||||
|
||||
it('increment count by the specified delta', () => {
|
||||
const { count, increment } = useCounter(0);
|
||||
increment(5);
|
||||
expect(count.value).toBe(5);
|
||||
});
|
||||
|
||||
it('decrement count by 1 by default', () => {
|
||||
const { count, decrement } = useCounter(5);
|
||||
decrement();
|
||||
expect(count.value).toBe(4);
|
||||
});
|
||||
|
||||
it('decrement count by the specified delta', () => {
|
||||
const { count, decrement } = useCounter(10);
|
||||
decrement(5);
|
||||
expect(count.value).toBe(5);
|
||||
});
|
||||
|
||||
it('set count to the specified value', () => {
|
||||
const { count, set } = useCounter(0);
|
||||
set(10);
|
||||
expect(count.value).toBe(10);
|
||||
});
|
||||
|
||||
it('get the current count value', () => {
|
||||
const { get } = useCounter(5);
|
||||
expect(get()).toBe(5);
|
||||
});
|
||||
|
||||
it('reset count to the initial value', () => {
|
||||
const { count, reset } = useCounter(10);
|
||||
count.value = 5;
|
||||
reset();
|
||||
expect(count.value).toBe(10);
|
||||
});
|
||||
|
||||
it('reset count to the specified value', () => {
|
||||
const { count, reset } = useCounter(10);
|
||||
count.value = 5;
|
||||
reset(20);
|
||||
expect(count.value).toBe(20);
|
||||
});
|
||||
|
||||
it('clamp count to the minimum value', () => {
|
||||
const { count, decrement } = useCounter(Number.MIN_SAFE_INTEGER);
|
||||
decrement();
|
||||
expect(count.value).toBe(Number.MIN_SAFE_INTEGER);
|
||||
});
|
||||
|
||||
it('clamp count to the maximum value', () => {
|
||||
const { count, increment } = useCounter(Number.MAX_SAFE_INTEGER);
|
||||
increment();
|
||||
expect(count.value).toBe(Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
});
|
||||
73
web/vue/src/composables/state/useCounter/index.ts
Normal file
73
web/vue/src/composables/state/useCounter/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ref, toValue, type MaybeRefOrGetter, type Ref } from 'vue';
|
||||
import { clamp } from '@robonen/stdlib';
|
||||
|
||||
export interface UseCounterOptions {
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface UseConterReturn {
|
||||
count: Ref<number>;
|
||||
increment: (delta?: number) => void;
|
||||
decrement: (delta?: number) => void;
|
||||
set: (value: number) => void;
|
||||
get: () => number;
|
||||
reset: (value?: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useCounter
|
||||
* @category State
|
||||
* @description A composable that provides a counter with increment, decrement, set, get, and reset functions
|
||||
*
|
||||
* @param {MaybeRef<number>} [initialValue=0] The initial value of the counter
|
||||
* @param {UseCounterOptions} [options={}] The options for the counter
|
||||
* @param {number} [options.min=Number.MIN_SAFE_INTEGER] The minimum value of the counter
|
||||
* @param {number} [options.max=Number.MAX_SAFE_INTEGER] The maximum value of the counter
|
||||
* @returns {UseConterReturn} The counter object
|
||||
*
|
||||
* @example
|
||||
* const { count, increment } = useCounter(0);
|
||||
*
|
||||
* @example
|
||||
* const { count, increment, decrement, set, get, reset } = useCounter(0, { min: 0, max: 10 });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useCounter(
|
||||
initialValue: MaybeRefOrGetter<number> = 0,
|
||||
options: UseCounterOptions = {},
|
||||
): UseConterReturn {
|
||||
let _initialValue = toValue(initialValue);
|
||||
const count = ref(_initialValue);
|
||||
|
||||
const {
|
||||
min = Number.MIN_SAFE_INTEGER,
|
||||
max = Number.MAX_SAFE_INTEGER,
|
||||
} = options;
|
||||
|
||||
const increment = (delta = 1) =>
|
||||
count.value = clamp(count.value + delta, min, max);
|
||||
|
||||
const decrement = (delta = 1) =>
|
||||
count.value = clamp(count.value - delta, min, max);
|
||||
|
||||
const set = (value: number) =>
|
||||
count.value = clamp(value, min, max);
|
||||
|
||||
const get = () => count.value;
|
||||
|
||||
const reset = (value = _initialValue) => {
|
||||
_initialValue = value;
|
||||
return set(value);
|
||||
};
|
||||
|
||||
return {
|
||||
count,
|
||||
increment,
|
||||
decrement,
|
||||
set,
|
||||
get,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { useInjectionStore } from '.';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
function testFactory<Args, Return>(
|
||||
store: ReturnType<typeof useInjectionStore<Args[], Return>>,
|
||||
) {
|
||||
const { useProvidingState, useInjectedState } = store;
|
||||
|
||||
const Child = defineComponent({
|
||||
setup() {
|
||||
const state = useInjectedState();
|
||||
return { state };
|
||||
},
|
||||
template: `{{ state }}`,
|
||||
});
|
||||
|
||||
const Parent = defineComponent({
|
||||
components: { Child },
|
||||
setup() {
|
||||
const state = useProvidingState();
|
||||
return { state };
|
||||
},
|
||||
template: `<Child />`,
|
||||
});
|
||||
|
||||
return {
|
||||
Parent,
|
||||
Child,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useInjectionState', () => {
|
||||
it('provides and injects state correctly', () => {
|
||||
const { Parent } = testFactory(
|
||||
useInjectionStore(() => ref('base'))
|
||||
);
|
||||
|
||||
const wrapper = mount(Parent);
|
||||
expect(wrapper.text()).toBe('base');
|
||||
});
|
||||
|
||||
it('injects default value when state is not provided', () => {
|
||||
const { Child } = testFactory(
|
||||
useInjectionStore(() => ref('without provider'), {
|
||||
defaultValue: ref('default'),
|
||||
injectionKey: 'testKey',
|
||||
})
|
||||
);
|
||||
|
||||
const wrapper = mount(Child);
|
||||
expect(wrapper.text()).toBe('default');
|
||||
});
|
||||
|
||||
it('provides state at app level', () => {
|
||||
const injectionStore = useInjectionStore(() => ref('app level'));
|
||||
const { Child } = testFactory(injectionStore);
|
||||
|
||||
const wrapper = mount(Child, {
|
||||
global: {
|
||||
plugins: [
|
||||
app => {
|
||||
const state = injectionStore.useAppProvidingState(app)();
|
||||
expect(state.value).toBe('app level');
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('app level');
|
||||
});
|
||||
|
||||
it('works with custom injection key', () => {
|
||||
const { Parent } = testFactory(
|
||||
useInjectionStore(() => ref('custom key'), {
|
||||
injectionKey: Symbol('customKey'),
|
||||
}),
|
||||
);
|
||||
|
||||
const wrapper = mount(Parent);
|
||||
expect(wrapper.text()).toBe('custom key');
|
||||
});
|
||||
|
||||
it('handles state factory with arguments', () => {
|
||||
const injectionStore = useInjectionStore((arg: string) => arg);
|
||||
const { Child } = testFactory(injectionStore);
|
||||
|
||||
const wrapper = mount(Child, {
|
||||
global: {
|
||||
plugins: [
|
||||
app => injectionStore.useAppProvidingState(app)('with args'),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.text()).toBe('with args');
|
||||
});
|
||||
});
|
||||
73
web/vue/src/composables/state/useInjectionStore/index.ts
Normal file
73
web/vue/src/composables/state/useInjectionStore/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useContextFactory } from '../useContextFactory';
|
||||
import type { App, InjectionKey } from 'vue';
|
||||
|
||||
export interface useInjectionStoreOptions<Return> {
|
||||
injectionName?: string;
|
||||
defaultValue?: Return;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useInjectionStore
|
||||
* @category State
|
||||
* @description Create a global state that can be injected into components
|
||||
*
|
||||
* @param {Function} stateFactory A factory function that creates the state
|
||||
* @param {useInjectionStoreOptions} options An object with the following properties
|
||||
* @param {string | InjectionKey} options.injectionKey The key to use for the injection
|
||||
* @param {any} options.defaultValue The default value to use when the state is not provided
|
||||
* @returns {Object} An object with `useProvidingState`, `useAppProvidingState`, and `useInjectedState` functions
|
||||
*
|
||||
* @example
|
||||
* const { useProvidingState, useInjectedState } = useInjectionStore(() => ref('Hello World'));
|
||||
*
|
||||
* // In a parent component
|
||||
* const state = useProvidingState();
|
||||
*
|
||||
* // In a child component
|
||||
* const state = useInjectedState();
|
||||
*
|
||||
* @example
|
||||
* const { useProvidingState, useInjectedState } = useInjectionStore(() => ref('Hello World'), {
|
||||
* injectionKey: 'MyState',
|
||||
* defaultValue: 'Default Value'
|
||||
* });
|
||||
*
|
||||
* // In a plugin
|
||||
* {
|
||||
* install(app) {
|
||||
* const state = useAppProvidingState(app)();
|
||||
* state.value = 'Hello World';
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // In a component
|
||||
* const state = useInjectedState();
|
||||
*
|
||||
* @since 0.0.5
|
||||
*/
|
||||
export function useInjectionStore<Args extends any[], Return>(
|
||||
stateFactory: (...args: Args) => Return,
|
||||
options?: useInjectionStoreOptions<Return>
|
||||
) {
|
||||
const ctx = useContextFactory<Return>(options?.injectionName ?? stateFactory.name ?? 'InjectionStore');
|
||||
|
||||
const useProvidingState = (...args: Args) => {
|
||||
const state = stateFactory(...args);
|
||||
ctx.provide(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const useAppProvidingState = (app: App) => (...args: Args) => {
|
||||
const state = stateFactory(...args);
|
||||
ctx.appProvide(app)(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const useInjectedState = () => ctx.inject(options?.defaultValue);
|
||||
|
||||
return {
|
||||
useProvidingState,
|
||||
useAppProvidingState,
|
||||
useInjectedState
|
||||
};
|
||||
}
|
||||
110
web/vue/src/composables/state/useToggle/index.test.ts
Normal file
110
web/vue/src/composables/state/useToggle/index.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { it, expect, describe } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useToggle } from '.';
|
||||
|
||||
describe('useToggle', () => {
|
||||
it('initialize with false by default', () => {
|
||||
const { value } = useToggle();
|
||||
expect(value.value).toBe(false);
|
||||
});
|
||||
|
||||
it('initialize with the provided initial value', () => {
|
||||
const { value } = useToggle(true);
|
||||
expect(value.value).toBe(true);
|
||||
});
|
||||
|
||||
it('initialize with the provided initial value from a ref', () => {
|
||||
const { value } = useToggle(ref(true));
|
||||
expect(value.value).toBe(true);
|
||||
});
|
||||
|
||||
it('toggle from false to true', () => {
|
||||
const { value, toggle } = useToggle(false);
|
||||
toggle();
|
||||
expect(value.value).toBe(true);
|
||||
});
|
||||
|
||||
it('toggle from true to false', () => {
|
||||
const { value, toggle } = useToggle(true);
|
||||
toggle();
|
||||
expect(value.value).toBe(false);
|
||||
});
|
||||
|
||||
it('toggle multiple times', () => {
|
||||
const { value, toggle } = useToggle(false);
|
||||
toggle();
|
||||
expect(value.value).toBe(true);
|
||||
toggle();
|
||||
expect(value.value).toBe(false);
|
||||
toggle();
|
||||
expect(value.value).toBe(true);
|
||||
});
|
||||
|
||||
it('toggle returns the new value', () => {
|
||||
const { toggle } = useToggle(false);
|
||||
expect(toggle()).toBe(true);
|
||||
expect(toggle()).toBe(false);
|
||||
});
|
||||
|
||||
it('set a specific value via toggle', () => {
|
||||
const { value, toggle } = useToggle(false);
|
||||
toggle(true);
|
||||
expect(value.value).toBe(true);
|
||||
toggle(true);
|
||||
expect(value.value).toBe(true);
|
||||
});
|
||||
|
||||
it('use custom truthy and falsy values', () => {
|
||||
const { value, toggle } = useToggle('off', {
|
||||
truthyValue: 'on',
|
||||
falsyValue: 'off',
|
||||
});
|
||||
|
||||
expect(value.value).toBe('off');
|
||||
toggle();
|
||||
expect(value.value).toBe('on');
|
||||
toggle();
|
||||
expect(value.value).toBe('off');
|
||||
});
|
||||
|
||||
it('set a specific custom value via toggle', () => {
|
||||
const { value, toggle } = useToggle('off', {
|
||||
truthyValue: 'on',
|
||||
falsyValue: 'off',
|
||||
});
|
||||
|
||||
toggle('on');
|
||||
expect(value.value).toBe('on');
|
||||
toggle('on');
|
||||
expect(value.value).toBe('on');
|
||||
});
|
||||
|
||||
it('use ref-based truthy and falsy values', () => {
|
||||
const truthy = ref('yes');
|
||||
const falsy = ref('no');
|
||||
|
||||
const { value, toggle } = useToggle('no', {
|
||||
truthyValue: truthy,
|
||||
falsyValue: falsy,
|
||||
});
|
||||
|
||||
expect(value.value).toBe('no');
|
||||
toggle();
|
||||
expect(value.value).toBe('yes');
|
||||
toggle();
|
||||
expect(value.value).toBe('no');
|
||||
});
|
||||
|
||||
it('use getter-based truthy and falsy values', () => {
|
||||
const { value, toggle } = useToggle(0, {
|
||||
truthyValue: () => 1,
|
||||
falsyValue: () => 0,
|
||||
});
|
||||
|
||||
expect(value.value).toBe(0);
|
||||
toggle();
|
||||
expect(value.value).toBe(1);
|
||||
toggle();
|
||||
expect(value.value).toBe(0);
|
||||
});
|
||||
});
|
||||
56
web/vue/src/composables/state/useToggle/index.ts
Normal file
56
web/vue/src/composables/state/useToggle/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ref, toValue, type MaybeRefOrGetter, type MaybeRef, type Ref } from 'vue';
|
||||
|
||||
export interface UseToggleOptions<Truthy, Falsy> {
|
||||
truthyValue?: MaybeRefOrGetter<Truthy>,
|
||||
falsyValue?: MaybeRefOrGetter<Falsy>,
|
||||
}
|
||||
|
||||
export interface UseToggleReturn<Truthy, Falsy> {
|
||||
value: Ref<Truthy | Falsy>;
|
||||
toggle: (value?: Truthy | Falsy) => Truthy | Falsy;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name useToggle
|
||||
* @category State
|
||||
* @description A composable that provides a boolean toggle with customizable truthy/falsy values
|
||||
*
|
||||
* @param {MaybeRef<Truthy | Falsy>} [initialValue=false] The initial value
|
||||
* @param {UseToggleOptions<Truthy, Falsy>} [options={}] Options for custom truthy/falsy values
|
||||
* @returns {UseToggleReturn<Truthy, Falsy>} The toggle state and function
|
||||
*
|
||||
* @example
|
||||
* const { value, toggle } = useToggle();
|
||||
*
|
||||
* @example
|
||||
* const { value, toggle } = useToggle(false, { truthyValue: 'on', falsyValue: 'off' });
|
||||
*
|
||||
* @since 0.0.1
|
||||
*/
|
||||
export function useToggle<Truthy = true, Falsy = false>(
|
||||
initialValue: MaybeRef<Truthy | Falsy> = false as Truthy | Falsy,
|
||||
options: UseToggleOptions<Truthy, Falsy> = {},
|
||||
): UseToggleReturn<Truthy, Falsy> {
|
||||
const {
|
||||
truthyValue = true as Truthy,
|
||||
falsyValue = false as Falsy,
|
||||
} = options;
|
||||
|
||||
const value = ref(initialValue) as Ref<Truthy | Falsy>;
|
||||
|
||||
const toggle = (newValue?: Truthy | Falsy) => {
|
||||
if (newValue !== undefined) {
|
||||
value.value = newValue;
|
||||
return value.value;
|
||||
}
|
||||
|
||||
const truthy = toValue(truthyValue);
|
||||
const falsy = toValue(falsyValue);
|
||||
|
||||
value.value = value.value === truthy ? falsy : truthy;
|
||||
|
||||
return value.value;
|
||||
};
|
||||
|
||||
return { value, toggle };
|
||||
}
|
||||
Reference in New Issue
Block a user