Skip to content

Commit

Permalink
Add support for nested fields
Browse files Browse the repository at this point in the history
  • Loading branch information
Kyzegs committed Aug 25, 2023
1 parent e251378 commit 025294f
Show file tree
Hide file tree
Showing 5 changed files with 87 additions and 37 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ module.exports = {
'*.tsx',
],
rules: {
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/ban-types': ['error', {
types: {
null: false,
Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
"@types/flat": "^5.0.2",
"@types/lodash": "^4.14.197",
"@types/react": "^18.1.0",
"@types/testing-library__jest-dom": "^5.14.3",
"@typescript-eslint/eslint-plugin": ">=6.0.0",
Expand All @@ -57,13 +59,14 @@
"eslint-config-xo": "^0.43.1",
"eslint-config-xo-typescript": "^1.0.1",
"eslint-plugin-react": "^7.33.1",
"flat": "^5.0.2",
"jsdom": "^22.1.0",
"lodash": "^4.17.21",
"prettier": "^2.6.2",
"pretty-quick": "^3.1.3",
"react": "18.1.0",
"react-dom": "18.1.0",
"react-hook-form": "7.31.1",
"remeda": "^1.24.0",
"tsup": "^5.12.7",
"typescript": "^4.9.5",
"vitest": "^0.34.1"
Expand Down
32 changes: 25 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 24 additions & 23 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import { useEffect } from 'react';
import * as R from 'remeda';
import {
type FieldValue,
type FieldValues,
type SetFieldValue,
type UseFormWatch,
} from 'react-hook-form';
import lodash from 'lodash';
import { type FieldPath, type FieldValues, get, set, type UseFormSetValue, type UseFormWatch } from 'react-hook-form';
import flatten from 'flat';

export type FormPersistConfig<TFieldValues extends FieldValues = FieldValues> = {
storage?: Storage;
watch: UseFormWatch<TFieldValues>;
setValue: SetFieldValue<TFieldValues>;
exclude?: Array<FieldValue<TFieldValues>>;
onDataRestored?: (data: any) => void;
setValue: UseFormSetValue<TFieldValues>;
exclude?: Array<FieldPath<TFieldValues>>;
onDataRestored?: (data: Partial<TFieldValues>) => void;
validate?: boolean;
dirty?: boolean;
touch?: boolean;
Expand Down Expand Up @@ -45,25 +41,28 @@ const useFormPersist = <TFieldValues extends FieldValues = FieldValues>(
};

useEffect(() => {
const str = getStorage().getItem(name);
const payload = getStorage().getItem(name);

if (str) {
const { _timestamp = null, ...values }: { _timestamp: number | null } & Partial<TFieldValues> = JSON.parse(str);
const dataRestored: Partial<TFieldValues> = {};
if (payload) {
const data = JSON.parse(payload);
const { _timestamp = null }: { _timestamp: number | null } = data;
const { ...values }: Partial<TFieldValues> = data;
const restored: Partial<TFieldValues> = {};

if (timeout && _timestamp && (Date.now() - _timestamp) > timeout) {
onTimeout();
clearStorage();
return;
}

Object.entries(values)
.filter(([key, value]) => !exclude.includes(key) && !R.equals(value, watchedValues[key]))
const paths = flatten<Partial<TFieldValues>, Record<FieldPath<TFieldValues>, TFieldValues[keyof TFieldValues]>>(values);
// @ts-expect-error
const entries: Array<[FieldPath<TFieldValues>, TFieldValues[keyof TFieldValues]]> = Object.entries(paths);

entries.filter(([key, value]) => !exclude.includes(key) && !lodash.isEqual(value, get(watchedValues, key)))
.forEach(([key, value]) => {
// @ts-expect-error Should be able to index it just fine
dataRestored[key] = value;
set(restored, key, value);

// eslint-disable-next-line @typescript-eslint/no-unsafe-call
setValue(key, value, {
shouldValidate: validate,
shouldDirty: dirty,
Expand All @@ -72,17 +71,19 @@ const useFormPersist = <TFieldValues extends FieldValues = FieldValues>(
});

if (onDataRestored) {
onDataRestored(dataRestored);
onDataRestored(restored);
}
}
}, [storage, name, onDataRestored, setValue]);

useEffect(() => {
const values = R.omit(watchedValues, exclude);
const values = lodash(watchedValues)
.omit(exclude)
.omitBy(value => typeof value === 'object' && lodash.isEmpty(value))
.value();

if (Object.entries(values).length) {
if (!lodash.isEmpty(values)) {
if (timeout !== undefined) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
values._timestamp = Date.now();
}
Expand Down
39 changes: 33 additions & 6 deletions tests/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ const Form = ({ config = {}, props = {} }: { config?: Partial<FormPersistConfig>
baz:
<input id='baz' {...register('baz')} />
</label>
<label>
qux:
<input id='qux' {...register('qux.quux')} />
</label>
<button type='submit'>submit</button>
</form>
);
Expand All @@ -46,6 +50,9 @@ describe('react-hook-form-persistant', () => {
foo: 'foo',
bar: '',
baz: '',
qux: {
quux: '',
},
});
});

Expand All @@ -64,11 +71,12 @@ describe('react-hook-form-persistant', () => {
});

test('should not persist excluded fields', async () => {
render(<Form config={{ exclude: ['baz', 'foo'] }} />);
render(<Form config={{ exclude: ['baz', 'foo', 'qux.quux'] }} />);

await userEvent.type(screen.getByLabelText('foo:'), 'foo');
await userEvent.type(screen.getByLabelText('bar:'), 'bar');
await userEvent.type(screen.getByLabelText('baz:'), 'baz');
await userEvent.type(screen.getByLabelText('qux:'), 'qux');

expect(JSON.parse(window.sessionStorage.getItem(STORAGE_KEY) ?? '{}')).toEqual({
bar: 'bar',
Expand All @@ -84,12 +92,16 @@ describe('react-hook-form-persistant', () => {
await userEvent.type(screen.getByLabelText('foo:'), 'foo');
await userEvent.type(screen.getByLabelText('bar:'), 'bar');
await userEvent.type(screen.getByLabelText('baz:'), 'baz');
await userEvent.type(screen.getByLabelText('qux:'), 'qux');

expect(spy).toBeCalled();
expect(JSON.parse(window.sessionStorage.getItem(STORAGE_KEY) ?? '{}')).toEqual({
bar: 'bar',
baz: 'baz',
foo: 'foo',
qux: {
quux: 'qux',
},
_timestamp: now,
});

Expand All @@ -106,9 +118,22 @@ describe('react-hook-form-persistant', () => {
test('should not set value if diff is equal', async () => {
const setValue = vi.fn(() => {});

window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ foo: 'bar' }));
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
foo: 'bar',
qux: { quux: 'qux' },
}));

render(<Form config={{ setValue }} props={{ defaultValues: { foo: 'bar' } }} />);
render(
<Form
config={{ setValue }}
props={{
defaultValues: {
foo: 'bar',
qux: { quux: 'qux' },
},
}}
/>,
);

expect(setValue).toHaveBeenCalledTimes(0);
});
Expand All @@ -126,12 +151,15 @@ describe('react-hook-form-persistant', () => {
test('should call onDataRestored callback', async () => {
const onDataRestored = vi.fn(() => {});

window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ foo: 'bar' }));
window.sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
foo: 'bar',
qux: { quux: 'qux' },
}));

render(<Form config={{ onDataRestored }} />);

expect(onDataRestored).toHaveBeenCalledOnce();
expect(onDataRestored).toHaveBeenCalledWith({ foo: 'bar' });
expect(onDataRestored).toHaveBeenCalledWith({ foo: 'bar', qux: { quux: 'qux' } });
});

test('should clear storage', async () => {
Expand All @@ -141,7 +169,6 @@ describe('react-hook-form-persistant', () => {
const { result: formPersistResult } = renderHook(() => useFormPersist(STORAGE_KEY, {
watch: formResult.current.watch,
setValue: formResult.current.setValue,

}));

formPersistResult.current.clear();
Expand Down

0 comments on commit 025294f

Please sign in to comment.