From 0a9972557d2b3f4d4da2ed26b28d54633ab42582 Mon Sep 17 00:00:00 2001 From: xobotyi Date: Sat, 26 Oct 2019 02:17:09 +0300 Subject: [PATCH 01/10] impl: useStateHistory hook; --- src/__stories__/useStateHistory.story.tsx | 75 +++++++++++++++++++++++ src/__stories__/util/resolveHookState.ts | 7 +++ src/index.ts | 1 + src/useStateHistory.ts | 74 ++++++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 src/__stories__/useStateHistory.story.tsx create mode 100644 src/__stories__/util/resolveHookState.ts create mode 100644 src/useStateHistory.ts diff --git a/src/__stories__/useStateHistory.story.tsx b/src/__stories__/useStateHistory.story.tsx new file mode 100644 index 0000000000..6c4767bc6c --- /dev/null +++ b/src/__stories__/useStateHistory.story.tsx @@ -0,0 +1,75 @@ +import { storiesOf } from '@storybook/react'; +import * as React from 'react'; +import { useRef } from 'react'; +import { useCounter, useStateHistory } from '..'; +import ShowDocs from './util/ShowDocs'; + +const Demo = () => { + const [state, setState, history] = useStateHistory(''); + const input = useRef(null); + + const [stepSize, { set: setStepSize }] = useCounter(1, 3, 1); + + console.log(history); + + return ( +
+
+
) => { + e.preventDefault(); + state !== input.current!.value && setState(input.current!.value); + }} + style={{ display: 'inline-block' }} + > + + +
+
+ +
+ Current state: {state} +
+
+ + +   Step size:  + ) => { + setStepSize((e.currentTarget.value as any) * 1); + }} + /> +
+
+ ); +}; + +storiesOf('State|useStateHistory', module) + .add('Docs', () => ) + .add('Demo', () => ); diff --git a/src/__stories__/util/resolveHookState.ts b/src/__stories__/util/resolveHookState.ts new file mode 100644 index 0000000000..076da7bd32 --- /dev/null +++ b/src/__stories__/util/resolveHookState.ts @@ -0,0 +1,7 @@ +export function resolveHookState(state: S | (() => S)): S { + if (typeof state === 'function') { + return (state as () => S)(); + } + + return state; +} diff --git a/src/index.ts b/src/index.ts index 82f239642a..6db3d3fcf7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -78,6 +78,7 @@ export { default as useSpeech } from './useSpeech'; // not exported because of peer dependency // export { default as useSpring } from './useSpring'; export { default as useStartTyping } from './useStartTyping'; +export { useStateHistory } from './useStateHistory'; export { default as useStateList } from './useStateList'; export { default as useThrottle } from './useThrottle'; export { default as useThrottleFn } from './useThrottleFn'; diff --git a/src/useStateHistory.ts b/src/useStateHistory.ts new file mode 100644 index 0000000000..8d8f29b47b --- /dev/null +++ b/src/useStateHistory.ts @@ -0,0 +1,74 @@ +import { Dispatch, SetStateAction, useCallback, useMemo, useRef, useState } from 'react'; +import { resolveHookState } from './__stories__/util/resolveHookState'; + +interface HistoryState { + history: S[]; + position: number; + capacity: number; + back: (amount?: number) => void; + forward: (amount?: number) => void; +} + +type UseStateHistoryReturn = [S, Dispatch>, HistoryState]; + +export function useStateHistory(initialState: S | (() => S), capacity?: number): UseStateHistoryReturn; +export function useStateHistory(): UseStateHistoryReturn; +export function useStateHistory( + initialState?: S | (() => S), + capacity: number = 10 +): UseStateHistoryReturn { + const [state, innerSetState] = useState(initialState); + const history = useRef>([resolveHookState(initialState)]); + const historyPosition = useRef(0); + + const setState = useCallback( + (newState: S | (() => S)) => { + innerSetState(() => { + const s = resolveHookState(newState); + + if (history.current.length && historyPosition.current < history.current.length - 1) { + history.current.splice(historyPosition.current, history.current.length - historyPosition.current); + } + + historyPosition.current = history.current.push(s) - 1; + + if (historyPosition.current > 9) { + history.current.splice(0, historyPosition.current - 9); + historyPosition.current = 9; + } + + return s; + }); + }, + [state] + ) as Dispatch>; + + const historyState = useMemo( + () => ({ + history: history.current, + position: historyPosition.current, + capacity, + back: (amount: number = 1) => { + if (!historyPosition.current) { + return; + } + + historyPosition.current -= Math.min(amount, historyPosition.current); + + innerSetState(history.current[historyPosition.current]); + }, + forward: (amount: number = 1) => { + if (historyPosition.current >= history.current.length - 1) { + return; + } + + historyPosition.current += Math.min(amount, history.current.length - 1 - historyPosition.current); + + innerSetState(history.current[historyPosition.current]); + }, + }), + [state, capacity] + ); + + return [state, setState, historyState]; +} From 14402fd6e85ea114d1a8f72f51ae781caa70862a Mon Sep 17 00:00:00 2001 From: xobotyi Date: Sat, 26 Oct 2019 02:20:53 +0300 Subject: [PATCH 02/10] fix: createBreakpoint lint fix (author skipped hooks); --- src/__stories__/createBreakpoint.story.tsx | 22 +++++----- src/createBreakpoint.ts | 49 ++++++++++++---------- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/src/__stories__/createBreakpoint.story.tsx b/src/__stories__/createBreakpoint.story.tsx index 92068f79b5..052797f858 100644 --- a/src/__stories__/createBreakpoint.story.tsx +++ b/src/__stories__/createBreakpoint.story.tsx @@ -1,8 +1,8 @@ -import { number, withKnobs } from "@storybook/addon-knobs"; -import { storiesOf } from "@storybook/react"; -import React from "react"; -import { createBreakpoint } from ".."; -import ShowDocs from "./util/ShowDocs"; +import { withKnobs } from '@storybook/addon-knobs'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { createBreakpoint } from '..'; +import ShowDocs from './util/ShowDocs'; const useBreakpointA = createBreakpoint(); const useBreakpointB = createBreakpoint({ mobileM: 350, laptop: 1024, tablet: 768 }); @@ -12,18 +12,18 @@ const Demo = () => { const breakpointB = useBreakpointB(); return (
-

{"try resize your window"}

-

{"createBreakpoint() #default : { laptopL: 1440, laptop: 1024, tablet: 768 }"}

+

{'try resize your window'}

+

{'createBreakpoint() #default : { laptopL: 1440, laptop: 1024, tablet: 768 }'}

{breakpointA}

-

{"createBreakpoint({ mobileM: 350, laptop: 1024, tablet: 768 })"}

+

{'createBreakpoint({ mobileM: 350, laptop: 1024, tablet: 768 })'}

