Skip to content

Commit

Permalink
feat(storage): add persist form to storagecloses #24
Browse files Browse the repository at this point in the history
  • Loading branch information
tehshin committed Jul 26, 2020
1 parent 8ee0067 commit 2ca8e6e
Show file tree
Hide file tree
Showing 10 changed files with 233 additions and 17 deletions.
48 changes: 44 additions & 4 deletions projects/ngneat/reactive-forms/src/lib/control-actions.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { ValidationErrors } from '@angular/forms';
import { ValidationErrors, FormArray as NgFormArray } from '@angular/forms';
import { defer, merge, Observable, of, Subscription } from 'rxjs';
import { distinctUntilChanged, map } from 'rxjs/operators';
import { distinctUntilChanged, map, tap, debounceTime } from 'rxjs/operators';
import { FormArray } from './formArray';
import { FormControl } from './formControl';
import { FormGroup } from './formGroup';
import { AbstractControl, ControlOptions, ControlState, ValidatorFn, ControlPath } from './types';
import {
AbstractControl,
ControlOptions,
ControlState,
ValidatorFn,
ControlPath,
PersistOptions,
ControlFactoryMap
} from './types';
import { coerceArray, isNil } from './utils';

function getControlValue<T>(control: AbstractControl<T>): T {
Expand Down Expand Up @@ -122,7 +130,7 @@ export function hasErrorAndDirty<T>(control: AbstractControl<T>, error: string,

export function markAllDirty<T>(control: FormArray<T> | FormGroup<T>): void {
control.markAsDirty({ onlySelf: true });
(control as any)._forEachChild(control => control.markAllAsDirty());
(control as any)._forEachChild(control => control.markAllAsDirty?.());
}

export function selectControlValue$<T, R>(
Expand All @@ -131,3 +139,35 @@ export function selectControlValue$<T, R>(
): Observable<R> {
return (control.value$ as Observable<any>).pipe(map(mapFn), distinctUntilChanged());
}

export function persistValue$<T>(control: FormGroup<T>, key: string, options: PersistOptions<T>): Observable<T> {
return control.valueChanges.pipe(
debounceTime(options.debounceTime),
tap(value => options.manager.setValue(key, value))
);
}

export function handleFormArrays<T>(
control: AbstractControl<T>,
formValue: T,
arrControlFactory: ControlFactoryMap<T>
) {
Object.keys(formValue).forEach(controlName => {
const value = formValue[controlName];
if (Array.isArray(value) && control.get(controlName) instanceof NgFormArray) {
if (!arrControlFactory || (arrControlFactory && !(controlName in arrControlFactory))) {
throw new Error(`Please provide arrControlFactory for ${controlName}`);
}
const current = control.get(controlName) as NgFormArray;
const fc = arrControlFactory[controlName];
clearFormArray(current);
value.forEach((v, i) => current.insert(i, fc(v)));
}
});
}

export function clearFormArray(control: NgFormArray) {
while (control.length !== 0) {
control.removeAt(0);
}
}
59 changes: 50 additions & 9 deletions projects/ngneat/reactive-forms/src/lib/formGroup.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { fakeAsync, tick } from '@angular/core/testing';
import { of, Subject } from 'rxjs';
import { FormControl } from './formControl';
import { FormGroup } from './formGroup';
import { FormArray } from './formArray';

type Person = {
name: string;
phone: {
num: number;
prefix: number;
};
skills: string[];
};

const errorFn = group => {
Expand All @@ -21,7 +24,8 @@ const createGroup = (withError = false) => {
phone: new FormGroup({
num: new FormControl(),
prefix: new FormControl()
})
}),
skills: new FormArray([])
},
{ validators: withError ? errorFn : [] }
);
Expand All @@ -32,11 +36,11 @@ describe('FormGroup', () => {
const control = createGroup();
const spy = jest.fn();
control.value$.subscribe(spy);
expect(spy).toHaveBeenCalledWith({ name: null, phone: { num: null, prefix: null } });
expect(spy).toHaveBeenCalledWith({ name: null, phone: { num: null, prefix: null }, skills: [] });
control.patchValue({
name: 'changed'
});
expect(spy).toHaveBeenCalledWith({ name: 'changed', phone: { num: null, prefix: null } });
expect(spy).toHaveBeenCalledWith({ name: 'changed', phone: { num: null, prefix: null }, skills: [] });
});

it('should disabledChanges$', () => {
Expand Down Expand Up @@ -94,30 +98,34 @@ describe('FormGroup', () => {
phone: {
num: 1,
prefix: 2
}
},
skills: []
})
);
expect(control.value).toEqual({
name: 'a',
phone: {
num: 1,
prefix: 2
}
},
skills: []
});

