Skip to content

Commit e87ff6f

Browse files
Nick Linnenbrüggernickhh76
authored andcommitted
feat(forms-manager): add support for session storage
Adds ability to use session storage for persistence, configurable through NgFormsManagerConfig
1 parent dd1c627 commit e87ff6f

File tree

6 files changed

+188
-19
lines changed

6 files changed

+188
-19
lines changed

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ formsManager.destroy();
246246
formsManager.controlChanges('login').pipe(takeUntil(controlDestroyed('login')));
247247
```
248248

249-
## Persist to Local Storage
249+
## Persist to Local Storage or Session Storage
250250

251251
In the `upsert` method, pass the `persistState` flag:
252252

@@ -256,6 +256,8 @@ formsManager.upsert(formName, abstractContorl, {
256256
});
257257
```
258258

259+
By default, the state is persisted to Local Storage. Session Storage can be used instead by passing the `NG_FORMS_MANAGER_CONFIG` provider (see below).
260+
259261
## Validators
260262

261263
The library exposes two helpers method for adding cross component validation:
@@ -335,7 +337,7 @@ interface AppForms {
335337
name: string;
336338
age: number;
337339
city: string;
338-
}
340+
};
339341
}
340342
```
341343
@@ -426,6 +428,7 @@ import { NG_FORMS_MANAGER_CONFIG, NgFormsManagerConfig } from '@ngneat/forms-man
426428
useValue: new NgFormsManagerConfig({
427429
debounceTime: 1000, // defaults to 300
428430
storage: {
431+
type: 'SessionStorage',
429432
key: 'NgFormManager',
430433
},
431434
}),

projects/ngneat/forms-manager/src/lib/config.ts

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,47 @@
11
import { InjectionToken } from '@angular/core';
22

3+
type DeepPartial<T> = {
4+
[P in keyof T]?: DeepPartial<T[P]>;
5+
};
6+
7+
export type StorageOption = 'LocalStorage' | 'SessionStorage';
38
export type Config = {
49
storage: {
10+
type: StorageOption;
511
key: string;
612
};
713
debounceTime: number;
814
};
915

1016
const defaults: Config = {
1117
storage: {
18+
type: 'LocalStorage',
1219
key: 'ngFormsManager',
1320
},
1421
debounceTime: 300,
1522
};
1623

1724
export function mergeConfig(
18-
defaults: Partial<Config>,
19-
providerConfig: Partial<Config> = {},
20-
inlineConfig: Partial<Config>
21-
) {
25+
defaults: Config,
26+
providerConfig: DeepPartial<Config> = {},
27+
inlineConfig: DeepPartial<Config>
28+
): Config {
2229
return {
2330
...defaults,
31+
...providerConfig,
32+
...inlineConfig,
2433
storage: {
2534
...defaults.storage,
2635
...providerConfig.storage,
2736
...inlineConfig.storage,
2837
},
29-
...providerConfig,
30-
...inlineConfig,
31-
} as Config;
38+
};
3239
}
3340

3441
export class NgFormsManagerConfig {
35-
constructor(private config: Partial<Config> = {}) {}
42+
constructor(private config: DeepPartial<Config> = {}) {}
3643

37-
merge(inline: Partial<Config> = {}): Config {
44+
merge(inline: DeepPartial<Config> = {}): Config {
3845
return mergeConfig(defaults, this.config, inline);
3946
}
4047
}

projects/ngneat/forms-manager/src/lib/forms-manager.spec.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { fakeAsync, tick } from '@angular/core/testing';
1+
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
22
import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms';
33
import { NgFormsManager } from './forms-manager';
4-
import { NgFormsManagerConfig } from './config';
4+
import { NG_FORMS_MANAGER_CONFIG, NgFormsManagerConfig } from './config';
5+
import { LOCAL_STORAGE_TOKEN, SESSION_STORAGE_TOKEN } from './injection-tokens';
56

67
// get forms snapshot
78
function getSnapshot(formsManager) {
@@ -1445,4 +1446,66 @@ describe('FormsManager', () => {
14451446
formsManager = null;
14461447
});
14471448
});
1449+
1450+
describe('Storage', () => {
1451+
let formsManager: NgFormsManager;
1452+
let localStorageMock: jasmine.SpyObj<Storage>;
1453+
let sessionStorageMock: jasmine.SpyObj<Storage>;
1454+
1455+
function configureTestingModule(ngFormsManagerConfig: NgFormsManagerConfig) {
1456+
TestBed.configureTestingModule({
1457+
providers: [
1458+
NgFormsManager,
1459+
{
1460+
provide: NG_FORMS_MANAGER_CONFIG,
1461+
useValue: ngFormsManagerConfig,
1462+
},
1463+
{
1464+
provide: LOCAL_STORAGE_TOKEN,
1465+
useValue: jasmine.createSpyObj('localStorage', ['setItem', 'getItem']),
1466+
},
1467+
{
1468+
provide: SESSION_STORAGE_TOKEN,
1469+
useValue: jasmine.createSpyObj('sessionStorage', ['setItem', 'getItem']),
1470+
},
1471+
],
1472+
});
1473+
formsManager = TestBed.inject(NgFormsManager);
1474+
localStorageMock = TestBed.inject(LOCAL_STORAGE_TOKEN) as jasmine.SpyObj<Storage>;
1475+
sessionStorageMock = TestBed.inject(SESSION_STORAGE_TOKEN) as jasmine.SpyObj<Storage>;
1476+
1477+
formsManager.upsert('user', new FormGroup({ control: new FormControl('control') }), {
1478+
persistState: true,
1479+
});
1480+
}
1481+
1482+
it('should store to localStorage when storage parameter omitted in NG_FORMS_MANAGER_CONFIG', () => {
1483+
configureTestingModule(new NgFormsManagerConfig());
1484+
expect(localStorageMock.getItem).toHaveBeenCalled();
1485+
expect(localStorageMock.setItem).toHaveBeenCalled();
1486+
expect(sessionStorageMock.getItem).not.toHaveBeenCalled();
1487+
expect(sessionStorageMock.setItem).not.toHaveBeenCalled();
1488+
});
1489+
1490+
it('should store to localStorage when configured as such in NG_FORMS_MANAGER_CONFIG', () => {
1491+
configureTestingModule(new NgFormsManagerConfig({ storage: { type: 'LocalStorage' } }));
1492+
expect(localStorageMock.getItem).toHaveBeenCalled();
1493+
expect(localStorageMock.setItem).toHaveBeenCalled();
1494+
expect(sessionStorageMock.getItem).not.toHaveBeenCalled();
1495+
expect(sessionStorageMock.setItem).not.toHaveBeenCalled();
1496+
});
1497+
1498+
it('should store to sessionStorage when configured as such in NG_FORMS_MANAGER_CONFIG', () => {
1499+
configureTestingModule(new NgFormsManagerConfig({ storage: { type: 'SessionStorage' } }));
1500+
expect(sessionStorageMock.getItem).toHaveBeenCalled();
1501+
expect(sessionStorageMock.setItem).toHaveBeenCalled();
1502+
expect(localStorageMock.getItem).not.toHaveBeenCalled();
1503+
expect(localStorageMock.setItem).not.toHaveBeenCalled();
1504+
});
1505+
1506+
afterEach(() => {
1507+
formsManager.unsubscribe();
1508+
formsManager = null;
1509+
});
1510+
});
14481511
});

projects/ngneat/forms-manager/src/lib/forms-manager.ts

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,33 @@ import { AbstractControl, FormArray, FormGroup } from '@angular/forms';
33
import { EMPTY, merge, Observable, Subject, Subscription, timer } from 'rxjs';
44
import { debounce, distinctUntilChanged, filter, map, mapTo } from 'rxjs/operators';
55
import { deleteControl, findControl, handleFormArray, toStore } from './builders';
6-
import { Config, NgFormsManagerConfig, NG_FORMS_MANAGER_CONFIG } from './config';
6+
import { Config, NG_FORMS_MANAGER_CONFIG, NgFormsManagerConfig } from './config';
77
import { FormsStore } from './forms-manager.store';
88
import { isEqual } from './isEqual';
99
import { Control, ControlFactory, FormKeys, HashMap, UpsertConfig } from './types';
1010
import { coerceArray, filterControlKeys, filterNil, isBrowser, mergeDeep } from './utils';
11+
import { LOCAL_STORAGE_TOKEN, SESSION_STORAGE_TOKEN } from './injection-tokens';
1112

1213
const NO_DEBOUNCE = Symbol('NO_DEBOUNCE');
1314

15+
// @dynamic; see https://angular.io/guide/angular-compiler-options#strictmetadataemit
1416
@Injectable({ providedIn: 'root' })
1517
export class NgFormsManager<FormsState = any> {
1618
private readonly store: FormsStore<FormsState>;
19+
private readonly browserStorage?: Storage;
1720
private valueChanges$$: Map<keyof FormsState, Subscription> = new Map();
1821
private instances$$: Map<keyof FormsState, AbstractControl> = new Map();
1922
private initialValues$$: Map<keyof FormsState, any> = new Map();
2023
private destroy$$ = new Subject();
2124

22-
constructor(@Optional() @Inject(NG_FORMS_MANAGER_CONFIG) private config: NgFormsManagerConfig) {
25+
constructor(
26+
@Optional() @Inject(NG_FORMS_MANAGER_CONFIG) private config: NgFormsManagerConfig,
27+
@Optional() @Inject(LOCAL_STORAGE_TOKEN) private readonly localStorage?: Storage,
28+
@Optional() @Inject(SESSION_STORAGE_TOKEN) private readonly sessionStorage?: Storage
29+
) {
2330
this.store = new FormsStore({} as FormsState);
31+
this.browserStorage =
32+
this.config.merge().storage.type === 'LocalStorage' ? this.localStorage : this.sessionStorage;
2433
}
2534

2635
/**
@@ -490,7 +499,7 @@ export class NgFormsManager<FormsState = any> {
490499
*
491500
* @example
492501
*
493-
* Removes the control from the store and from LocalStorage
502+
* Removes the control from the store and from LocalStorage/SessionStorage
494503
*
495504
* manager.clear('login');
496505
*
@@ -593,19 +602,22 @@ export class NgFormsManager<FormsState = any> {
593602
}
594603

595604
private removeFromStorage() {
596-
localStorage.setItem(this.config.merge().storage.key, JSON.stringify(this.store.getValue()));
605+
this.browserStorage?.setItem(
606+
this.config.merge().storage.key,
607+
JSON.stringify(this.store.getValue())
608+
);
597609
}
598610

599611
private updateStorage(name: keyof FormsState, value: any, config) {
600612
if (isBrowser() && config.persistState) {
601613
const storageValue = this.getFromStorage(config.storage.key);
602614
storageValue[name] = filterControlKeys(value);
603-
localStorage.setItem(config.storage.key, JSON.stringify(storageValue));
615+
this.browserStorage?.setItem(config.storage.key, JSON.stringify(storageValue));
604616
}
605617
}
606618

607619
private getFromStorage(key: string) {
608-
return JSON.parse(localStorage.getItem(key) || '{}');
620+
return JSON.parse(this.browserStorage?.getItem(key) || '{}');
609621
}
610622

611623
private deleteControl(name: FormKeys<FormsState>) {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { PLATFORM_ID } from '@angular/core';
3+
import { LOCAL_STORAGE_TOKEN, SESSION_STORAGE_TOKEN } from './injection-tokens';
4+
5+
const PLATFORM_BROWSER_ID: string = 'browser';
6+
const PLATFORM_SERVER_ID: string = 'server';
7+
const PLATFORM_WORKER_APP_ID: string = 'browserWorkerApp';
8+
const PLATFORM_WORKER_UI_ID: string = 'browserWorkerUi';
9+
10+
describe('SESSION_STORAGE_TOKEN', () => {
11+
it('should contain the sessionStorage object on platform browser', () => {
12+
TestBed.configureTestingModule({
13+
providers: [{ provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID }],
14+
});
15+
expect(TestBed.inject(SESSION_STORAGE_TOKEN)).toBe(sessionStorage);
16+
});
17+
it('should contain undefined on platform server', () => {
18+
TestBed.configureTestingModule({
19+
providers: [{ provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID }],
20+
});
21+
expect(TestBed.inject(SESSION_STORAGE_TOKEN)).toBeUndefined();
22+
});
23+
it('should contain undefined on platform worker app', () => {
24+
TestBed.configureTestingModule({
25+
providers: [{ provide: PLATFORM_ID, useValue: PLATFORM_WORKER_APP_ID }],
26+
});
27+
expect(TestBed.inject(SESSION_STORAGE_TOKEN)).toBeUndefined();
28+
});
29+
it('should contain undefined on platform worker ui', () => {
30+
TestBed.configureTestingModule({
31+
providers: [{ provide: PLATFORM_ID, useValue: PLATFORM_WORKER_UI_ID }],
32+
});
33+
expect(TestBed.inject(SESSION_STORAGE_TOKEN)).toBeUndefined();
34+
});
35+
});
36+
37+
describe('LOCAL_STORAGE_TOKEN', () => {
38+
it('should contain the localStorage object on platform browser', () => {
39+
TestBed.configureTestingModule({
40+
providers: [{ provide: PLATFORM_ID, useValue: PLATFORM_BROWSER_ID }],
41+
});
42+
expect(TestBed.inject(LOCAL_STORAGE_TOKEN)).toBe(localStorage);
43+
});
44+
it('should contain undefined on platform server', () => {
45+
TestBed.configureTestingModule({
46+
providers: [{ provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID }],
47+
});
48+
expect(TestBed.inject(LOCAL_STORAGE_TOKEN)).toBeUndefined();
49+
});
50+
it('should contain undefined on platform worker app', () => {
51+
TestBed.configureTestingModule({
52+
providers: [{ provide: PLATFORM_ID, useValue: PLATFORM_WORKER_APP_ID }],
53+
});
54+
expect(TestBed.inject(LOCAL_STORAGE_TOKEN)).toBeUndefined();
55+
});
56+
it('should contain undefined on platform worker ui', () => {
57+
TestBed.configureTestingModule({
58+
providers: [{ provide: PLATFORM_ID, useValue: PLATFORM_WORKER_UI_ID }],
59+
});
60+
expect(TestBed.inject(LOCAL_STORAGE_TOKEN)).toBeUndefined();
61+
});
62+
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { isPlatformBrowser } from '@angular/common';
2+
import { InjectionToken, PLATFORM_ID, inject } from '@angular/core';
3+
4+
/**
5+
* Injection Token to safely inject {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage} to Angular DI
6+
*/
7+
export const SESSION_STORAGE_TOKEN: InjectionToken<Storage | undefined> = new InjectionToken<
8+
Storage | undefined
9+
>('SESSION_STORAGE_TOKEN', {
10+
providedIn: 'root',
11+
factory: () => (isPlatformBrowser(inject(PLATFORM_ID)) ? sessionStorage : undefined),
12+
});
13+
14+
/**
15+
* Injection Token to safely inject {@link https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage} to Angular DI
16+
*/
17+
export const LOCAL_STORAGE_TOKEN: InjectionToken<Storage | undefined> = new InjectionToken<
18+
Storage | undefined
19+
>('LOCAL_STORAGE_TOKEN', {
20+
providedIn: 'root',
21+
factory: () => (isPlatformBrowser(inject(PLATFORM_ID)) ? localStorage : undefined),
22+
});

0 commit comments

Comments
 (0)