diff --git a/package-lock.json b/package-lock.json index b9341e1..209ef09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ "npm": ">= 7.0.0" }, "peerDependencies": { - "@muban/muban": "^1.0.0-alpha.28" + "@muban/muban": "^1.0.0-alpha.34" } }, "node_modules/@babel/code-frame": { diff --git a/package.json b/package.json index 54e581c..e004d88 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "plop": "plop" }, "peerDependencies": { - "@muban/muban": "^1.0.0-alpha.28" + "@muban/muban": "^1.0.0-alpha.34" }, "devDependencies": { "@babel/core": "^7.12.10", diff --git a/src/useInterval/useInterval.stories.mdx b/src/useInterval/useInterval.stories.mdx new file mode 100644 index 0000000..8d9e1d4 --- /dev/null +++ b/src/useInterval/useInterval.stories.mdx @@ -0,0 +1,73 @@ +import { Meta } from '@storybook/addon-docs'; + + + +# useInterval + +The `useInterval` hook is a wrapper around the native `setInterval`, it allows you to easily set +an interval within your component that will be auto cancelled when the component unmounts. + +## Reference + +```ts +function useInterval( + callback: () => void, + interval?: number = 100, + startImmediate?: boolean = true, +): { startInterval: () => void, stopInterval: () => void; isIntervalRunning: ComputedRef } +``` + +### Parameters +* `callback` – The callback you want to trigger once the interval runs. +* `interval` - The duration of the interval you want to create. +* `startImmediate` - Whether or not you want to immediately start the interval. + +### Returns +* `{ startInterval, stopInterval, isIntervalRunning }` + * `startInterval` – A function that starts the interval, any running intervals will automatically be stopped. + * `stopInterval` – A function that will stop the current active interval. + * `isIntervalRunning` – A computed ref that keeps track whether or not the interval is running. + +## Usage + +```ts +const { startInterval, stopInterval, isIntervalRunning } = useInterval(() => { + console.log('The interval has run') +}, 1000, false); +```` + +```ts +const Demo = defineComponent({ + name: 'demo', + refs: { + startBtn: 'start-button' + stopButton: 'stop-button' + }, + setup({ refs }) { + // The interval starts as soon as the component is mounted. + useInterval(() => { + console.log('The immediate interval callback is triggered.') + }, 1000); + + // The interval doesn't start automatically, but requires a user action to start. + const { startInterval, stopInterval, isIntervalRunning } = useInterval(() => { + console.log('The user-action interval callback is triggered.') + }, 1000, false); + + return [ + bind(refs.startBtn, { + click() { + startInterval(); // This actually starts the interval. + } + }), + bind(refs.stopButton, { + click() { + stopInterval(); // This stops the interval if it's active. + } + }) + ] + } +}) +``` diff --git a/src/useInterval/useInterval.stories.ts b/src/useInterval/useInterval.stories.ts new file mode 100644 index 0000000..6c6ab41 --- /dev/null +++ b/src/useInterval/useInterval.stories.ts @@ -0,0 +1,86 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { bind, computed, defineComponent, propType } from '@muban/muban'; +import type { Story } from '@muban/storybook/types-6-0'; +import { html } from '@muban/template'; +import { useInterval } from './useInterval'; +import { useStorybookLog } from '../hooks/useStorybookLog'; + +export default { + title: 'useInterval', +}; + +type DemoStoryProps = { startImmediate?: boolean; interval?: number }; + +export const Demo: Story = () => ({ + component: defineComponent({ + name: 'story', + props: { + startImmediate: propType.boolean.defaultValue(false), + interval: propType.number, + }, + refs: { + label: 'label', + startButton: 'start-button', + stopButton: 'stop-button', + }, + setup({ refs, props }) { + const [logBinding, log] = useStorybookLog(refs.label); + + function onInterval() { + log('interval called'); + } + + const { startInterval, stopInterval, isIntervalRunning } = useInterval( + onInterval, + props.interval, + props.startImmediate, + ); + + return [ + logBinding, + bind(refs.startButton, { + attr: { + disabled: isIntervalRunning, + }, + click() { + startInterval(); + }, + }), + bind(refs.stopButton, { + attr: { + disabled: computed(() => !isIntervalRunning.value), + }, + click() { + log('interval stopped'); + stopInterval(); + }, + }), + ]; + }, + }), + template: ({ startImmediate = false, interval = 2500 }: DemoStoryProps = {}) => html`
+
+

Instructions!

+

+ The demo interval is set to 2.5 seconds, you can start it by clicking the start button. You + can stop the interval by clicking the stop button. +

+
+
+
+
Test Area
+
+ + ${' '} + +
+
+
`, +}); +Demo.storyName = 'demo'; diff --git a/src/useInterval/useInterval.test.ts b/src/useInterval/useInterval.test.ts new file mode 100644 index 0000000..974c215 --- /dev/null +++ b/src/useInterval/useInterval.test.ts @@ -0,0 +1,102 @@ +import { runComponentSetup } from '@muban/test-utils'; +import { useInterval } from './useInterval'; + +jest.mock('@muban/muban', () => jest.requireActual('@muban/test-utils').getMubanLifecycleMock()); + +describe('useInterval', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + it('should not crash', () => { + runComponentSetup(() => { + useInterval(() => undefined); + }); + }); + + it('should start immediate and not be completed', () => { + const mockHandler = jest.fn(); + + runComponentSetup(() => { + useInterval(mockHandler, 100); + }); + + expect(mockHandler).toBeCalledTimes(0); + }); + + it('should start immediate and be called once', () => { + const mockHandler = jest.fn(); + + runComponentSetup( + () => useInterval(mockHandler, 100), + ({ stopInterval }) => { + jest.advanceTimersByTime(100); + stopInterval(); + }, + ); + + expect(mockHandler).toBeCalledTimes(1); + }); + + it('should trigger start and be stopped after three calls', () => { + const mockHandler = jest.fn(); + + runComponentSetup( + () => useInterval(mockHandler, 100, false), + ({ startInterval, stopInterval }) => { + startInterval(); + jest.advanceTimersByTime(300); + stopInterval(); + }, + ); + + expect(mockHandler).toBeCalledTimes(3); + }); + + it('should trigger stop once the interval is started', () => { + const mockHandler = jest.fn(); + + runComponentSetup( + () => useInterval(mockHandler, 200, false), + ({ startInterval, stopInterval }) => { + startInterval(); + jest.advanceTimersByTime(100); + stopInterval(); + jest.advanceTimersByTime(200); + }, + ); + + expect(mockHandler).toBeCalledTimes(0); + }); + + it('should know that the interval is running', () => { + const mockHandler = jest.fn(); + + runComponentSetup( + () => useInterval(mockHandler, 200, false), + ({ startInterval, stopInterval, isIntervalRunning }) => { + startInterval(); + jest.advanceTimersByTime(100); + expect(isIntervalRunning.value).toEqual(true); + stopInterval(); + expect(isIntervalRunning.value).toEqual(false); + }, + ); + }); + + it('should start a new interval before the old one was triggered and only complete once', () => { + const mockHandler = jest.fn(); + + runComponentSetup( + () => useInterval(mockHandler, 100, false), + ({ startInterval }) => { + startInterval(); + jest.advanceTimersByTime(50); + startInterval(); + jest.advanceTimersByTime(100); + }, + ); + + expect(mockHandler).toBeCalledTimes(1); + }); +}); diff --git a/src/useInterval/useInterval.ts b/src/useInterval/useInterval.ts new file mode 100644 index 0000000..c44264d --- /dev/null +++ b/src/useInterval/useInterval.ts @@ -0,0 +1,49 @@ +import type { ComputedRef } from '@muban/muban'; +import { ref, onMounted, onUnmounted, computed } from '@muban/muban'; + +// We use `-1` as the value to indicate that an interval is not running. +const NOT_RUNNING = -1; + +/** + * A hook that can be used to call a function on a provided interval, by default the interval + * will run immediate. You can also start and cancel the interval whenever needed. + * + * @param callback The callback you want to trigger once the interval runs. + * @param interval The duration of the interval you want to create. + * @param startImmediate Whether or not you want to immediately start the interval. + */ +export const useInterval = ( + callback: () => void, + interval: number = 100, + startImmediate: boolean = true, +): { + startInterval: () => void; + stopInterval: () => void; + isIntervalRunning: ComputedRef; +} => { + const intervalId = ref(NOT_RUNNING); + + function start() { + stop(); + intervalId.value = setInterval(callback, interval) as unknown as number; + } + + function stop() { + clearInterval(intervalId.value); + intervalId.value = NOT_RUNNING; + } + + onUnmounted(() => { + stop(); + }); + + onMounted(() => { + if (startImmediate) start(); + }); + + return { + startInterval: start, + stopInterval: stop, + isIntervalRunning: computed(() => intervalId.value !== NOT_RUNNING), + }; +}; diff --git a/src/useInterval/useIntervalStories.test.ts b/src/useInterval/useIntervalStories.test.ts new file mode 100644 index 0000000..9b2180d --- /dev/null +++ b/src/useInterval/useIntervalStories.test.ts @@ -0,0 +1,29 @@ +import '@testing-library/jest-dom'; +import { waitFor, render } from '@muban/testing-library'; +import userEvent from '@testing-library/user-event'; +import { Demo } from './useInterval.stories'; + +describe('useInterval stories', () => { + const { click } = userEvent.setup(); + + it('should render', () => { + const { getByText } = render(Demo); + + expect(getByText('Test Area')).toBeInTheDocument(); + }); + + it('should start immediate and be called after 100ms', async () => { + const { getByText } = render(Demo, { startImmediate: true, interval: 100 }); + + await waitFor(() => expect(getByText('interval called')).toBeInTheDocument()); + }); + + it('should start after clicking start and be called after 100ms', async () => { + const { getByText, getByRef } = render(Demo, { interval: 100 }); + const startButton = getByRef('start-button'); + + click(startButton); + + await waitFor(() => expect(getByText('interval called')).toBeInTheDocument()); + }); +}); diff --git a/src/useTimeout/useTimeout.stories.mdx b/src/useTimeout/useTimeout.stories.mdx new file mode 100644 index 0000000..28c6dc5 --- /dev/null +++ b/src/useTimeout/useTimeout.stories.mdx @@ -0,0 +1,73 @@ +import { Meta } from '@storybook/addon-docs'; + + + +# useTimeout + +The `useTimeout` hook is a wrapper around the native `setTimeout`, it allows you to easily create set +a timeout within your component that will be auto cancelled when the component unmounts. + +## Reference + +```ts +function useTimeout( + callback: () => void, + duration?: number = 100, + startImmediate?: boolean = true, +): { startTimeout: () => void, clearButton: () => void, isTimeoutRunning: ComputedRef } +``` + +### Parameters +* `callback` – The function to invoke when the timeout runs out. +* `duration` - The duration of the timeout you want to create. +* `startImmediate` - Whether or not you want to immediately start the timeout. + +### Returns +* `{ startTimeout, clearTimeout, isTimeoutRunning }` + * `startTimeout` – A function that starts the timeout, any running timeouts will automatically be cancelled. + * `clearTimeout` – A function that will cancel the current active timeout. + * `isTimeoutRunning` – A computed ref that keeps track whether or not the timeout is running. + +## Usage + +```ts +const { startTimeout, clearTimeout, isTimeoutRunning } = useTimeout(() => { + console.log('The timeout has run out') +}, 1000, false); +```` + +```ts +const Demo = defineComponent({ + name: 'demo', + refs: { + startBtn: 'start-button' + clearButton: 'clear-button' + }, + setup({ refs }) { + // The timeout runs as soon as the component is mounted. + useTimeout(() => { + console.log('The timeout has run out') + }, 1000); + + // The timeout doesn't start automatically, but requires a user action to start. + const { startTimeout, clearTimeout } = useTimeout(() => { + console.log('The timeout has run out') + }, 1000, false); + + return [ + bind(refs.startBtn, { + click() { + startTimeout(); // This actually starts the timeout. + } + }), + bind(refs.cancelButton, { + click() { + clearTimeout(); // This clears the timeout if it's active. + } + }) + ] + } +}) +``` diff --git a/src/useTimeout/useTimeout.stories.ts b/src/useTimeout/useTimeout.stories.ts new file mode 100644 index 0000000..603e658 --- /dev/null +++ b/src/useTimeout/useTimeout.stories.ts @@ -0,0 +1,84 @@ +/* eslint-disable unicorn/prevent-abbreviations,import/no-extraneous-dependencies */ +import { bind, computed, defineComponent, propType, ref } from '@muban/muban'; +import type { Story } from '@muban/storybook/types-6-0'; +import { html } from '@muban/template'; +import { useTimeout } from './useTimeout'; +import { useStorybookLog } from '../hooks/useStorybookLog'; + +export default { + title: 'useTimeout', +}; + +type DemoStoryProps = { startImmediate?: boolean; duration?: number }; + +export const Demo: Story = () => ({ + component: defineComponent({ + name: 'story', + props: { + startImmediate: propType.boolean.defaultValue(false), + duration: propType.number, + }, + refs: { + label: 'label', + startButton: 'start-button', + clearButton: 'clear-button', + }, + setup({ refs, props }) { + const [logBinding, log] = useStorybookLog(refs.label); + + function onTimeoutComplete() { + log('timeout complete'); + } + + const { startTimeout, clearTimeout, isTimeoutRunning } = useTimeout( + onTimeoutComplete, + props.duration, + props.startImmediate, + ); + + return [ + logBinding, + bind(refs.startButton, { + attr: { + disabled: isTimeoutRunning, + }, + click() { + startTimeout(); + }, + }), + bind(refs.clearButton, { + attr: { + disabled: computed(() => !isTimeoutRunning.value), + }, + click() { + log('cleared timeout'); + clearTimeout(); + }, + }), + ]; + }, + }), + template: ({ startImmediate = false, duration = 1000 }: DemoStoryProps = {}) => html`
+
+

Instructions!

+

+ The demo timeout is set to 1 second, you can start it by clicking the start button. You can + cancel the timeout by clicking the cancel button. +

+
+
+
+
Test Area
+
+ + ${' '} + +
+
+
`, +}); +Demo.storyName = 'demo'; diff --git a/src/useTimeout/useTimeout.test.ts b/src/useTimeout/useTimeout.test.ts new file mode 100644 index 0000000..0b0563a --- /dev/null +++ b/src/useTimeout/useTimeout.test.ts @@ -0,0 +1,96 @@ +import { runComponentSetup } from '@muban/test-utils'; +import { useTimeout } from './useTimeout'; + +jest.mock('@muban/muban', () => jest.requireActual('@muban/test-utils').getMubanLifecycleMock()); + +describe('useTimeout', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + it('should not crash', () => { + runComponentSetup(() => { + useTimeout(() => undefined); + }); + }); + + it('should start immediate and be completed after 100ms', () => { + const mockHandler = jest.fn(); + + runComponentSetup(() => { + useTimeout(mockHandler, 100); + }); + + jest.advanceTimersByTime(200); + expect(mockHandler).toBeCalledTimes(1); + }); + + it('should start immediate and not be completed', () => { + const mockHandler = jest.fn(); + + runComponentSetup(() => { + useTimeout(mockHandler, 100); + }); + + expect(mockHandler).toBeCalledTimes(0); + }); + + it('should trigger start and be completed after 100ms', () => { + const mockHandler = jest.fn(); + + runComponentSetup( + () => useTimeout(mockHandler, 100, false), + ({ startTimeout }) => { + startTimeout(); + }, + ); + jest.advanceTimersByTime(200); + expect(mockHandler).toBeCalledTimes(1); + }); + + it('should know that the timeout is running', () => { + const mockHandler = jest.fn(); + + runComponentSetup( + () => useTimeout(mockHandler, 200, false), + ({ startTimeout, clearTimeout, isTimeoutRunning }) => { + startTimeout(); + jest.advanceTimersByTime(100); + expect(isTimeoutRunning.value).toEqual(true); + clearTimeout(); + expect(isTimeoutRunning.value).toEqual(false); + }, + ); + }); + + it('should trigger cancel once the timeout is started', () => { + const mockHandler = jest.fn(); + + runComponentSetup( + () => useTimeout(mockHandler, 500, false), + ({ startTimeout, clearTimeout }) => { + startTimeout(); + jest.advanceTimersByTime(100); + clearTimeout(); + }, + ); + + expect(mockHandler).toBeCalledTimes(0); + }); + + it('should start a new timeout before the old one running out and only complete once', () => { + const mockHandler = jest.fn(); + + runComponentSetup( + () => useTimeout(mockHandler, 200, false), + ({ startTimeout }) => { + startTimeout(); + jest.advanceTimersByTime(100); + startTimeout(); + jest.advanceTimersByTime(300); + }, + ); + + expect(mockHandler).toBeCalledTimes(1); + }); +}); diff --git a/src/useTimeout/useTimeout.ts b/src/useTimeout/useTimeout.ts new file mode 100644 index 0000000..88567ea --- /dev/null +++ b/src/useTimeout/useTimeout.ts @@ -0,0 +1,52 @@ +import type { ComputedRef } from '@muban/muban'; +import { computed, onMounted, onUnmounted, ref } from '@muban/muban'; + +// We use `-1` as the value to indicate that an interval is not running. +const NOT_RUNNING = -1; + +/** + * A hook that can be used to apply a timeout to a certain function but also give you the option + * to cancel it before it's executed. + * + * @param callback The callback you want to trigger once the timeout is completed. + * @param duration The duration of the timeout you want to create. + * @param startImmediate Whether or not you want to immediately start the timeout. + */ +export const useTimeout = ( + callback: () => void, + duration: number = 100, + startImmediate: boolean = true, +): { + startTimeout: () => void; + clearTimeout: () => void; + isTimeoutRunning: ComputedRef; +} => { + const timeoutId = ref(NOT_RUNNING); + + function start() { + clear(); + timeoutId.value = setTimeout(() => { + timeoutId.value = NOT_RUNNING; + callback(); + }, duration) as unknown as number; + } + + function clear() { + clearTimeout(timeoutId.value); + timeoutId.value = NOT_RUNNING; + } + + onUnmounted(() => { + clear(); + }); + + onMounted(() => { + if (startImmediate) start(); + }); + + return { + startTimeout: start, + clearTimeout: clear, + isTimeoutRunning: computed(() => timeoutId.value !== NOT_RUNNING), + }; +}; diff --git a/src/useTimeout/useTimeoutStories.test.ts b/src/useTimeout/useTimeoutStories.test.ts new file mode 100644 index 0000000..a111241 --- /dev/null +++ b/src/useTimeout/useTimeoutStories.test.ts @@ -0,0 +1,29 @@ +import '@testing-library/jest-dom'; +import { waitFor, render } from '@muban/testing-library'; +import userEvent from '@testing-library/user-event'; +import { Demo } from './useTimeout.stories'; + +describe('useTimeout stories', () => { + const { click } = userEvent.setup(); + + it('should render', () => { + const { getByText } = render(Demo); + + expect(getByText('Test Area')).toBeInTheDocument(); + }); + + it('should start immediate and be completed after 1ms', async () => { + const { getByText } = render(Demo, { startImmediate: true, duration: 1 }); + + await waitFor(() => expect(getByText('timeout complete')).toBeInTheDocument()); + }); + + it('should start after clicking start and be completed after 1ms', async () => { + const { getByText, getByRef } = render(Demo, { duration: 1 }); + const startButton = getByRef('start-button'); + + click(startButton); + + await waitFor(() => expect(getByText('timeout complete')).toBeInTheDocument()); + }); +});