Skip to content

Commit 701ccef

Browse files
committed
feat(lib): add dirty functionality
1 parent dc6e564 commit 701ccef

16 files changed

+336
-99
lines changed

README.md

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
✅ Allows Typed Forms!<br>
2020
✅ Auto persists the form's state upon user navigation.<br>
2121
✅ Provides an API to reactively querying any form, from anywhere. <br>
22-
✅ Persist the form's state to local storage.
22+
✅ Persist the form's state to local storage.<br>
23+
✅ Built-in dirty functionality.
2324

2425
<hr />
2526

@@ -282,7 +283,7 @@ export class HomeComponent {
282283
`NgFormsManager` can take a generic type where you can define the forms shape. For example:
283284
284285
```ts
285-
export interface AppForms = {
286+
interface AppForms = {
286287
onboarding: {
287288
name: string;
288289
age: number;
@@ -305,6 +306,51 @@ export class OnboardingComponent {
305306
}
306307
```
307308
309+
Note that you can split the types across files using a definition file:
310+
311+
```ts
312+
// login-form.d.ts
313+
interface AppForms {
314+
login: {
315+
email: string;
316+
password: string
317+
}
318+
}
319+
320+
// onboarding.d.ts
321+
interface AppForms {
322+
onboarding: {
323+
...
324+
}
325+
}
326+
```
327+
328+
## Using the Dirty Functionality
329+
330+
The library provides built-in support for the common "Is the form dirty?" question. Dirty means that the current control's
331+
value is different from the initial value. It can be useful when we need to toggle the visibility of a "save" button or displaying a dialog when the user leaves the page.
332+
333+
To start using it, you should set the `withInitialValue` option:
334+
335+
```ts
336+
@Component({
337+
template: `
338+
<button *ngIf="isDirty$ | async">Save</button>
339+
`,
340+
})
341+
export class SettingsComponent {
342+
isDirty$ = this.formsManager.initialValueChanged(name);
343+
344+
constructor(private formsManager: NgFormsManager<AppForms>) {}
345+
346+
ngOnInit() {
347+
this.formsManager.upsert(name, control, {
348+
withInitialValue: true,
349+
});
350+
}
351+
}
352+
```
353+
308354
## NgFormsManager Config
309355
310356
You can override the default config by passing the `NG_FORMS_MANAGER_CONFIG` provider:

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

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { Inject, Injectable, Optional } from '@angular/core';
2-
import { AbstractControl, Form } from '@angular/forms';
3-
import { coerceArray, filterControlKeys, filterNil, isBrowser, mergeDeep } from './utils';
2+
import { AbstractControl } from '@angular/forms';
3+
import { coerceArray, filterControlKeys, filterNil, isBrowser, isObject, mergeDeep } from './utils';
44
import { merge, Observable, Subject, Subscription } from 'rxjs';
5-
import { debounceTime, distinctUntilChanged, filter, map, tap } from 'rxjs/operators';
5+
import { debounceTime, distinctUntilChanged, filter, map } from 'rxjs/operators';
66
import { FormsStore } from './forms-manager.store';
7-
import { Control, ControlFactory, FormKeys, HashMap } from './types';
7+
import { Control, ControlFactory, FormKeys, HashMap, UpsertConfig } from './types';
88
import { Config, NG_FORMS_MANAGER_CONFIG, NgFormsManagerConfig } from './config';
99
import { isEqual } from './isEqual';
1010
import { deleteControl, findControl, handleFormArray, toStore } from './builders';
@@ -14,6 +14,7 @@ export class NgFormsManager<FormsState = any> {
1414
private readonly store: FormsStore<FormsState>;
1515
private valueChanges$$: Map<keyof FormsState, Subscription> = new Map();
1616
private instances$$: Map<keyof FormsState, AbstractControl> = new Map();
17+
private initialValues$$: Map<keyof FormsState, any> = new Map();
1718
private destroy$$ = new Subject();
1819

1920
constructor(@Optional() @Inject(NG_FORMS_MANAGER_CONFIG) private config: NgFormsManagerConfig) {
@@ -33,6 +34,19 @@ export class NgFormsManager<FormsState = any> {
3334
return this.controlChanges(name, path).pipe(map(control => control.valid));
3435
}
3536

37+
/**
38+
*
39+
* Whether the control is valid
40+
*
41+
* @example
42+
*
43+
* manager.isValid(name);
44+
*
45+
*/
46+
isValid(name: keyof FormsState) {
47+
return this.hasControl(name) && this.getControl(name).valid;
48+
}
49+
3650
/**
3751
*
3852
* @example
@@ -116,6 +130,25 @@ export class NgFormsManager<FormsState = any> {
116130
);
117131
}
118132

133+
/**
134+
*
135+
* Whether the initial control value is deep equal to current value
136+
*
137+
* @example
138+
*
139+
* const dirty$ = manager.initialValueChanged('settings');
140+
*
141+
*/
142+
initialValueChanged(name: keyof FormsState): Observable<boolean> {
143+
if (this.initialValues$$.has(name) === false) {
144+
console.error(`You should set the withInitialValue option to the ${name} control`);
145+
}
146+
147+
return this.valueChanges(name).pipe(
148+
map(current => isEqual(current, this.initialValues$$.get(name)) === false)
149+
);
150+
}
151+
119152
/**
120153
*
121154
* @example
@@ -214,6 +247,19 @@ export class NgFormsManager<FormsState = any> {
214247
}
215248
}
216249

250+
/**
251+
*
252+
* Sets the initial value for a control
253+
*
254+
* @example
255+
*
256+
* manager.setInitialValue('login', value);
257+
*
258+
*/
259+
setInitialValue(name: keyof FormsState, value: any) {
260+
this.initialValues$$.set(name, value);
261+
}
262+
217263
/**
218264
*
219265
* @example
@@ -254,6 +300,7 @@ export class NgFormsManager<FormsState = any> {
254300
clear(name?: FormKeys<FormsState>) {
255301
name ? this.deleteControl(name) : this.store.set({} as FormsState);
256302
this.removeFromStorage();
303+
this.removeInitialValue(name);
257304
}
258305

259306
/**
@@ -283,16 +330,12 @@ export class NgFormsManager<FormsState = any> {
283330
* manager.upsert('login', this.login, { arrControlFactory: value => new FormControl('') });
284331
*
285332
*/
286-
upsert(
287-
name: keyof FormsState,
288-
control: AbstractControl,
289-
config: {
290-
persistState?: boolean;
291-
debounceTime?: number;
292-
arrControlFactory?: ControlFactory | HashMap<ControlFactory>;
293-
} = {}
294-
) {
295-
const mergedConfig = this.config.merge(config) as Config & { arrControlFactory; persistState };
333+
upsert(name: keyof FormsState, control: AbstractControl, config: UpsertConfig = {}) {
334+
const mergedConfig: Config & UpsertConfig = this.config.merge(config);
335+
336+
if (mergedConfig.withInitialValue && this.initialValues$$.has(name) === false) {
337+
this.setInitialValue(name, control.value);
338+
}
296339

297340
if (isBrowser() && config.persistState && this.hasControl(name) === false) {
298341
const storageValue = this.getFromStorage(mergedConfig.storage.key);
@@ -374,4 +417,8 @@ export class NgFormsManager<FormsState = any> {
374417

375418
return value;
376419
}
420+
421+
private removeInitialValue(name: FormKeys<FormsState>) {
422+
coerceArray(name).forEach(name => this.initialValues$$.delete(name));
423+
}
377424
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ const formTwo = new FormGroup({
1818
age: new FormControl(),
1919
});
2020

21-
manager.upsert('formOne', formOne);
22-
manager.upsert('formTwo', formTwo);
21+
manager.upsert({ name: 'formOne', control: formOne });
22+
manager.upsert({ name: 'formTwo', control: formTwo });
2323

2424
manager.validityChanges('formOne').subscribe(isValid => {
2525
// infer boolean

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,10 @@ export interface HashMap<T = any> {
1212
}
1313

1414
export type FormKeys<FormsState> = keyof FormsState | (keyof FormsState)[];
15+
16+
export interface UpsertConfig {
17+
persistState?: boolean;
18+
debounceTime?: number;
19+
arrControlFactory?: ControlFactory | HashMap<ControlFactory>;
20+
withInitialValue?: boolean;
21+
}

src/app/app-routing.module.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { NgModule } from '@angular/core';
2-
import { Routes, RouterModule } from '@angular/router';
3-
import { FormsComponent } from './forms/forms.component';
2+
import { RouterModule, Routes } from '@angular/router';
43
import { HomeComponent } from './home/home.component';
4+
import { DemoComponent } from './demo/demo.component';
5+
import { StepOneComponent } from './step-one/step-one.component';
6+
import { StepTwoComponent } from './step-two/step-two.component';
57

68
const routes: Routes = [
79
{
@@ -10,8 +12,23 @@ const routes: Routes = [
1012
pathMatch: 'full',
1113
},
1214
{
13-
path: 'forms',
14-
component: FormsComponent,
15+
path: 'demo',
16+
component: DemoComponent,
17+
children: [
18+
{
19+
path: '',
20+
pathMatch: 'full',
21+
redirectTo: 'one',
22+
},
23+
{
24+
path: 'one',
25+
component: StepOneComponent,
26+
},
27+
{
28+
path: 'two',
29+
component: StepTwoComponent,
30+
},
31+
],
1532
},
1633
];
1734

src/app/app.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
>
1313
</li>
1414
<li class="nav-item">
15-
<a class="nav-link" routerLinkActive="active" routerLink="forms">Forms</a>
15+
<a class="nav-link" routerLinkActive="active" routerLink="demo">Demo</a>
1616
</li>
1717
</ul>
1818
</div>

src/app/app.module.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@ import { NgModule } from '@angular/core';
33

44
import { AppRoutingModule } from './app-routing.module';
55
import { AppComponent } from './app.component';
6-
import { FormsComponent } from './forms/forms.component';
76
import { ReactiveFormsModule } from '@angular/forms';
8-
import { NG_FORMS_MANAGER_CONFIG, NgFormsManagerConfig } from '@ngneat/forms-manager';
97
import { HomeComponent } from './home/home.component';
8+
import { DemoComponent } from './demo/demo.component';
9+
import { StepTwoComponent } from './step-two/step-two.component';
10+
import { StepOneComponent } from './step-one/step-one.component';
1011

1112
@NgModule({
12-
declarations: [AppComponent, FormsComponent, HomeComponent],
13+
declarations: [AppComponent, HomeComponent, DemoComponent, StepTwoComponent, StepOneComponent],
1314
imports: [BrowserModule, AppRoutingModule, ReactiveFormsModule],
1415
providers: [
1516
// {

src/app/demo/demo.component.scss

Whitespace-only changes.

src/app/demo/demo.component.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Component, OnInit } from '@angular/core';
2+
import { NgFormsManager } from '@ngneat/forms-manager';
3+
import { map, tap } from 'rxjs/operators';
4+
5+
@Component({
6+
template: `
7+
<div class="container mt">
8+
<div class="row">
9+
<div class="col-3">
10+
<div class="nav flex-column nav-pills">
11+
<a
12+
class="nav-link"
13+
routerLink="one"
14+
routerLinkActive="active"
15+
[ngStyle]="stepOneInvalid$ | async"
16+
>Step One</a
17+
>
18+
<a
19+
class="nav-link"
20+
routerLink="two"
21+
[ngStyle]="stepTwoInvalid$ | async"
22+
routerLinkActive="active"
23+
>Step Two</a
24+
>
25+
</div>
26+
</div>
27+
<div class="col-9">
28+
<div class="tab-content">
29+
<router-outlet></router-outlet>
30+
</div>
31+
<button class="btn btn-info" (click)="save()">Save</button>
32+
</div>
33+
</div>
34+
</div>
35+
`,
36+
})
37+
export class DemoComponent implements OnInit {
38+
stepOneInvalid$ = this.manager
39+
.validityChanges('stepOne')
40+
.pipe(map(valid => (valid ? null : { background: 'red' })));
41+
stepTwoInvalid$ = this.manager
42+
.validityChanges('stepTwo')
43+
.pipe(map(valid => (valid ? null : { background: 'red' })));
44+
45+
constructor(private manager: NgFormsManager<AppForms>) {}
46+
47+
ngOnInit() {}
48+
49+
save() {
50+
if (this.manager.isValid('stepOne') && this.manager.isValid('stepTwo')) {
51+
console.log('valid');
52+
}
53+
}
54+
}

src/app/forms/forms.component.html

Lines changed: 0 additions & 26 deletions
This file was deleted.

0 commit comments

Comments
 (0)