{breakpointB}

); }; -storiesOf("sensors|createBreakpoint", module) +storiesOf('sensors|createBreakpoint', module) .addDecorator(withKnobs) - .add("Docs", () => ) - .add("Demo", () => { + .add('Docs', () => ) + .add('Demo', () => { return ; }); diff --git a/src/createBreakpoint.ts b/src/createBreakpoint.ts index 966a03558c..eee72085ee 100644 --- a/src/createBreakpoint.ts +++ b/src/createBreakpoint.ts @@ -1,24 +1,31 @@ -import { useEffect, useState, useMemo } from 'react' +import { useEffect, useState, useMemo } from 'react'; -const createBreakpoint = (breakpoints: { [name: string]: number } = { laptopL: 1440, laptop: 1024, tablet: 768 }) => () => { - const [screen, setScreen] = useState(0) +const createBreakpoint = ( + breakpoints: { [name: string]: number } = { laptopL: 1440, laptop: 1024, tablet: 768 } +) => () => { + const [screen, setScreen] = useState(0); - useEffect(() => { - const setSideScreen = (): void => { - setScreen(window.innerWidth) - } - setSideScreen() - window.addEventListener('resize', setSideScreen) - return () => { - window.removeEventListener('resize', setSideScreen) - } - }) - const sortedBreakpoints = useMemo(() => Object.entries(breakpoints).sort((a, b) => a[1] >= b[1] ? 1 : -1), [breakpoints]) - const result = sortedBreakpoints.reduce((acc, [name, width]) => { - if (screen >= width) return name - else return acc - }, sortedBreakpoints[0][0]) - return result -} + useEffect(() => { + const setSideScreen = (): void => { + setScreen(window.innerWidth); + }; + setSideScreen(); + window.addEventListener('resize', setSideScreen); + return () => { + window.removeEventListener('resize', setSideScreen); + }; + }); + const sortedBreakpoints = useMemo(() => Object.entries(breakpoints).sort((a, b) => (a[1] >= b[1] ? 1 : -1)), [ + breakpoints, + ]); + const result = sortedBreakpoints.reduce((acc, [name, width]) => { + if (screen >= width) { + return name; + } else { + return acc; + } + }, sortedBreakpoints[0][0]); + return result; +}; -export default createBreakpoint +export default createBreakpoint; From 7ac2f9b0dc384609ba6a61a3894b402e2c112df5 Mon Sep 17 00:00:00 2001 From: xobotyi Date: Sat, 26 Oct 2019 12:47:06 +0300 Subject: [PATCH 03/10] impl(useStateHistory): added initialHistory parameter and go(pos: number) method; impl(useStateHistory): improved story demo; impl(useStateHistory): docs; --- docs/useStateHistory.md | 24 ++++++ src/__stories__/useStateHistory.story.tsx | 94 +++++++++++++---------- src/useStateHistory.ts | 36 +++++++-- 3 files changed, 107 insertions(+), 47 deletions(-) create mode 100644 docs/useStateHistory.md diff --git a/docs/useStateHistory.md b/docs/useStateHistory.md new file mode 100644 index 0000000000..9e88e573cb --- /dev/null +++ b/docs/useStateHistory.md @@ -0,0 +1,24 @@ +# `useStateHistory` + +Stores defined amount of previous state values and provides handles to travel through them. + +## Usage + +## Reference + +```typescript +const [state, setState, stateHistory] = useStateHistory( + initialState?: S | (()=>S), + initialHistory?: S, + historyCapacity?: number = 0 +); +``` + +- **`state`**, **`setState`** and **`initialState`** are exactly the same with native React's `useState` hook; +- **`historyCapacity`** - amount of history entries that hold by storage; +- **`initialHistory`** - if defined it will be used as initial history value, otherwise history will equals `[ initialState ]`. +If entries amount is greater than `historyCapacity` parameter it wont be modified on init but will be trimmed on next `setState`; +- **`stateHistory`** - an object containing history state: + - **`history`**_`: S[]`_ - an array holding history entries; + - **`position`**_`: number`_ - current position _index_ in history; + diff --git a/src/__stories__/useStateHistory.story.tsx b/src/__stories__/useStateHistory.story.tsx index 6c4767bc6c..e24d1e1b9d 100644 --- a/src/__stories__/useStateHistory.story.tsx +++ b/src/__stories__/useStateHistory.story.tsx @@ -1,28 +1,57 @@ import { storiesOf } from '@storybook/react'; import * as React from 'react'; -import { useRef } from 'react'; +import { useCallback, useRef } from 'react'; import { useCounter, useStateHistory } from '..'; import ShowDocs from './util/ShowDocs'; const Demo = () => { - const [state, setState, history] = useStateHistory(''); - const input = useRef(null); + const [state, setState, history] = useStateHistory('', ['hello', 'world']); + const inputRef = useRef(null); const [stepSize, { set: setStepSize }] = useCounter(1, 3, 1); - console.log(history); + const handleFormSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + state !== inputRef.current!.value && setState(inputRef.current!.value); + }, + [state] + ); + + const handleBackClick = useCallback( + (e: React.MouseEvent) => { + if (e.currentTarget.disabled) { + return; + } + + history.back(stepSize); + }, + [history, stepSize] + ); + + const handleForwardClick = useCallback( + (e: React.MouseEvent) => { + if (e.currentTarget.disabled) { + return; + } + + history.forward(stepSize); + }, + [history, stepSize] + ); + + const handleStepSizeChange = useCallback( + (e: React.FormEvent) => { + setStepSize((e.currentTarget.value as any) * 1); + }, + [stepSize] + ); return (
-
) => { - e.preventDefault(); - state !== input.current!.value && setState(input.current!.value); - }} - style={{ display: 'inline-block' }} - > - + +
@@ -31,38 +60,23 @@ const Demo = () => { Current state: {state}
- -   Step size:  - ) => { - setStepSize((e.currentTarget.value as any) * 1); + +
+ +
+
Current history
+
') + .replace(/ /g, ' '), }} />
@@ -71,5 +85,5 @@ const Demo = () => { }; storiesOf('State|useStateHistory', module) - .add('Docs', () => ) + .add('Docs', () => ) .add('Demo', () => ); diff --git a/src/useStateHistory.ts b/src/useStateHistory.ts index 8d8f29b47b..29aa93bb31 100644 --- a/src/useStateHistory.ts +++ b/src/useStateHistory.ts @@ -7,19 +7,25 @@ interface HistoryState { capacity: number; back: (amount?: number) => void; forward: (amount?: number) => void; + go: (position: number) => void; } type UseStateHistoryReturn = [S, Dispatch>, HistoryState]; -export function useStateHistory(initialState: S | (() => S), capacity?: number): UseStateHistoryReturn; +export function useStateHistory( + initialState: S | (() => S), + initialHistory?: S[], + capacity?: number +): UseStateHistoryReturn; export function useStateHistory(): UseStateHistoryReturn; export function useStateHistory( initialState?: S | (() => S), + initialHistory?: S[], capacity: number = 10 ): UseStateHistoryReturn { const [state, innerSetState] = useState(initialState); - const history = useRef>([resolveHookState(initialState)]); - const historyPosition = useRef(0); + const history = useRef>(initialHistory || [resolveHookState(initialState)]); + const historyPosition = useRef(history.current.length - 1); const setState = useCallback( (newState: S | (() => S)) => { @@ -53,18 +59,34 @@ export function useStateHistory( return; } - historyPosition.current -= Math.min(amount, historyPosition.current); + innerSetState(() => { + historyPosition.current -= Math.min(amount, historyPosition.current); - innerSetState(history.current[historyPosition.current]); + return history.current[historyPosition.current]; + }); }, forward: (amount: number = 1) => { if (historyPosition.current >= history.current.length - 1) { return; } - historyPosition.current += Math.min(amount, history.current.length - 1 - historyPosition.current); + innerSetState(() => { + historyPosition.current += Math.min(amount, history.current.length - 1 - historyPosition.current); + + return history.current[historyPosition.current]; + }); + }, + go: (pos: number) => { + if (pos === 0) { + return; + } + + innerSetState(() => { + historyPosition.current = + pos < 0 ? Math.max(history.current.length - 1 - pos, 0) : Math.min(history.current.length - 1, pos); - innerSetState(history.current[historyPosition.current]); + return history.current[historyPosition.current]; + }); }, }), [state, capacity] From dddf71d8ac176ee4103bf68dff8185faa326a823 Mon Sep 17 00:00:00 2001 From: xobotyi Date: Sat, 26 Oct 2019 02:17:09 +0300 Subject: [PATCH 04/10] impl: useStateHistory hook; --- src/__stories__/useStateHistory.story.tsx | 75 +++++++++++++++++++++++ src/__stories__/util/resolveHookState.ts | 7 +++ src/index.ts | 1 + src/useStateHistory.ts | 74 ++++++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 src/__stories__/useStateHistory.story.tsx create mode 100644 src/__stories__/util/resolveHookState.ts create mode 100644 src/useStateHistory.ts diff --git a/src/__stories__/useStateHistory.story.tsx b/src/__stories__/useStateHistory.story.tsx new file mode 100644 index 0000000000..6c4767bc6c --- /dev/null +++ b/src/__stories__/useStateHistory.story.tsx @@ -0,0 +1,75 @@ +import { storiesOf } from '@storybook/react'; +import * as React from 'react'; +import { useRef } from 'react'; +import { useCounter, useStateHistory } from '..'; +import ShowDocs from './util/ShowDocs'; + +const Demo = () => { + const [state, setState, history] = useStateHistory(''); + const input = useRef(null); + + const [stepSize, { set: setStepSize }] = useCounter(1, 3, 1); + + console.log(history); + + return ( +
+
+
) => { + e.preventDefault(); + state !== input.current!.value && setState(input.current!.value); + }} + style={{ display: 'inline-block' }} + > + + +
+
+ +
+ Current state: {state} +
+
+ + +   Step size:  + ) => { + setStepSize((e.currentTarget.value as any) * 1); + }} + /> +
+
+ ); +}; + +storiesOf('State|useStateHistory', module) + .add('Docs', () => ) + .add('Demo', () => ); diff --git a/src/__stories__/util/resolveHookState.ts b/src/__stories__/util/resolveHookState.ts new file mode 100644 index 0000000000..076da7bd32 --- /dev/null +++ b/src/__stories__/util/resolveHookState.ts @@ -0,0 +1,7 @@ +export function resolveHookState(state: S | (() => S)): S { + if (typeof state === 'function') { + return (state as () => S)(); + } + + return state; +} diff --git a/src/index.ts b/src/index.ts index 08e4383a34..f6ea14b299 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,6 +75,7 @@ export { default as useSpeech } from './useSpeech'; // not exported because of peer dependency // export { default as useSpring } from './useSpring'; export { default as useStartTyping } from './useStartTyping'; +export { useStateHistory } from './useStateHistory'; export { default as useStateList } from './useStateList'; export { default as useThrottle } from './useThrottle'; export { default as useThrottleFn } from './useThrottleFn'; diff --git a/src/useStateHistory.ts b/src/useStateHistory.ts new file mode 100644 index 0000000000..8d8f29b47b --- /dev/null +++ b/src/useStateHistory.ts @@ -0,0 +1,74 @@ +import { Dispatch, SetStateAction, useCallback, useMemo, useRef, useState } from 'react'; +import { resolveHookState } from './__stories__/util/resolveHookState'; + +interface HistoryState { + history: S[]; + position: number; + capacity: number; + back: (amount?: number) => void; + forward: (amount?: number) => void; +} + +type UseStateHistoryReturn = [S, Dispatch>, HistoryState]; + +export function useStateHistory(initialState: S | (() => S), capacity?: number): UseStateHistoryReturn; +export function useStateHistory(): UseStateHistoryReturn; +export function useStateHistory( + initialState?: S | (() => S), + capacity: number = 10 +): UseStateHistoryReturn { + const [state, innerSetState] = useState(initialState); + const history = useRef>([resolveHookState(initialState)]); + const historyPosition = useRef(0); + + const setState = useCallback( + (newState: S | (() => S)) => { + innerSetState(() => { + const s = resolveHookState(newState); + + if (history.current.length && historyPosition.current < history.current.length - 1) { + history.current.splice(historyPosition.current, history.current.length - historyPosition.current); + } + + historyPosition.current = history.current.push(s) - 1; + + if (historyPosition.current > 9) { + history.current.splice(0, historyPosition.current - 9); + historyPosition.current = 9; + } + + return s; + }); + }, + [state] + ) as Dispatch>; + + const historyState = useMemo( + () => ({ + history: history.current, + position: historyPosition.current, + capacity, + back: (amount: number = 1) => { + if (!historyPosition.current) { + return; + } + + historyPosition.current -= Math.min(amount, historyPosition.current); + + innerSetState(history.current[historyPosition.current]); + }, + forward: (amount: number = 1) => { + if (historyPosition.current >= history.current.length - 1) { + return; + } + + historyPosition.current += Math.min(amount, history.current.length - 1 - historyPosition.current); + + innerSetState(history.current[historyPosition.current]); + }, + }), + [state, capacity] + ); + + return [state, setState, historyState]; +} From 1957605d18b5672c243307ae74d1d18ac7a060ca Mon Sep 17 00:00:00 2001 From: xobotyi Date: Sat, 26 Oct 2019 12:47:06 +0300 Subject: [PATCH 05/10] impl(useStateHistory): added initialHistory parameter and go(pos: number) method; impl(useStateHistory): improved story demo; impl(useStateHistory): docs; --- docs/useStateHistory.md | 24 ++++++ src/__stories__/useStateHistory.story.tsx | 94 +++++++++++++---------- src/useStateHistory.ts | 36 +++++++-- 3 files changed, 107 insertions(+), 47 deletions(-) create mode 100644 docs/useStateHistory.md diff --git a/docs/useStateHistory.md b/docs/useStateHistory.md new file mode 100644 index 0000000000..9e88e573cb --- /dev/null +++ b/docs/useStateHistory.md @@ -0,0 +1,24 @@ +# `useStateHistory` + +Stores defined amount of previous state values and provides handles to travel through them. + +## Usage + +## Reference + +```typescript +const [state, setState, stateHistory] = useStateHistory( + initialState?: S | (()=>S), + initialHistory?: S, + historyCapacity?: number = 0 +); +``` + +- **`state`**, **`setState`** and **`initialState`** are exactly the same with native React's `useState` hook; +- **`historyCapacity`** - amount of history entries that hold by storage; +- **`initialHistory`** - if defined it will be used as initial history value, otherwise history will equals `[ initialState ]`. +If entries amount is greater than `historyCapacity` parameter it wont be modified on init but will be trimmed on next `setState`; +- **`stateHistory`** - an object containing history state: + - **`history`**_`: S[]`_ - an array holding history entries; + - **`position`**_`: number`_ - current position _index_ in history; + diff --git a/src/__stories__/useStateHistory.story.tsx b/src/__stories__/useStateHistory.story.tsx index 6c4767bc6c..e24d1e1b9d 100644 --- a/src/__stories__/useStateHistory.story.tsx +++ b/src/__stories__/useStateHistory.story.tsx @@ -1,28 +1,57 @@ import { storiesOf } from '@storybook/react'; import * as React from 'react'; -import { useRef } from 'react'; +import { useCallback, useRef } from 'react'; import { useCounter, useStateHistory } from '..'; import ShowDocs from './util/ShowDocs'; const Demo = () => { - const [state, setState, history] = useStateHistory(''); - const input = useRef(null); + const [state, setState, history] = useStateHistory('', ['hello', 'world']); + const inputRef = useRef(null); const [stepSize, { set: setStepSize }] = useCounter(1, 3, 1); - console.log(history); + const handleFormSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + state !== inputRef.current!.value && setState(inputRef.current!.value); + }, + [state] + ); + + const handleBackClick = useCallback( + (e: React.MouseEvent) => { + if (e.currentTarget.disabled) { + return; + } + + history.back(stepSize); + }, + [history, stepSize] + ); + + const handleForwardClick = useCallback( + (e: React.MouseEvent) => { + if (e.currentTarget.disabled) { + return; + } + + history.forward(stepSize); + }, + [history, stepSize] + ); + + const handleStepSizeChange = useCallback( + (e: React.FormEvent) => { + setStepSize((e.currentTarget.value as any) * 1); + }, + [stepSize] + ); return (
-
) => { - e.preventDefault(); - state !== input.current!.value && setState(input.current!.value); - }} - style={{ display: 'inline-block' }} - > - + +
@@ -31,38 +60,23 @@ const Demo = () => { Current state: {state}
- -   Step size:  - ) => { - setStepSize((e.currentTarget.value as any) * 1); + +
+ +
+
Current history
+
') + .replace(/ /g, ' '), }} />
@@ -71,5 +85,5 @@ const Demo = () => { }; storiesOf('State|useStateHistory', module) - .add('Docs', () => ) + .add('Docs', () => ) .add('Demo', () => ); diff --git a/src/useStateHistory.ts b/src/useStateHistory.ts index 8d8f29b47b..29aa93bb31 100644 --- a/src/useStateHistory.ts +++ b/src/useStateHistory.ts @@ -7,19 +7,25 @@ interface HistoryState { capacity: number; back: (amount?: number) => void; forward: (amount?: number) => void; + go: (position: number) => void; } type UseStateHistoryReturn = [S, Dispatch>, HistoryState]; -export function useStateHistory(initialState: S | (() => S), capacity?: number): UseStateHistoryReturn; +export function useStateHistory( + initialState: S | (() => S), + initialHistory?: S[], + capacity?: number +): UseStateHistoryReturn; export function useStateHistory(): UseStateHistoryReturn; export function useStateHistory( initialState?: S | (() => S), + initialHistory?: S[], capacity: number = 10 ): UseStateHistoryReturn { const [state, innerSetState] = useState(initialState); - const history = useRef>([resolveHookState(initialState)]); - const historyPosition = useRef(0); + const history = useRef>(initialHistory || [resolveHookState(initialState)]); + const historyPosition = useRef(history.current.length - 1); const setState = useCallback( (newState: S | (() => S)) => { @@ -53,18 +59,34 @@ export function useStateHistory( return; } - historyPosition.current -= Math.min(amount, historyPosition.current); + innerSetState(() => { + historyPosition.current -= Math.min(amount, historyPosition.current); - innerSetState(history.current[historyPosition.current]); + return history.current[historyPosition.current]; + }); }, forward: (amount: number = 1) => { if (historyPosition.current >= history.current.length - 1) { return; } - historyPosition.current += Math.min(amount, history.current.length - 1 - historyPosition.current); + innerSetState(() => { + historyPosition.current += Math.min(amount, history.current.length - 1 - historyPosition.current); + + return history.current[historyPosition.current]; + }); + }, + go: (pos: number) => { + if (pos === 0) { + return; + } + + innerSetState(() => { + historyPosition.current = + pos < 0 ? Math.max(history.current.length - 1 - pos, 0) : Math.min(history.current.length - 1, pos); - innerSetState(history.current[historyPosition.current]); + return history.current[historyPosition.current]; + }); }, }), [state, capacity] From 6a3e0adf46cd752e6b5c6b9ebacc8da55c84b057 Mon Sep 17 00:00:00 2001 From: xobotyi Date: Sun, 27 Oct 2019 21:57:24 +0300 Subject: [PATCH 06/10] fix(useStateHistory): fixed history capacity overflow; impl(useStateHistory): improved docs; --- docs/useStateHistory.md | 11 +++++++- src/__stories__/util/resolveHookState.ts | 6 +---- src/__tests__/useStateHistory.test.ts | 7 +++++ src/useStateHistory.ts | 34 +++++++++++++----------- 4 files changed, 37 insertions(+), 21 deletions(-) create mode 100644 src/__tests__/useStateHistory.test.ts diff --git a/docs/useStateHistory.md b/docs/useStateHistory.md index 9e88e573cb..34502228c0 100644 --- a/docs/useStateHistory.md +++ b/docs/useStateHistory.md @@ -17,8 +17,17 @@ const [state, setState, stateHistory] = useStateHistory( - **`state`**, **`setState`** and **`initialState`** are exactly the same with native React's `useState` hook; - **`historyCapacity`** - amount of history entries that hold by storage; - **`initialHistory`** - if defined it will be used as initial history value, otherwise history will equals `[ initialState ]`. +Initial state will not be pushed to initial history. If entries amount is greater than `historyCapacity` parameter it wont be modified on init but will be trimmed on next `setState`; - **`stateHistory`** - an object containing history state: - - **`history`**_`: S[]`_ - an array holding history entries; + - **`history`**_`: S[]`_ - an array holding history entries. _I will have the same ref all the time so pe careful with that one!_; - **`position`**_`: number`_ - current position _index_ in history; + - **`capacity`**_`: number = 10`_ - maximum amount of history entries; + - **`back`**_`: (amount?: number) => void`_ - go back in state history, it will cause `setState` invoke and component re-render. + If first element of history reached, the call will have no effect; + - **`forward`**_`: (amount?: number) => void`_ - go forward in state history, it will cause `setState` invoke and component re-render. + If last element of history reached, the call will have no effect; + - **`go`**_`: (position: number) => void`_ - go to arbitrary position in history. + In case `position` is non-negative ot will count elements from beginning. + Negative `position` will cause elements counting from the end, so `go(-2)` equals `go(history.length - 1)`; diff --git a/src/__stories__/util/resolveHookState.ts b/src/__stories__/util/resolveHookState.ts index 076da7bd32..fb917d7675 100644 --- a/src/__stories__/util/resolveHookState.ts +++ b/src/__stories__/util/resolveHookState.ts @@ -1,7 +1,3 @@ export function resolveHookState(state: S | (() => S)): S { - if (typeof state === 'function') { - return (state as () => S)(); - } - - return state; + return typeof state === 'function' ? (state as () => S)() : state; } diff --git a/src/__tests__/useStateHistory.test.ts b/src/__tests__/useStateHistory.test.ts new file mode 100644 index 0000000000..84b4719c72 --- /dev/null +++ b/src/__tests__/useStateHistory.test.ts @@ -0,0 +1,7 @@ +import { useStateHistory } from '../..'; + +describe('useStateHistory', () => { + it('should be defined', () => { + expect(useStateHistory).toBeDefined(); + }); +}); diff --git a/src/useStateHistory.ts b/src/useStateHistory.ts index 29aa93bb31..66ee412152 100644 --- a/src/useStateHistory.ts +++ b/src/useStateHistory.ts @@ -18,36 +18,40 @@ export function useStateHistory( capacity?: number ): UseStateHistoryReturn; export function useStateHistory(): UseStateHistoryReturn; + export function useStateHistory( initialState?: S | (() => S), initialHistory?: S[], capacity: number = 10 -): UseStateHistoryReturn { - const [state, innerSetState] = useState(initialState); - const history = useRef>(initialHistory || [resolveHookState(initialState)]); - const historyPosition = useRef(history.current.length - 1); +): UseStateHistoryReturn { + initialState = resolveHookState(initialState as S); + initialHistory = Array.isArray(initialHistory) ? [...initialHistory] : ([initialState] as S[]); + + const [state, innerSetState] = useState(initialState); + const history = useRef(initialHistory); + const historyPosition = useRef(history.current.length - 1); const setState = useCallback( (newState: S | (() => S)) => { innerSetState(() => { - const s = resolveHookState(newState); + newState = resolveHookState(newState); if (history.current.length && historyPosition.current < history.current.length - 1) { - history.current.splice(historyPosition.current, history.current.length - historyPosition.current); + history.current.splice(historyPosition.current, history.current.length - historyPosition.current - 1); } - historyPosition.current = history.current.push(s) - 1; + historyPosition.current = history.current.push(newState) - 1; - if (historyPosition.current > 9) { - history.current.splice(0, historyPosition.current - 9); - historyPosition.current = 9; + if (historyPosition.current > capacity) { + history.current.splice(0, historyPosition.current - capacity + 1); + historyPosition.current = capacity; } - return s; + return newState; }); }, - [state] - ) as Dispatch>; + [state, capacity] + ) as Dispatch>; const historyState = useMemo( () => ({ @@ -77,13 +81,13 @@ export function useStateHistory( }); }, go: (pos: number) => { - if (pos === 0) { + if (pos === historyPosition.current) { return; } innerSetState(() => { historyPosition.current = - pos < 0 ? Math.max(history.current.length - 1 - pos, 0) : Math.min(history.current.length - 1, pos); + pos < 0 ? Math.max(history.current.length - pos, 0) : Math.min(history.current.length - 1, pos); return history.current[historyPosition.current]; }); From 3ad928b3e47222aee5e39300c662256fd3db83b5 Mon Sep 17 00:00:00 2001 From: xobotyi Date: Wed, 13 Nov 2019 02:13:14 +0300 Subject: [PATCH 07/10] refactor(useStateWithHistory): crop on capacity change removed due to inability to make intuitive behavior; refactor(useStateWithHistory): changed call signature; refactor(useStateWithHistory): reworked setState method to shift elements on capacity reach; --- src/__stories__/useStateHistory.story.tsx | 4 +- src/__tests__/useStateHistory.test.ts | 7 -- src/index.ts | 2 +- src/useStateHistory.ts | 100 ----------------- src/useStateWithHistory.ts | 129 ++++++++++++++++++++++ tests/useStateWithHistory.test.ts | 83 ++++++++++++++ 6 files changed, 215 insertions(+), 110 deletions(-) delete mode 100644 src/__tests__/useStateHistory.test.ts delete mode 100644 src/useStateHistory.ts create mode 100644 src/useStateWithHistory.ts create mode 100644 tests/useStateWithHistory.test.ts diff --git a/src/__stories__/useStateHistory.story.tsx b/src/__stories__/useStateHistory.story.tsx index e24d1e1b9d..442b0ab7c6 100644 --- a/src/__stories__/useStateHistory.story.tsx +++ b/src/__stories__/useStateHistory.story.tsx @@ -1,11 +1,11 @@ import { storiesOf } from '@storybook/react'; import * as React from 'react'; import { useCallback, useRef } from 'react'; -import { useCounter, useStateHistory } from '..'; +import { useCounter, useStateWithHistory } from '..'; import ShowDocs from './util/ShowDocs'; const Demo = () => { - const [state, setState, history] = useStateHistory('', ['hello', 'world']); + const [state, setState, history] = useStateWithHistory('', ['hello', 'world']); const inputRef = useRef(null); const [stepSize, { set: setStepSize }] = useCounter(1, 3, 1); diff --git a/src/__tests__/useStateHistory.test.ts b/src/__tests__/useStateHistory.test.ts deleted file mode 100644 index 84b4719c72..0000000000 --- a/src/__tests__/useStateHistory.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { useStateHistory } from '../..'; - -describe('useStateHistory', () => { - it('should be defined', () => { - expect(useStateHistory).toBeDefined(); - }); -}); diff --git a/src/index.ts b/src/index.ts index f6ea14b299..2559d82d1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,7 +75,7 @@ export { default as useSpeech } from './useSpeech'; // not exported because of peer dependency // export { default as useSpring } from './useSpring'; export { default as useStartTyping } from './useStartTyping'; -export { useStateHistory } from './useStateHistory'; +export { useStateWithHistory } from './useStateWithHistory'; export { default as useStateList } from './useStateList'; export { default as useThrottle } from './useThrottle'; export { default as useThrottleFn } from './useThrottleFn'; diff --git a/src/useStateHistory.ts b/src/useStateHistory.ts deleted file mode 100644 index 66ee412152..0000000000 --- a/src/useStateHistory.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { Dispatch, SetStateAction, useCallback, useMemo, useRef, useState } from 'react'; -import { resolveHookState } from './__stories__/util/resolveHookState'; - -interface HistoryState { - history: S[]; - position: number; - capacity: number; - back: (amount?: number) => void; - forward: (amount?: number) => void; - go: (position: number) => void; -} - -type UseStateHistoryReturn = [S, Dispatch>, HistoryState]; - -export function useStateHistory( - initialState: S | (() => S), - initialHistory?: S[], - capacity?: number -): UseStateHistoryReturn; -export function useStateHistory(): UseStateHistoryReturn; - -export function useStateHistory( - initialState?: S | (() => S), - initialHistory?: S[], - capacity: number = 10 -): UseStateHistoryReturn { - initialState = resolveHookState(initialState as S); - initialHistory = Array.isArray(initialHistory) ? [...initialHistory] : ([initialState] as S[]); - - const [state, innerSetState] = useState(initialState); - const history = useRef(initialHistory); - const historyPosition = useRef(history.current.length - 1); - - const setState = useCallback( - (newState: S | (() => S)) => { - innerSetState(() => { - newState = resolveHookState(newState); - - if (history.current.length && historyPosition.current < history.current.length - 1) { - history.current.splice(historyPosition.current, history.current.length - historyPosition.current - 1); - } - - historyPosition.current = history.current.push(newState) - 1; - - if (historyPosition.current > capacity) { - history.current.splice(0, historyPosition.current - capacity + 1); - historyPosition.current = capacity; - } - - return newState; - }); - }, - [state, capacity] - ) as Dispatch>; - - const historyState = useMemo( - () => ({ - history: history.current, - position: historyPosition.current, - capacity, - back: (amount: number = 1) => { - if (!historyPosition.current) { - return; - } - - innerSetState(() => { - historyPosition.current -= Math.min(amount, historyPosition.current); - - return history.current[historyPosition.current]; - }); - }, - forward: (amount: number = 1) => { - if (historyPosition.current >= history.current.length - 1) { - return; - } - - innerSetState(() => { - historyPosition.current += Math.min(amount, history.current.length - 1 - historyPosition.current); - - return history.current[historyPosition.current]; - }); - }, - go: (pos: number) => { - if (pos === historyPosition.current) { - return; - } - - innerSetState(() => { - historyPosition.current = - pos < 0 ? Math.max(history.current.length - pos, 0) : Math.min(history.current.length - 1, pos); - - return history.current[historyPosition.current]; - }); - }, - }), - [state, capacity] - ); - - return [state, setState, historyState]; -} diff --git a/src/useStateWithHistory.ts b/src/useStateWithHistory.ts new file mode 100644 index 0000000000..43a60e941e --- /dev/null +++ b/src/useStateWithHistory.ts @@ -0,0 +1,129 @@ +import { Dispatch, useCallback, useMemo, useRef, useState } from 'react'; +import { useFirstMountState } from './useFirstMountState'; +import { InitialHookState, ResolvableHookState, resolveHookState } from './util/resolveHookState'; + +interface HistoryState { + history: S[]; + position: number; + capacity: number; + back: (amount?: number) => void; + forward: (amount?: number) => void; + go: (position: number) => void; +} + +export type UseStateHistoryReturn = [S, Dispatch>, HistoryState]; + +export function useStateWithHistory( + initialState: InitialHookState, + capacity?: number, + initialHistory?: I[] +): UseStateHistoryReturn; +export function useStateWithHistory(): UseStateHistoryReturn; + +export function useStateWithHistory( + initialState?: InitialHookState, + capacity: number = 10, + initialHistory?: I[] +): UseStateHistoryReturn { + if (capacity < 1) { + throw new Error(`Capacity has to be greater than 1, got '${capacity}'`); + } + + const isFirstMount = useFirstMountState(); + const [state, innerSetState] = useState(initialState as S); + const history = useRef((initialHistory ?? []) as S[]); + const historyPosition = useRef(history.current.length && history.current.length - 1); + + // do the states manipulation only on first mount, no sense to load re-renders with useless calculations + if (isFirstMount) { + if (history.current.length) { + // if last element of history !== initial - push initial to history + if (history.current[history.current.length - 1] !== initialState) { + history.current.push(initialState as I); + } + + // if initial history bigger that capacity - crop the first elements out + if (history.current.length > capacity) { + history.current = history.current.slice(history.current.length - capacity); + } + } else { + // initiate the history with initial state + history.current.push(initialState as I); + } + } + + const setState = useCallback( + (newState: ResolvableHookState): void => { + innerSetState(currentState => { + newState = resolveHookState(newState); + + // is state has changed + if (newState !== currentState) { + // if current position is not the last - pop element to the right + if (historyPosition.current < history.current.length - 1) { + history.current = history.current.slice(0, historyPosition.current + 1); + } + + historyPosition.current = history.current.push(newState as I) - 1; + + // if capacity is reached - shift first elements + if (history.current.length > capacity) { + history.current = history.current.slice(history.current.length - capacity); + } + } + + return newState; + }); + }, + [state, capacity] + ) as Dispatch>; + + const historyState = useMemo( + () => ({ + history: history.current, + position: historyPosition.current, + capacity, + back: (amount: number = 1) => { + // don't do anything if we already at the left border + if (!historyPosition.current) { + return; + } + + innerSetState(() => { + historyPosition.current -= Math.min(amount, historyPosition.current); + + return history.current[historyPosition.current]; + }); + }, + forward: (amount: number = 1) => { + // don't do anything if we already at the right border + if (historyPosition.current === history.current.length - 1) { + return; + } + + innerSetState(() => { + historyPosition.current = Math.min(historyPosition.current + amount, history.current.length - 1); + + return history.current[historyPosition.current]; + }); + }, + go: (position: number) => { + if (position === historyPosition.current) { + return; + } + + innerSetState(() => { + historyPosition.current = + position < 0 + ? Math.max(history.current.length + position, 0) + : Math.min(history.current.length - 1, position); + + return history.current[historyPosition.current]; + }); + }, + }), + [state] + ); + + return [state, setState, historyState]; +} diff --git a/tests/useStateWithHistory.test.ts b/tests/useStateWithHistory.test.ts new file mode 100644 index 0000000000..bdc15e1650 --- /dev/null +++ b/tests/useStateWithHistory.test.ts @@ -0,0 +1,83 @@ +import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks'; +import { useRef } from 'react'; +import { UseStateHistoryReturn, useStateWithHistory } from '../src/useStateWithHistory'; +import { InitialHookState } from '../src/util/resolveHookState'; + +describe('useStateWithHistory', () => { + it('should be defined', () => { + expect(useStateWithHistory).toBeDefined(); + }); + + function getHook( + initialState?: InitialHookState, + initialHistory?: S[], + initialCapacity?: number + ): RenderHookResult<{ state?: S; history?: S[]; capacity?: number }, [UseStateHistoryReturn, number]> { + return renderHook( + ({ state, history, capacity }) => { + const renders = useRef(0); + renders.current++; + return [useStateWithHistory(state, history, capacity), renders.current]; + }, + { + initialProps: { + state: initialState, + history: initialHistory, + capacity: initialCapacity, + } as { state?: S; history?: S[]; capacity?: number }, + } + ); + } + + it('should return state, state setter and history structure', () => { + const res = getHook(0).result.current[0]; + + expect(res).toStrictEqual([expect.any(Number), expect.any(Function), expect.any(Object)]); + expect(res[2]).toStrictEqual({ + history: expect.any(Array), + position: expect.any(Number), + capacity: expect.any(Number), + back: expect.any(Function), + forward: expect.any(Function), + go: expect.any(Function), + }); + }); + + it('should act like regular setState', () => { + const hook = getHook(() => 1); + + expect(hook.result.current[0][0]).toBe(1); + act(() => { + hook.result.current[0][1](321); + }); + expect(hook.result.current[0][0]).toBe(321); + act(() => { + hook.result.current[0][1](() => 111); + }); + expect(hook.result.current[0][0]).toBe(111); + }); + + it('should receive initial history and not push initial state there', () => { + const hook = getHook(1, [1, 2, 3]); + expect(hook.result.current[0][2].history).toEqual([1, 2, 3]); + }); + + it('should push initial state to the history if initialHistory is omitted', () => { + const hook = getHook(321, undefined); + expect(hook.result.current[0][2].history).toEqual([321]); + }); + + describe('capacity', () => { + it('should crop the initial history from the beginning if it exceeds capacity', () => { + const hook = getHook(1, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 5); + expect(hook.result.current[0][2].history).toEqual([6, 7, 8, 9, 10]); + }); + + it('should crop the history if capacity value has changed', () => { + const hook = getHook(1, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 20); + expect(hook.result.current[0][2].history).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + hook.rerender({ capacity: 5 }); + expect(hook.result.current[0][2].history).toEqual([6, 7, 8, 9, 10]); + }); + }); +}); From 4a41a933b082ce74ef438b84c6828f9611c5c58a Mon Sep 17 00:00:00 2001 From: xobotyi Date: Sun, 24 Nov 2019 22:29:58 +0300 Subject: [PATCH 08/10] tests(useStateWithHistory): tests are done 100% now; --- tests/useStateWithHistory.test.ts | 297 ++++++++++++++++++++++++++++-- 1 file changed, 277 insertions(+), 20 deletions(-) diff --git a/tests/useStateWithHistory.test.ts b/tests/useStateWithHistory.test.ts index bdc15e1650..4c1fc9dbba 100644 --- a/tests/useStateWithHistory.test.ts +++ b/tests/useStateWithHistory.test.ts @@ -8,23 +8,23 @@ describe('useStateWithHistory', () => { expect(useStateWithHistory).toBeDefined(); }); - function getHook( + function getHook( initialState?: InitialHookState, - initialHistory?: S[], - initialCapacity?: number - ): RenderHookResult<{ state?: S; history?: S[]; capacity?: number }, [UseStateHistoryReturn, number]> { + initialCapacity?: number, + initialHistory?: I[] + ): RenderHookResult<{ state?: S; history?: I[]; capacity?: number }, [UseStateHistoryReturn, number]> { return renderHook( ({ state, history, capacity }) => { const renders = useRef(0); renders.current++; - return [useStateWithHistory(state, history, capacity), renders.current]; + return [useStateWithHistory(state, capacity, history), renders.current]; }, { initialProps: { state: initialState, history: initialHistory, capacity: initialCapacity, - } as { state?: S; history?: S[]; capacity?: number }, + } as { state?: S; history?: I[]; capacity?: number }, } ); } @@ -57,27 +57,284 @@ describe('useStateWithHistory', () => { expect(hook.result.current[0][0]).toBe(111); }); - it('should receive initial history and not push initial state there', () => { - const hook = getHook(1, [1, 2, 3]); + it('should receive initial history', () => { + const hook = getHook(3, undefined, [1, 2, 3]); expect(hook.result.current[0][2].history).toEqual([1, 2, 3]); }); - it('should push initial state to the history if initialHistory is omitted', () => { - const hook = getHook(321, undefined); - expect(hook.result.current[0][2].history).toEqual([321]); + it('should push initial state to initial history if last element not equals it', () => { + const hook = getHook(1, undefined, [1, 2, 3]); + expect(hook.result.current[0][2].history).toEqual([1, 2, 3, 1]); }); - describe('capacity', () => { - it('should crop the initial history from the beginning if it exceeds capacity', () => { - const hook = getHook(1, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 5); - expect(hook.result.current[0][2].history).toEqual([6, 7, 8, 9, 10]); + it('should crop initial history in case it exceeds capacity', () => { + const hook = getHook(10, 5, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + expect(hook.result.current[0][2].history).toEqual([6, 7, 8, 9, 10]); + }); + + it('should apply capacity change only with next state set', () => { + const hook = getHook(5, 5, [1, 2, 3, 4, 5]); + expect(hook.result.current[0][2].capacity).toBe(5); + expect(hook.result.current[0][2].history).toEqual([1, 2, 3, 4, 5]); + + hook.rerender({ state: 5, capacity: 4, history: [1, 2, 3, 4, 5] }); + + expect(hook.result.current[0][2].capacity).toBe(5); + expect(hook.result.current[0][2].history).toEqual([1, 2, 3, 4, 5]); + + act(() => { + hook.result.current[0][1](() => 111); }); - it('should crop the history if capacity value has changed', () => { - const hook = getHook(1, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 20); - expect(hook.result.current[0][2].history).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); - hook.rerender({ capacity: 5 }); - expect(hook.result.current[0][2].history).toEqual([6, 7, 8, 9, 10]); + expect(hook.result.current[0][0]).toBe(111); + expect(hook.result.current[0][2].capacity).toBe(4); + expect(hook.result.current[0][2].history).toEqual([3, 4, 5, 111]); + + hook.rerender({ state: 5, capacity: 3, history: [1, 2, 3, 4, 5] }); + expect(hook.result.current[0][2].capacity).toBe(4); + expect(hook.result.current[0][2].history).toEqual([3, 4, 5, 111]); + + act(() => { + hook.result.current[0][1](() => 321); + }); + + expect(hook.result.current[0][0]).toBe(321); + expect(hook.result.current[0][2].capacity).toBe(3); + expect(hook.result.current[0][2].history).toEqual([5, 111, 321]); + }); + + describe('history.back()', () => { + it('should cause rerender', () => { + const hook = getHook(5, 5, [1, 2, 3, 4, 5]); + + expect(hook.result.current[1]).toBe(1); + act(() => { + hook.result.current[0][2].back(1); + }); + expect(hook.result.current[1]).toBe(2); + }); + + it('should travel history back one step at a time if called without arguments', () => { + const hook = getHook(5, 5, [1, 2, 3, 4, 5]); + + expect(hook.result.current[0][0]).toBe(5); + + act(() => { + hook.result.current[0][2].back(); + }); + expect(hook.result.current[0][0]).toBe(4); + act(() => { + hook.result.current[0][2].back(); + }); + expect(hook.result.current[0][0]).toBe(3); + act(() => { + hook.result.current[0][2].back(); + }); + expect(hook.result.current[0][0]).toBe(2); + }); + + it('should travel history back by arbitrary amount of elements passed as 1st argument', () => { + const hook = getHook(5, 5, [1, 2, 3, 4, 5]); + + expect(hook.result.current[0][0]).toBe(5); + + act(() => { + hook.result.current[0][2].back(2); + }); + expect(hook.result.current[0][0]).toBe(3); + + act(() => { + hook.result.current[0][2].back(3); + }); + expect(hook.result.current[0][0]).toBe(1); + }); + + it('should stop on first element if traveled to the left border', () => { + const hook = getHook(5, 5, [1, 2, 3, 4, 5]); + + expect(hook.result.current[0][0]).toBe(5); + + act(() => { + hook.result.current[0][2].back(6); + }); + expect(hook.result.current[0][0]).toBe(1); + + act(() => { + hook.result.current[0][2].back(150); + }); + expect(hook.result.current[0][0]).toBe(1); + + act(() => { + hook.result.current[0][2].back(); + }); + expect(hook.result.current[0][0]).toBe(1); + }); + }); + + describe('history.forward()', () => { + it('should cause rerender', () => { + const hook = getHook(5, 5, [1, 2, 3, 4, 5]); + + act(() => { + hook.result.current[0][2].back(3); + }); + expect(hook.result.current[1]).toBe(2); + act(() => { + hook.result.current[0][2].forward(1); + }); + expect(hook.result.current[1]).toBe(3); + }); + + it('should travel history forward one step at a time if called without arguments', () => { + const hook = getHook(5, 5, [1, 2, 3, 4, 5]); + + act(() => { + hook.result.current[0][2].back(6); + }); + expect(hook.result.current[0][0]).toBe(1); + + act(() => { + hook.result.current[0][2].forward(); + }); + expect(hook.result.current[0][0]).toBe(2); + + act(() => { + hook.result.current[0][2].forward(); + }); + expect(hook.result.current[0][0]).toBe(3); }); + + it('should travel history forward by arbitrary amount of elements passed as 1st argument', () => { + const hook = getHook(5, 5, [1, 2, 3, 4, 5]); + + act(() => { + hook.result.current[0][2].back(6); + }); + expect(hook.result.current[0][0]).toBe(1); + + act(() => { + hook.result.current[0][2].forward(2); + }); + expect(hook.result.current[0][0]).toBe(3); + + act(() => { + hook.result.current[0][2].forward(2); + }); + expect(hook.result.current[0][0]).toBe(5); + }); + + it('should stop on last element if traveled to the right border', () => { + const hook = getHook(5, 5, [1, 2, 3, 4, 5]); + + act(() => { + hook.result.current[0][2].back(6); + }); + expect(hook.result.current[0][0]).toBe(1); + + act(() => { + hook.result.current[0][2].forward(7); + }); + expect(hook.result.current[0][0]).toBe(5); + + act(() => { + hook.result.current[0][2].forward(250); + }); + expect(hook.result.current[0][0]).toBe(5); + }); + }); + + describe('history.go()', () => { + it('should cause rerender', () => { + const hook = getHook(5, 5, [1, 2, 3, 4, 5]); + + expect(hook.result.current[1]).toBe(1); + act(() => { + hook.result.current[0][2].go(1); + }); + expect(hook.result.current[1]).toBe(2); + }); + + it('should go to arbitrary position passed as 1st element', () => { + const hook = getHook(5, 5, [1, 2, 3, 4, 5]); + + act(() => { + hook.result.current[0][2].go(1); + }); + expect(hook.result.current[0][0]).toBe(2); + + act(() => { + hook.result.current[0][2].go(3); + }); + expect(hook.result.current[0][0]).toBe(4); + + act(() => { + hook.result.current[0][2].go(0); + }); + expect(hook.result.current[0][0]).toBe(1); + }); + + it('should count from the right if position is negative', () => { + const hook = getHook(5, 5, [1, 2, 3, 4, 5]); + + act(() => { + hook.result.current[0][2].go(-1); + }); + expect(hook.result.current[0][0]).toBe(5); + + act(() => { + hook.result.current[0][2].go(-3); + }); + expect(hook.result.current[0][0]).toBe(3); + + act(() => { + hook.result.current[0][2].go(-5); + }); + expect(hook.result.current[0][0]).toBe(1); + }); + + it('should properly handle too big values', () => { + const hook = getHook(5, 5, [1, 2, 3, 4, 5]); + + act(() => { + hook.result.current[0][2].go(-150); + }); + expect(hook.result.current[0][0]).toBe(1); + + act(() => { + hook.result.current[0][2].go(250); + }); + expect(hook.result.current[0][0]).toBe(5); + }); + + it('should do nothing is position is equals current', () => { + const hook = getHook(5, 5, [1, 2, 3, 4, 5]); + + act(() => { + hook.result.current[0][2].go(4); + }); + expect(hook.result.current[1]).toBe(1); + expect(hook.result.current[0][0]).toBe(5); + expect(hook.result.current[1]).toBe(1); + }); + }); + + it('should pop elements to the right when setting state being not in the end of history', () => { + const hook = getHook(5, 5, [1, 2, 3, 4, 5]); + act(() => { + hook.result.current[0][2].back(2); + }); + expect(hook.result.current[0][2].history).toEqual([1, 2, 3, 4, 5]); + act(() => { + hook.result.current[0][1](8); + }); + expect(hook.result.current[0][2].history).toEqual([1, 2, 3, 8]); + }); + + it('should throw if capacity is 0 or negative', () => { + let hook = getHook(3, -1); + expect(hook.result.error).toEqual(new Error(`Capacity has to be greater than 1, got '-1'`)); + + hook = getHook(3, 0); + expect(hook.result.error).toEqual(new Error(`Capacity has to be greater than 1, got '0'`)); }); }); From 483460bfbc3430d6de5fe728e22223fc9186e8d0 Mon Sep 17 00:00:00 2001 From: xobotyi Date: Sun, 24 Nov 2019 22:46:01 +0300 Subject: [PATCH 09/10] docs(useStateWithHistory): update readme, story and docs; fix(useStateWithHistory): make the history position work properly on init; --- README.md | 1 + docs/{useStateHistory.md => useStateWithHistory.md} | 6 +++--- ...StateHistory.story.tsx => useStateWithHistory.story.tsx} | 6 +++--- src/useStateWithHistory.ts | 4 +++- 4 files changed, 10 insertions(+), 7 deletions(-) rename docs/{useStateHistory.md => useStateWithHistory.md} (93%) rename src/__stories__/{useStateHistory.story.tsx => useStateWithHistory.story.tsx} (91%) diff --git a/README.md b/README.md index e35d791c00..6f370fb078 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ - [`useSet`](./docs/useSet.md) — tracks state of a Set. [![][img-demo]](https://codesandbox.io/s/bold-shtern-6jlgw) - [`useQueue`](./docs/useQueue.md) — implements simple queue. - [`useStateValidator`](./docs/useStateValidator.md) — tracks state of an object. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usestatevalidator--demo) + - [`useStateWithHistory`](./docs/useStateWithHistory.md) — stores previous state values and provides handles to travel through them. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usestatewithhistory--demo) - [`useMultiStateValidator`](./docs/useMultiStateValidator.md) — alike the `useStateValidator`, but tracks multiple states at a time. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usemultistatevalidator--demo) - [`useMediatedState`](./docs/useMediatedState.md) — like the regular `useState` but with mediation by custom function. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usemediatedstate--demo) - [`useFirstMountState`](./docs/useFirstMountState.md) — check if current render is first. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usefirstmountstate--demo) diff --git a/docs/useStateHistory.md b/docs/useStateWithHistory.md similarity index 93% rename from docs/useStateHistory.md rename to docs/useStateWithHistory.md index 34502228c0..5d6ac85c09 100644 --- a/docs/useStateHistory.md +++ b/docs/useStateWithHistory.md @@ -7,10 +7,10 @@ Stores defined amount of previous state values and provides handles to travel th ## Reference ```typescript -const [state, setState, stateHistory] = useStateHistory( +const [state, setState, stateHistory] = useStateWithHistory( initialState?: S | (()=>S), - initialHistory?: S, - historyCapacity?: number = 0 + historyCapacity?: number = 10, + initialHistory?: S ); ``` diff --git a/src/__stories__/useStateHistory.story.tsx b/src/__stories__/useStateWithHistory.story.tsx similarity index 91% rename from src/__stories__/useStateHistory.story.tsx rename to src/__stories__/useStateWithHistory.story.tsx index 442b0ab7c6..9d267bb877 100644 --- a/src/__stories__/useStateHistory.story.tsx +++ b/src/__stories__/useStateWithHistory.story.tsx @@ -5,7 +5,7 @@ import { useCounter, useStateWithHistory } from '..'; import ShowDocs from './util/ShowDocs'; const Demo = () => { - const [state, setState, history] = useStateWithHistory('', ['hello', 'world']); + const [state, setState, history] = useStateWithHistory('', 10, ['hello', 'world']); const inputRef = useRef(null); const [stepSize, { set: setStepSize }] = useCounter(1, 3, 1); @@ -84,6 +84,6 @@ const Demo = () => { ); }; -storiesOf('State|useStateHistory', module) - .add('Docs', () => ) +storiesOf('State|useStateWithHistory', module) + .add('Docs', () => ) .add('Demo', () => ); diff --git a/src/useStateWithHistory.ts b/src/useStateWithHistory.ts index 43a60e941e..a489293223 100644 --- a/src/useStateWithHistory.ts +++ b/src/useStateWithHistory.ts @@ -32,7 +32,7 @@ export function useStateWithHistory( const isFirstMount = useFirstMountState(); const [state, innerSetState] = useState(initialState as S); const history = useRef((initialHistory ?? []) as S[]); - const historyPosition = useRef(history.current.length && history.current.length - 1); + const historyPosition = useRef(0); // do the states manipulation only on first mount, no sense to load re-renders with useless calculations if (isFirstMount) { @@ -50,6 +50,8 @@ export function useStateWithHistory( // initiate the history with initial state history.current.push(initialState as I); } + + historyPosition.current = history.current.length && history.current.length - 1; } const setState = useCallback( From 0d508f2d0ea34f7da1f45abb4062584e63aef15b Mon Sep 17 00:00:00 2001 From: xobotyi Date: Mon, 25 Nov 2019 12:49:02 +0300 Subject: [PATCH 10/10] fix: remove accidentally duplicated and old files; --- docs/useStateHistory.md | 24 ------ src/__stories__/useStateHistory.story.tsx | 89 --------------------- src/__stories__/util/resolveHookState.ts | 7 -- src/useStateHistory.ts | 96 ----------------------- 4 files changed, 216 deletions(-) delete mode 100644 docs/useStateHistory.md delete mode 100644 src/__stories__/useStateHistory.story.tsx delete mode 100644 src/__stories__/util/resolveHookState.ts delete mode 100644 src/useStateHistory.ts diff --git a/docs/useStateHistory.md b/docs/useStateHistory.md deleted file mode 100644 index 9e88e573cb..0000000000 --- a/docs/useStateHistory.md +++ /dev/null @@ -1,24 +0,0 @@ -# `useStateHistory` - -Stores defined amount of previous state values and provides handles to travel through them. - -## Usage - -## Reference - -```typescript -const [state, setState, stateHistory] = useStateHistory( - initialState?: S | (()=>S), - initialHistory?: S, - historyCapacity?: number = 0 -); -``` - -- **`state`**, **`setState`** and **`initialState`** are exactly the same with native React's `useState` hook; -- **`historyCapacity`** - amount of history entries that hold by storage; -- **`initialHistory`** - if defined it will be used as initial history value, otherwise history will equals `[ initialState ]`. -If entries amount is greater than `historyCapacity` parameter it wont be modified on init but will be trimmed on next `setState`; -- **`stateHistory`** - an object containing history state: - - **`history`**_`: S[]`_ - an array holding history entries; - - **`position`**_`: number`_ - current position _index_ in history; - diff --git a/src/__stories__/useStateHistory.story.tsx b/src/__stories__/useStateHistory.story.tsx deleted file mode 100644 index e24d1e1b9d..0000000000 --- a/src/__stories__/useStateHistory.story.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { storiesOf } from '@storybook/react'; -import * as React from 'react'; -import { useCallback, useRef } from 'react'; -import { useCounter, useStateHistory } from '..'; -import ShowDocs from './util/ShowDocs'; - -const Demo = () => { - const [state, setState, history] = useStateHistory('', ['hello', 'world']); - const inputRef = useRef(null); - - const [stepSize, { set: setStepSize }] = useCounter(1, 3, 1); - - const handleFormSubmit = useCallback( - (e: React.FormEvent) => { - e.preventDefault(); - state !== inputRef.current!.value && setState(inputRef.current!.value); - }, - [state] - ); - - const handleBackClick = useCallback( - (e: React.MouseEvent) => { - if (e.currentTarget.disabled) { - return; - } - - history.back(stepSize); - }, - [history, stepSize] - ); - - const handleForwardClick = useCallback( - (e: React.MouseEvent) => { - if (e.currentTarget.disabled) { - return; - } - - history.forward(stepSize); - }, - [history, stepSize] - ); - - const handleStepSizeChange = useCallback( - (e: React.FormEvent) => { - setStepSize((e.currentTarget.value as any) * 1); - }, - [stepSize] - ); - - return ( -
-
-
- - -
-
- -
- Current state: {state} -
-
- - -   Step size:  - -
- -
-
Current history
-
') - .replace(/ /g, ' '), - }} - /> -
-
- ); -}; - -storiesOf('State|useStateHistory', module) - .add('Docs', () => ) - .add('Demo', () => ); diff --git a/src/__stories__/util/resolveHookState.ts b/src/__stories__/util/resolveHookState.ts deleted file mode 100644 index 076da7bd32..0000000000 --- a/src/__stories__/util/resolveHookState.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function resolveHookState(state: S | (() => S)): S { - if (typeof state === 'function') { - return (state as () => S)(); - } - - return state; -} diff --git a/src/useStateHistory.ts b/src/useStateHistory.ts deleted file mode 100644 index 17e099b3c9..0000000000 --- a/src/useStateHistory.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Dispatch, SetStateAction, useCallback, useMemo, useRef, useState } from 'react'; -import { resolveHookState } from './__stories__/util/resolveHookState'; - -interface HistoryState { - history: S[]; - position: number; - capacity: number; - back: (amount?: number) => void; - forward: (amount?: number) => void; - go: (position: number) => void; -} - -type UseStateHistoryReturn = [S, Dispatch>, HistoryState]; - -export function useStateHistory( - initialState: S | (() => S), - initialHistory?: S[], - capacity?: number -): UseStateHistoryReturn; -export function useStateHistory(): UseStateHistoryReturn; -export function useStateHistory( - initialState?: S | (() => S), - initialHistory?: S[], - capacity: number = 10 -): UseStateHistoryReturn { - const [state, innerSetState] = useState(initialState); - const history = useRef<(S | undefined)[]>(initialHistory || [resolveHookState(initialState)]); - const historyPosition = useRef(history.current.length - 1); - - const setState = useCallback( - (newState: S | (() => S)) => { - innerSetState(() => { - const s = resolveHookState(newState); - - if (history.current.length && historyPosition.current < history.current.length - 1) { - history.current.splice(historyPosition.current, history.current.length - historyPosition.current); - } - - historyPosition.current = history.current.push(s) - 1; - - if (historyPosition.current > 9) { - history.current.splice(0, historyPosition.current - 9); - historyPosition.current = 9; - } - - return s; - }); - }, - [state] - ) as Dispatch>; - - const historyState = useMemo( - () => ({ - history: history.current, - position: historyPosition.current, - capacity, - back: (amount: number = 1) => { - if (!historyPosition.current) { - return; - } - - innerSetState(() => { - historyPosition.current -= Math.min(amount, historyPosition.current); - - return history.current[historyPosition.current]; - }); - }, - forward: (amount: number = 1) => { - if (historyPosition.current >= history.current.length - 1) { - return; - } - - innerSetState(() => { - historyPosition.current += Math.min(amount, history.current.length - 1 - historyPosition.current); - - return history.current[historyPosition.current]; - }); - }, - go: (pos: number) => { - if (pos === 0) { - return; - } - - innerSetState(() => { - historyPosition.current = - pos < 0 ? Math.max(history.current.length - 1 - pos, 0) : Math.min(history.current.length - 1, pos); - - return history.current[historyPosition.current]; - }); - }, - }), - [state, capacity] - ); - - return [state, setState, historyState]; -}