Skip to content

Commit 94995dd

Browse files
vitPinchukasimonok
andauthored
Add useSavedState and useLocalStorage (#77)
* add storage state * Update useSavedState to the latest version * Fix lint errors --------- Co-authored-by: asimonok <[email protected]>
1 parent fe9432f commit 94995dd

File tree

9 files changed

+294
-2
lines changed

9 files changed

+294
-2
lines changed

Diff for: package-lock.json

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: packages/components/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -89,5 +89,5 @@
8989
"typecheck": "tsc --emitDeclarationOnly false --noEmit"
9090
},
9191
"types": "dist/index.d.ts",
92-
"version": "3.9.0"
92+
"version": "4.0.0"
9393
}

Diff for: packages/components/src/hooks/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ export * from './useData';
66
export * from './useDatasourceRequest';
77
export * from './useDatasources';
88
export * from './useFormBuilder';
9+
export * from './useLocalStorage';
10+
export * from './useSavedState';
+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
3+
import { useLocalStorage } from './useLocalStorage';
4+
5+
describe('Use Local Storage', () => {
6+
const key = 'testKey';
7+
const version = 1;
8+
9+
beforeEach(() => {
10+
window.localStorage.clear();
11+
});
12+
13+
it('Should return undefined if item does not exist', async () => {
14+
const { result } = renderHook(() => useLocalStorage(key, version));
15+
const data = await result.current.get();
16+
expect(data).toBeUndefined();
17+
});
18+
19+
it('Should return data if item exists with correct version', async () => {
20+
const { result } = renderHook(() => useLocalStorage(key, version));
21+
22+
window.localStorage.setItem(key, JSON.stringify({ version, data: 'testData' }));
23+
24+
const data = await result.current.get();
25+
expect(data).toBe('testData');
26+
});
27+
28+
it('Should return undefined if item does not exist correct version', async () => {
29+
const { result } = renderHook(() => useLocalStorage(key, version));
30+
31+
window.localStorage.setItem(key, JSON.stringify({ version: 2, data: 'testData' }));
32+
33+
const data = await result.current.get();
34+
expect(data).toBeUndefined();
35+
});
36+
37+
it('Should update localStorage with new data', async () => {
38+
const { result } = renderHook(() => useLocalStorage(key, version));
39+
40+
await act(async () => {
41+
await result.current.update('newData');
42+
});
43+
44+
const data = await result.current.get();
45+
expect(data).toBe('newData');
46+
});
47+
48+
it('Should update localStorage with correct version', async () => {
49+
const { result } = renderHook(() => useLocalStorage(key, version));
50+
51+
await act(async () => {
52+
await result.current.update('newData');
53+
});
54+
55+
const storedData = JSON.parse(window.localStorage.getItem(key)!);
56+
expect(storedData).toEqual({ version, data: 'newData' });
57+
});
58+
});

Diff for: packages/components/src/hooks/useLocalStorage.ts

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { useCallback, useMemo } from 'react';
2+
3+
/**
4+
* Local Storage Model
5+
*/
6+
export const useLocalStorage = (key: string, version: number) => {
7+
const get = useCallback(async () => {
8+
const json = window.localStorage.getItem(key);
9+
if (json) {
10+
const parsed = JSON.parse(json);
11+
12+
if (parsed?.version === version) {
13+
return parsed.data;
14+
}
15+
16+
return undefined;
17+
}
18+
19+
return undefined;
20+
}, [key, version]);
21+
22+
/**
23+
* Update
24+
*/
25+
const update = useCallback(
26+
async <T>(data: T) => {
27+
window.localStorage.setItem(
28+
key,
29+
JSON.stringify({
30+
version,
31+
data,
32+
})
33+
);
34+
return data;
35+
},
36+
[key, version]
37+
);
38+
39+
return useMemo(
40+
() => ({
41+
get,
42+
update,
43+
}),
44+
[get, update]
45+
);
46+
};