control.setValue({
name: 'd',
phone: {
num: 1,
prefix: 2
}
},
skills: []
});
expect(control.value).toEqual({
name: 'd',
phone: {
num: 1,
prefix: 2
}
},
skills: []
});
});

Expand All @@ -135,7 +143,8 @@ describe('FormGroup', () => {
phone: {
num: null,
prefix: null
}
},
skills: []
});

control.patchValue({
Expand All @@ -151,7 +160,8 @@ describe('FormGroup', () => {
phone: {
num: 1,
prefix: 2
}
},
skills: []
});
});

Expand Down Expand Up @@ -285,4 +295,35 @@ describe('FormGroup', () => {
control.patchValue({ name: 'Test' });
expect(spy).toHaveBeenCalledWith({ invalidName: true });
});

it('should persist', fakeAsync(() => {
const control = createGroup();
const debounceTime = 50;
const persistManager = {
getValue: jest.fn(),
setValue: jest.fn()
};
control.persist('key', { debounceTime, manager: persistManager }).subscribe();
control.getControl('name').setValue('ewan');
tick(debounceTime);
expect(persistManager.setValue).toHaveBeenCalledWith('key', control.value);
}));

it('should restore', () => {
const control = createGroup();
const person = { name: 'ewan', phone: { num: 5550153, prefix: 288 }, skills: ['acting', 'motorcycle'] };
const persistManager = {
getValue: jest.fn<Person, never>(() => person),
setValue: jest.fn()
};
const spy = jest.fn(value => new FormControl(value));
control.persist('key', {
manager: persistManager,
arrControlFactory: { skills: spy }
});
expect(persistManager.getValue).toHaveBeenCalledWith('key');
expect(spy).toHaveBeenCalledTimes(2);
expect(control.getControl('skills')).toHaveLength(2);
expect(control.value).toEqual(person);
});
});
29 changes: 26 additions & 3 deletions projects/ngneat/reactive-forms/src/lib/formGroup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FormGroup as NgFormGroup } from '@angular/forms';
import { FormGroup as NgFormGroup, FormArray as NgFormArray } from '@angular/forms';
import { isObservable, Observable, Subject, Subscription } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
import {
Expand All @@ -16,7 +16,9 @@ import {
markAllDirty,
mergeControlValidators,
selectControlValue$,
validateControlOn
validateControlOn,
persistValue$,
handleFormArrays
} from './control-actions';
import {
AbstractControl,
Expand All @@ -31,9 +33,14 @@ import {
Obj,
OnlySelf,
Validator,
ValidatorOrOpts
ValidatorOrOpts,
PersistOptions,
ControlFactoryMap
} from './types';
import { coerceArray } from './utils';
import { PersistManager } from './persistManager';
import { LocalStorageManager } from './localStorageManager';
import { FormArray } from './formArray';

export class FormGroup<T extends Obj = any, E extends object = any> extends NgFormGroup {
readonly value: T;
Expand Down Expand Up @@ -269,4 +276,20 @@ export class FormGroup<T extends Obj = any, E extends object = any> extends NgFo
setDisable(disable = true, opts?: ControlEventOptions) {
disableControl(this, disable, opts);
}

persist(key: string, { debounceTime, manager, arrControlFactory }: PersistOptions<T>): Observable<T> {
const persistManager = manager || new LocalStorageManager();
this.restore(key, persistManager, arrControlFactory);
return persistValue$(this, key, {
debounceTime: debounceTime || 250,
manager: persistManager
});
}

private restore(key: string, manager: PersistManager<T>, arrControlFactory: ControlFactoryMap<T>) {
const value = manager.getValue(key);
if (!value) return;
handleFormArrays(this, value, arrControlFactory);
this.patchValue(value, { emitEvent: false });
}
}
31 changes: 31 additions & 0 deletions projects/ngneat/reactive-forms/src/lib/localStorageManager.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { LocalStorageManager } from './localStorageManager';

type Person = {
name: string;
phone: {
num: number;
prefix: number;
};
};

const createStorageManager = () => {
return new LocalStorageManager<Person>();
};

describe('LocalStorageManager', () => {
it('should set value', () => {
const person = { name: 'ewan', phone: { num: 5550153, prefix: 288 } };
const spy = jest.spyOn(localStorage, 'setItem').mockImplementation(() => {});
const storageManager = createStorageManager();
storageManager.setValue('key', person);
expect(spy).toHaveBeenCalledWith('key', JSON.stringify(person));
});

it('should get value', () => {
const spy = jest.spyOn(localStorage, 'getItem').mockImplementation(() => '{ "name": "ewan" }');
const storageManager = createStorageManager();
const value = storageManager.getValue('key');
expect(spy).toHaveBeenCalledWith('key');
expect(value).toEqual({ name: 'ewan' });
});
});
11 changes: 11 additions & 0 deletions projects/ngneat/reactive-forms/src/lib/localStorageManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { PersistManager } from './persistManager';

export class LocalStorageManager<T> implements PersistManager<T> {
setValue(key: string, data: T): void {
localStorage.setItem(key, JSON.stringify(data));
}

getValue(key: string): T {
return JSON.parse(localStorage.getItem(key) || '{}');
}
}
4 changes: 4 additions & 0 deletions projects/ngneat/reactive-forms/src/lib/persistManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface PersistManager<T> {
setValue(key: string, data: T): void;
getValue(key: string): T;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { SessionStorageManager } from './sessionStorageManager';

type Person = {
name: string;
phone: {
num: number;
prefix: number;
};
};

const createStorageManager = () => {
return new SessionStorageManager<Person>();
};

describe('SessionStorageManager', () => {
it('should set value', () => {
const person = { name: 'ewan', phone: { num: 5550153, prefix: 288 } };
const spy = jest.spyOn(sessionStorage, 'setItem').mockImplementation(() => {});
const storageManager = createStorageManager();
storageManager.setValue('key', person);
expect(spy).toHaveBeenCalledWith('key', JSON.stringify(person));
});

it('should get value', () => {
const spy = jest.spyOn(sessionStorage, 'getItem').mockImplementation(() => '{ "name": "ewan" }');
const storageManager = createStorageManager();
const value = storageManager.getValue('key');
expect(spy).toHaveBeenCalledWith('key');
expect(value).toEqual({ name: 'ewan' });
});
});
11 changes: 11 additions & 0 deletions projects/ngneat/reactive-forms/src/lib/sessionStorageManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { PersistManager } from './persistManager';

export class SessionStorageManager<T> implements PersistManager<T> {
setValue(key: string, data: T): void {
sessionStorage.setItem(key, JSON.stringify(data));
}

getValue(key: string): T {
return JSON.parse(sessionStorage.getItem(key) || '{}');
}
}
13 changes: 13 additions & 0 deletions projects/ngneat/reactive-forms/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Observable } from 'rxjs';
import { FormArray } from './formArray';
import { FormControl } from './formControl';
import { FormGroup } from './formGroup';
import { PersistManager } from './persistManager';

export type ValidationErrors<T = NgValidationErrors> = T;
export type ValidatorFn<T = any, E = any> = (control: AbstractControl<T>) => ValidationErrors<E> | null;
Expand Down Expand Up @@ -70,3 +71,15 @@ export type KeyValueControls<T extends Obj> = {
export type ExtractAbstractControl<T, U> = T extends KeyValueControls<any>
? { [K in keyof U]: AbstractControl<U[K]> }
: T;

export type ArrayKeys<T> = { [K in keyof T]: T[K] extends Array<any> ? K : never }[keyof T];
export type ControlFactory<T> = (value: T) => AbstractControl<T>;
export type ControlFactoryMap<T> = {
[K in ArrayKeys<T>]?: ControlFactory<ArrayType<T[K]>>;
};

export interface PersistOptions<T> {
debounceTime?: number;
manager?: PersistManager<T>;
arrControlFactory?: ControlFactoryMap<T>;
}
13 changes: 12 additions & 1 deletion projects/ngneat/reactive-forms/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,16 @@ export { FormArray } from './lib/formArray';
export { FormControl } from './lib/formControl';
export { FormGroup } from './lib/formGroup';
export { FormBuilder } from './lib/formBuilder';
export { AbstractControl, ValidatorFn, AsyncValidatorFn, NgValidatorsErrors } from './lib/types';
export {
AbstractControl,
ValidatorFn,
AsyncValidatorFn,
NgValidatorsErrors,
ControlFactory,
ControlFactoryMap,
PersistOptions
} from './lib/types';
export { ControlValueAccessor } from './lib/controlValueAccessor';
export { PersistManager } from './lib/persistManager';
export { LocalStorageManager } from './lib/localStorageManager';
export { SessionStorageManager } from './lib/sessionStorageManager';

0 comments on commit 2ca8e6e

Please sign in to comment.