Skip to content

Commit

Permalink
feat: local state ngrxForm directives
Browse files Browse the repository at this point in the history
* 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
mucaho committed Nov 24, 2019
1 parent d974581 commit 454e20a
Show file tree
Hide file tree
Showing 31 changed files with 781 additions and 24 deletions.
50 changes: 50 additions & 0 deletions docs/user-guide/local-form-state.md
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.
4 changes: 2 additions & 2 deletions example-app/package-lock.json

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

2 changes: 1 addition & 1 deletion example-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"@ngrx/store-devtools": "7.4.0",
"@types/prismjs": "1.9.0",
"core-js": "3.1.3",
"ngrx-forms": "file:../ngrx-forms-5.1.0.tgz",
"ngrx-forms": "file:../ngrx-forms-6.0.0.tgz",
"prismjs": "1.16.0",
"rxjs": "6.5.2",
"zone.js": "0.9.1"
Expand Down
10 changes: 10 additions & 0 deletions example-app/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,15 @@ export class AppComponent {
hint: 'A form that uses third party form controls',
label: 'Material UI Form',
},
{
path: '/localStateIntroduction',
hint: 'Managing form state locally in the component ',
label: 'Local State: Introduction',
},
{
path: '/localStateAdvanced',
hint: 'Managing form state and external data locally in the component ',
label: 'Local State: Advanced',
},
];
}
8 changes: 8 additions & 0 deletions example-app/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,13 @@ export const routes: Routes = [
path: 'material',
loadChildren: './material-example/material.module#MaterialExampleModule',
},
{
path: 'localStateIntroduction',
loadChildren: './local-state-introduction/local-state-introduction.module#LocalStateIntroductionModule',
},
{
path: 'localStateAdvanced',
loadChildren: './local-state-advanced/local-state-advanced.module#LocalStateAdvancedModule',
},
{ path: '**', redirectTo: '/introduction' },
];
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>
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;
}
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;
}
}
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) { }
}
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 { }
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;
}
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>
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;
}
Loading

0 comments on commit 454e20a

Please sign in to comment.