Diff for: packages/components/src/hooks/useSavedState.test.ts

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
3+
import { useLocalStorage } from './useLocalStorage';
4+
import { useSavedState } from './useSavedState';
5+
6+
/**
7+
* Mock hook
8+
*/
9+
jest.mock('./useLocalStorage', () => ({
10+
useLocalStorage: jest.fn(),
11+
}));
12+
13+
describe('Use Saved State', () => {
14+
it('Should get initial value from model', async () => {
15+
jest.mocked(useLocalStorage).mockImplementation(() => ({
16+
get: jest.fn(() => Promise.resolve(111)),
17+
update: jest.fn(),
18+
}));
19+
20+
const { result } = await act(async () => renderHook(() => useSavedState('abc', 123)));
21+
22+
expect(result.current[0]).toEqual(111);
23+
});
24+
25+
it('Should merge initial value if object', async () => {
26+
const getValue = jest.fn(() =>
27+
Promise.resolve({
28+
a: 'saved',
29+
})
30+
);
31+
jest.mocked(useLocalStorage).mockImplementation(() => ({
32+
get: getValue,
33+
update: jest.fn(),
34+
}));
35+
36+
const { result } = await act(async () =>
37+
renderHook(() =>
38+
useSavedState(
39+
'abc',
40+
{
41+
a: 'initial',
42+
b: 'initial',
43+
},
44+
0,
45+
({ a }) => ({ a })
46+
)
47+
)
48+
);
49+
50+
expect(result.current[0]).toEqual({
51+
a: 'saved',
52+
b: 'initial',
53+
});
54+
});
55+
56+
it('Should save value in model', async () => {
57+
const update = jest.fn();
58+
jest.mocked(useLocalStorage).mockImplementation(() => ({
59+
get: jest.fn(() => Promise.resolve(111)),
60+
update,
61+
}));
62+
63+
const { result } = await act(async () => renderHook(() => useSavedState('abc', 123)));
64+
65+
await act(async () => result.current[1](123));
66+
67+
expect(update).toHaveBeenCalledWith(123);
68+
});
69+
70+
it('Should save value by function', async () => {
71+
const update = jest.fn();
72+
jest.mocked(useLocalStorage).mockImplementation(() => ({
73+
get: jest.fn(() => Promise.resolve(null)),
74+
update,
75+
}));
76+
77+
const { result } = await act(async () =>
78+
renderHook(() =>
79+
useSavedState('abc', {
80+
a: 'hello',
81+
b: 'hello',
82+
})
83+
)
84+
);
85+
86+
await act(async () =>
87+
result.current[1]((value) => ({
88+
...value,
89+
b: 'bye',
90+
}))
91+
);
92+
93+
expect(update).toHaveBeenCalledWith({
94+
a: 'hello',
95+
b: 'bye',
96+
});
97+
});
98+
});

Diff for: packages/components/src/hooks/useSavedState.ts

+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { merge } from 'lodash';
2+
import { Dispatch, useCallback, useEffect, useRef, useState } from 'react';
3+
4+
import { RecursivePartial } from '../types';
5+
import { useLocalStorage } from './useLocalStorage';
6+
7+
/**
8+
* Get State For Save
9+
*/
10+
type GetStateForSave<TValue> = (value: TValue) => TValue extends object ? RecursivePartial<TValue> : TValue;
11+
12+
/**
13+
* Set Value Action
14+
*/
15+
type SetValueAction<TValue> = TValue | ((value: TValue) => TValue);
16+
17+
/**
18+
* Use Saved State
19+
*/
20+
export const useSavedState = <TValue extends object | string | number | []>(
21+
key: string,
22+
initialValue: TValue,
23+
version = 2,
24+
getStateForSave: GetStateForSave<TValue> = (value) => value as never
25+
): [TValue, Dispatch<SetValueAction<TValue>>, boolean] => {
26+
/**
27+
* Value
28+
*/
29+
const [value, setValue] = useState(initialValue);
30+
const [loaded, setLoaded] = useState(false);
31+
32+
/**
33+
* Get state for save
34+
*/
35+
const getStateForSaveRef = useRef<GetStateForSave<TValue>>(getStateForSave);
36+
37+
/**
38+
* Local Storage Model
39+
*/
40+
const { get: getValue, update: saveValue } = useLocalStorage(key, version);
41+
42+
/**
43+
* Load Initial Value
44+
*/
45+
useEffect(() => {
46+
const getSavedValue = async () => {
47+
const savedValue = await getValue();
48+
49+
if (savedValue) {
50+
if (typeof savedValue === 'object' && !Array.isArray(savedValue)) {
51+
setValue((value) => merge({ ...(value as object) }, savedValue));
52+
} else {
53+
setValue(savedValue);
54+
}
55+
}
56+
setLoaded(true);
57+
};
58+
59+
getSavedValue();
60+
}, [getValue]);
61+
62+
/**
63+
* Update Value
64+
*/
65+
const update = useCallback(
66+
(updated: SetValueAction<TValue>) => {
67+
setValue((value) => {
68+
const newValue = typeof updated === 'function' ? updated(value) : updated;
69+
saveValue(getStateForSaveRef.current(newValue));
70+
return newValue;
71+
});
72+
},
73+
[saveValue]
74+
);
75+
76+
return [value, update, loaded];
77+
};

Diff for: packages/components/src/types/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './editors';
22
export * from './form-builder';
3+
export * from './storage';

Diff for: packages/components/src/types/storage.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Recursive Partial
3+
*/
4+
export type RecursivePartial<T> = {
5+
[P in keyof T]?: T[P] extends Array<infer U>
6+
? Array<RecursivePartial<U>>
7+
: T[P] extends object | undefined
8+
? RecursivePartial<T[P]>
9+
: T[P];
10+
};

0 commit comments

Comments
 (0)