-
Notifications
You must be signed in to change notification settings - Fork 110
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: local state ngrxForm directives
* Add NgrxLocalFormControlDirective and NgrxLocalFormDirective that emit actions on output EventEmitter instead of global ActionsSubject * Add unit and e2e tests * Add introductory and advanced usage example * Add documentation section about local state Closes #165
- Loading branch information
Showing
31 changed files
with
781 additions
and
24 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,50 @@ | ||
All form states are internally updated by **ngrx-forms** through dispatching actions from the directives via the `ActionsSubject` to ngrx's root or feature `Store`, by default. | ||
This is done in the background, so the user does not need to deal with forwarding these form actions from the directive to the store. | ||
|
||
However, if you prefer to listen for these actions explicitly and deal with updating the state manually, you can bind to `(ngrxFormsAction)`, the directive's output `EventEmitter`, directly. | ||
|
||
```typescript | ||
@Component({ | ||
selector: 'my-local-form-component', | ||
template: ` | ||
<form> | ||
<input type="text" | ||
[ngrxFormControlState]="formState.controls.myLocalFormField" | ||
(ngrxFormsAction)="handleFormAction($event)"> | ||
</form> | ||
`, | ||
}) | ||
export class MyLocalFormComponent { | ||
|
||
public formState = createFormGroupState("myLocalForm", { | ||
myLocalFormField: '' | ||
}); | ||
|
||
handleFormAction(action: Actions<any>) { | ||
this.formState = formGroupReducer(this.formState, action); | ||
} | ||
} | ||
``` | ||
|
||
That is all you need, except importing the `NgrxFormsModule` somewhere in your app's module. | ||
|
||
```typescript | ||
@NgModule({ | ||
declarations: [ | ||
AppComponent, | ||
], | ||
imports: [ | ||
NgrxFormsModule, | ||
// No need to import a StoreModule here | ||
], | ||
providers: [], | ||
bootstrap: [AppComponent] | ||
}) | ||
export class AppModule { } | ||
``` | ||
|
||
Maintaining a local form state has its merits: | ||
|
||
* The root or feature store is not populated with **temporary form data** which perishes after the component is destroyed. | ||
Form data is often used for creating and/or updating an entity and becomes obsolete after an update request is sent to the server. After the server responds, you can update your list or table of entities with the new or updated, proper entity data. | ||
* You still want to have **easy-to-reason** and **easy-to-test** form logic for handling how values propagate between controls, how each control is affected by validation, etc. by leveraging the various update functions. |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
52 changes: 52 additions & 0 deletions
52
example-app/src/app/local-state-advanced/local-state-advanced.component.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
<ngf-form-example exampleName="Local state: Advanced" | ||
[formState]="localState" | ||
> | ||
Advanced example that shows how you can disable emitting any form actions to the global store via the builtin ActionsSubject. | ||
Instead, you can listen for actions using the directive's output EventEmitter and route them to the component. | ||
<br /> | ||
The component holds, updates and maintains a local state, which does not taint the global store and | ||
is automatically cleaned up once the component is destroyed. | ||
<br /> | ||
In addition to the form data, the component requests and saves additional data from an external effect. | ||
The update logic is located in the reducer, which handles inter-dependencies between form data and additional data. | ||
<br /> | ||
<br /> | ||
<br /> | ||
|
||
<form | ||
[ngrxFormState]="localState.formState" | ||
(ngrxFormsAction)="handleFormAction($event)" | ||
> | ||
<div> | ||
<p> | ||
The entered country code impacts what you see in the manufacturer dropdown. | ||
Enter "US" or "UK" for some manufacturers, otherwise the list will be empty. | ||
</p> | ||
</div> | ||
<div> | ||
<label>CountryCode</label> | ||
<div> | ||
<input type="text" | ||
[ngrxFormControlState]="localState.formState.controls.countryCode" | ||
(ngrxFormsAction)="handleFormAction($event)"> | ||
</div> | ||
</div> | ||
<div> | ||
<label>Manufacturer</label> | ||
<div> | ||
<select | ||
[ngrxFormControlState]="localState.formState.controls.manufacturer" | ||
(ngrxFormsAction)="handleFormAction($event)"> | ||
|
||
<option [value]="''"> | ||
None | ||
</option> | ||
<option *ngFor="let manufacturer of localState.manufacturers" | ||
[value]="manufacturer"> | ||
{{manufacturer}} | ||
</option> | ||
</select> | ||
</div> | ||
</div> | ||
</form> | ||
</ngf-form-example> |
10 changes: 10 additions & 0 deletions
10
example-app/src/app/local-state-advanced/local-state-advanced.component.scss
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
@import '../../styles/shared'; | ||
|
||
:host { | ||
@extend .page; | ||
} | ||
|
||
label { | ||
display: block; | ||
margin-bottom: 5px; | ||
} |
54 changes: 54 additions & 0 deletions
54
example-app/src/app/local-state-advanced/local-state-advanced.component.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
import { ChangeDetectionStrategy, Component, ChangeDetectorRef, OnDestroy, OnInit } from '@angular/core'; | ||
import { ActionsSubject, Action } from '@ngrx/store'; | ||
import { SetValueAction, Actions } from 'ngrx-forms'; | ||
import { Subscription } from 'rxjs'; | ||
|
||
import { reducer, GetManufacturersAction, INITIAL_LOCAL_STATE } from './local-state-advanced.reducer'; | ||
|
||
@Component({ | ||
selector: 'ngf-local-state-advanced', | ||
templateUrl: './local-state-advanced.component.html', | ||
styleUrls: ['./local-state-advanced.component.scss'], | ||
changeDetection: ChangeDetectionStrategy.OnPush, | ||
}) | ||
export class LocalStateAdvancedComponent implements OnInit, OnDestroy { | ||
|
||
localState = INITIAL_LOCAL_STATE; | ||
|
||
private subscription = new Subscription(); | ||
|
||
constructor(private actionsSubject: ActionsSubject, private cd: ChangeDetectorRef) { | ||
} | ||
|
||
ngOnInit() { | ||
this.subscription = this.actionsSubject.subscribe(action => { | ||
const updated = this.updateState(action); | ||
if (updated) { | ||
// since OnPush is used, need to trigger detectChanges | ||
// when action from outside updates localState | ||
this.cd.detectChanges(); | ||
} | ||
}); | ||
} | ||
|
||
ngOnDestroy(): void { | ||
this.subscription.unsubscribe(); | ||
} | ||
|
||
handleFormAction(action: Actions<any>) { | ||
this.updateState(action); | ||
|
||
// trigger loading of new manufacturers list in effect | ||
if (action.type === SetValueAction.TYPE && action.controlId === this.localState.formState.controls.countryCode.id) { | ||
this.actionsSubject.next(new GetManufacturersAction(action.value)); | ||
} | ||
} | ||
|
||
private updateState(action: Action): boolean { | ||
const localState = reducer(this.localState, action); | ||
const updated = localState !== this.localState; | ||
this.localState = localState; | ||
|
||
return updated; | ||
} | ||
} |
29 changes: 29 additions & 0 deletions
29
example-app/src/app/local-state-advanced/local-state-advanced.effects.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import { Injectable } from '@angular/core'; | ||
import { Effect, ofType, Actions } from '@ngrx/effects'; | ||
import { Action } from '@ngrx/store'; | ||
import { Observable } from 'rxjs'; | ||
import { map, delay, debounceTime } from 'rxjs/operators'; | ||
|
||
import { GetManufacturersAction, SetManufacturersAction } from './local-state-advanced.reducer'; | ||
|
||
@Injectable() | ||
export class LocalStateAdvancedEffects { | ||
|
||
@Effect() | ||
getManufacturers$: Observable<Action> = this.actions$.pipe( | ||
ofType(GetManufacturersAction.TYPE), | ||
debounceTime(300), | ||
delay(1000), | ||
map((action: GetManufacturersAction) => { | ||
if (action.countryCode === 'US') { | ||
return new SetManufacturersAction(['Ford', 'Chevrolet']); | ||
} else if (action.countryCode === 'UK') { | ||
return new SetManufacturersAction(['Aston Martin', 'Jaguar']) | ||
} else { | ||
return new SetManufacturersAction([]) | ||
} | ||
}) | ||
); | ||
|
||
constructor(private actions$: Actions) { } | ||
} |
26 changes: 26 additions & 0 deletions
26
example-app/src/app/local-state-advanced/local-state-advanced.module.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
import { CommonModule } from '@angular/common'; | ||
import { NgModule } from '@angular/core'; | ||
import { RouterModule } from '@angular/router'; | ||
import { EffectsModule } from '@ngrx/effects'; | ||
import { NgrxFormsModule } from 'ngrx-forms'; | ||
|
||
import { SharedModule } from '../shared/shared.module'; | ||
import { LocalStateAdvancedComponent } from './local-state-advanced.component'; | ||
import { LocalStateAdvancedEffects } from './local-state-advanced.effects'; | ||
|
||
@NgModule({ | ||
imports: [ | ||
CommonModule, | ||
NgrxFormsModule, | ||
SharedModule, | ||
RouterModule.forChild([ | ||
{ path: '', component: LocalStateAdvancedComponent }, | ||
]), | ||
// Notice that StoreModule.forFeature is not included here! | ||
EffectsModule.forFeature([LocalStateAdvancedEffects]), | ||
], | ||
declarations: [ | ||
LocalStateAdvancedComponent, | ||
], | ||
}) | ||
export class LocalStateAdvancedModule { } |
74 changes: 74 additions & 0 deletions
74
example-app/src/app/local-state-advanced/local-state-advanced.reducer.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
import { Action, combineReducers } from '@ngrx/store'; | ||
import { createFormGroupState, formGroupReducer, FormGroupState, updateGroup, setValue } from 'ngrx-forms'; | ||
|
||
export class GetManufacturersAction implements Action { | ||
static readonly TYPE = 'localStateAdvanced/GET_MANUFACTURERS'; | ||
readonly type = GetManufacturersAction.TYPE; | ||
constructor(public countryCode: string) { } | ||
} | ||
|
||
export class SetManufacturersAction implements Action { | ||
static readonly TYPE = 'localStateAdvanced/SET_MANUFACTURERS'; | ||
readonly type = SetManufacturersAction.TYPE; | ||
constructor(public manufacturers: string[]) { } | ||
} | ||
|
||
export interface FormValue { | ||
countryCode: string; | ||
manufacturer: string; | ||
} | ||
|
||
export interface LocalState { | ||
manufacturers: string[]; | ||
formState: FormGroupState<FormValue>; | ||
} | ||
|
||
export const FORM_ID = 'localStateForm'; | ||
|
||
export const INITIAL_FORM_STATE = createFormGroupState<FormValue>(FORM_ID, { | ||
countryCode: '', | ||
manufacturer: '' | ||
}); | ||
|
||
export const INITIAL_LOCAL_STATE: LocalState = { | ||
manufacturers: [], | ||
formState: INITIAL_FORM_STATE | ||
}; | ||
|
||
const reducers = combineReducers<LocalState>({ | ||
manufacturers(manufacturers = [], a: Action) { | ||
// update from loaded data | ||
if (a.type === SetManufacturersAction.TYPE) { | ||
return (a as SetManufacturersAction).manufacturers; | ||
} | ||
return manufacturers; | ||
}, | ||
formState(fs = INITIAL_FORM_STATE, a: Action) { | ||
return formGroupReducer(fs, a); | ||
}, | ||
}); | ||
|
||
export function reducer(oldState: LocalState = INITIAL_LOCAL_STATE, action: Action) { | ||
// each reducer takes care of its individual state | ||
let state = reducers(oldState, action); | ||
|
||
if (state === oldState) { | ||
return state; | ||
} | ||
|
||
// one overarching reducer handles inter-dependencies | ||
const formState = updateGroup<FormValue>({ | ||
manufacturer: manufacturer => { | ||
if (!state.manufacturers.includes(manufacturer.value)) { | ||
return setValue('')(manufacturer); | ||
} | ||
return manufacturer; | ||
} | ||
})(state.formState); | ||
|
||
if (formState !== state.formState) { | ||
state = { ...state, formState }; | ||
} | ||
|
||
return state; | ||
} |
26 changes: 26 additions & 0 deletions
26
example-app/src/app/local-state-introduction/local-state-introduction.component.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
<ngf-form-example exampleName="Local state: Introduction" | ||
[formState]="formState" | ||
> | ||
Introductory example that shows how you can disable emitting any form actions to the global store via the builtin ActionsSubject. | ||
Instead, you can listen for actions using the directive's output EventEmitter and route them to the component. | ||
<br /> | ||
The component holds, updates and maintains a local form state, which does not taint the global store and | ||
is automatically cleaned up once the component is destroyed. | ||
<br /> | ||
<br /> | ||
<br /> | ||
|
||
<form | ||
[ngrxFormState]="formState" | ||
(ngrxFormsAction)="handleFormAction($event)" | ||
> | ||
<div> | ||
<label>CountryCode</label> | ||
<div> | ||
<input type="text" | ||
[ngrxFormControlState]="formState.controls.countryCode" | ||
(ngrxFormsAction)="handleFormAction($event)"> | ||
</div> | ||
</div> | ||
</form> | ||
</ngf-form-example> |
10 changes: 10 additions & 0 deletions
10
example-app/src/app/local-state-introduction/local-state-introduction.component.scss
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
@import '../../styles/shared'; | ||
|
||
:host { | ||
@extend .page; | ||
} | ||
|
||
label { | ||
display: block; | ||
margin-bottom: 5px; | ||
} |
Oops, something went wrong.