├── .babelrc ├── source ├── connect-array │ ├── index.ts │ ├── connect-array.module.ts │ └── connect-array.ts ├── form-exception.ts ├── connect │ ├── index.ts │ ├── connect.module.ts │ ├── connect-reactive.ts │ ├── connect.ts │ ├── connect-base.ts │ └── connect.test.ts ├── index.ts ├── compose-reducers.ts ├── form-reducer.ts ├── shims.ts ├── module.ts ├── tests.entry.ts ├── configure.ts ├── form-store.ts ├── tests.utilities.ts ├── compose-reducers.test.ts └── state.ts ├── .npmignore ├── webpack ├── plugins.js └── loaders.js ├── ISSUE_TEMPLATE.md ├── .gitignore ├── tsconfig.json ├── LICENSE ├── CHANGELOG.md ├── package.json ├── karma.conf.js └── README.md /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /source/connect-array/index.ts: -------------------------------------------------------------------------------- 1 | export * from './connect-array.module'; 2 | export * from './connect-array'; 3 | -------------------------------------------------------------------------------- /source/form-exception.ts: -------------------------------------------------------------------------------- 1 | export class FormException extends Error { 2 | constructor(msg: string) { 3 | super(msg); 4 | } 5 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | test 2 | examples 3 | .travis.yml 4 | .gitignore 5 | .babelrc 6 | .nyc_output/ 7 | coverage/ 8 | .vscode/ 9 | docs/ 10 | webpack/ 11 | *.tgz 12 | -------------------------------------------------------------------------------- /source/connect/index.ts: -------------------------------------------------------------------------------- 1 | export * from './connect-base'; 2 | export * from './connect-reactive'; 3 | export * from './connect.module'; 4 | export * from './connect'; 5 | -------------------------------------------------------------------------------- /source/index.ts: -------------------------------------------------------------------------------- 1 | export * from './compose-reducers'; 2 | export * from './form-reducer'; 3 | export * from './form-exception'; 4 | export * from './form-store'; 5 | export * from './configure'; 6 | export * from './connect'; 7 | export * from './connect-array'; 8 | export * from './module'; 9 | -------------------------------------------------------------------------------- /source/compose-reducers.ts: -------------------------------------------------------------------------------- 1 | import {Reducer, AnyAction} from 'redux'; 2 | 3 | export const composeReducers = 4 | (...reducers: Reducer[]): Reducer => 5 | (s: any, action: AnyAction) => 6 | reducers.reduce((st, reducer) => reducer(st, action), s); 7 | -------------------------------------------------------------------------------- /source/connect-array/connect-array.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { ConnectArray } from './connect-array'; 4 | 5 | const declarations = [ConnectArray]; 6 | 7 | @NgModule({ 8 | declarations: [...declarations], 9 | exports: [...declarations], 10 | }) 11 | export class NgReduxFormConnectArrayModule {} 12 | -------------------------------------------------------------------------------- /source/connect/connect.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | 3 | import { Connect } from './connect'; 4 | import { ReactiveConnect } from './connect-reactive'; 5 | 6 | const declarations = [Connect, ReactiveConnect]; 7 | 8 | @NgModule({ 9 | declarations: [...declarations], 10 | exports: [...declarations], 11 | }) 12 | export class NgReduxFormConnectModule {} 13 | -------------------------------------------------------------------------------- /webpack/plugins.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const webpack = require('webpack'); 4 | 5 | const base = [ 6 | new webpack.NoErrorsPlugin(), 7 | ]; 8 | 9 | const development = [ 10 | new webpack.SourceMapDevToolPlugin({filename: null, test: /\.ts$/}) 11 | ]; 12 | 13 | const production = []; 14 | 15 | module.exports = base 16 | .concat(process.env.NODE_ENV === 'production' ? production : []) 17 | .concat(process.env.NODE_ENV === 'development' ? development : []); 18 | -------------------------------------------------------------------------------- /source/connect/connect-reactive.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Directive, 3 | Input, 4 | } from '@angular/core'; 5 | 6 | import {FormStore} from '../form-store'; 7 | 8 | import {ConnectBase} from './connect-base'; 9 | 10 | // For reactive forms (without implicit NgForm) 11 | @Directive({ selector: 'form[connect][formGroup]' }) 12 | export class ReactiveConnect extends ConnectBase { 13 | @Input('formGroup') form: any; 14 | 15 | constructor( 16 | protected store: FormStore 17 | ) { 18 | super(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /source/connect/connect.ts: -------------------------------------------------------------------------------- 1 | import { Directive } from '@angular/core'; 2 | 3 | import { NgForm } from '@angular/forms'; 4 | 5 | import {FormStore} from '../form-store'; 6 | import {ConnectBase} from './connect-base'; 7 | 8 | 9 | // For template forms (with implicit NgForm) 10 | @Directive({ selector: 'form[connect]:not([formGroup])' }) 11 | export class Connect extends ConnectBase { 12 | 13 | constructor( 14 | protected store: FormStore, 15 | protected form: NgForm 16 | ) { 17 | super(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /webpack/loaders.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | exports.css = { 4 | test: /\.css$/, 5 | loader: 'raw-loader', 6 | }; 7 | 8 | exports.ts = { 9 | test: /\.ts$/, 10 | loader: '@ngtools/webpack', 11 | exclude: /node_modules/, 12 | }; 13 | 14 | exports.js = { 15 | test: /\.js$/, 16 | loader: 'babel-loader', 17 | query: { 18 | compact: false, 19 | }, 20 | include: /(angular|rxjs)/, 21 | }; 22 | 23 | exports.istanbulInstrumenter = { 24 | enforce: 'post', 25 | test: /^(.(?!\.test))*\.ts$/, 26 | loader: 'istanbul-instrumenter-loader', 27 | }; 28 | 29 | exports.html = { 30 | test: /\.html$/, 31 | loader: 'raw-loader', 32 | }; 33 | 34 | -------------------------------------------------------------------------------- /source/form-reducer.ts: -------------------------------------------------------------------------------- 1 | import {Iterable} from 'immutable'; 2 | 3 | import {Action} from 'redux'; 4 | 5 | import {FORM_CHANGED} from './form-store'; 6 | 7 | import {State} from './state'; 8 | 9 | export const defaultFormReducer = (initialState?: RootState | Iterable.Keyed) => { 10 | const reducer = (state: RootState | Iterable.Keyed | undefined = initialState, action: Action & {payload?: any}) => { 11 | switch (action.type) { 12 | case FORM_CHANGED: 13 | return State.assign( 14 | state, 15 | action.payload.path, 16 | action.payload.value); 17 | default: 18 | return state; 19 | } 20 | } 21 | 22 | return reducer; 23 | }; 24 | -------------------------------------------------------------------------------- /source/shims.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ControlContainer, 3 | ControlValueAccessor, 4 | CheckboxControlValueAccessor, 5 | SelectControlValueAccessor, 6 | SelectMultipleControlValueAccessor, 7 | RadioControlValueAccessor, 8 | } from '@angular/forms'; 9 | 10 | export function controlPath(name: string, parent: ControlContainer): string[] { 11 | return [...(parent.path || []), name]; 12 | } 13 | 14 | const BUILTIN_ACCESSORS = [ 15 | CheckboxControlValueAccessor, 16 | SelectControlValueAccessor, 17 | SelectMultipleControlValueAccessor, 18 | RadioControlValueAccessor, 19 | ]; 20 | 21 | export function isBuiltInAccessor(valueAccessor: ControlValueAccessor): boolean { 22 | return BUILTIN_ACCESSORS.some(a => valueAccessor.constructor === a); 23 | } 24 | -------------------------------------------------------------------------------- /ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### This is a... 2 | 3 | * [ ] feature request 4 | * [ ] bug report 5 | * [ ] usage question 6 | 7 | ### What toolchain are you using for transpilation/bundling? 8 | 9 | * [ ] @angular/cli 10 | * [ ] Custom @ngTools/webpack 11 | * [ ] Raw `ngc` 12 | * [ ] SystemJS 13 | * [ ] Rollup 14 | * [ ] Other 15 | 16 | ### Environment 17 | 18 | NodeJS Version: 19 | Typescript Version: 20 | Angular Version: 21 | @angular-redux/store version: 22 | @angular/cli version: (if applicable) 23 | OS: 24 | 25 | ### Link to repo showing the issus 26 | (optional, but helps _a lot_) 27 | 28 | ### Expected Behaviour: 29 | 30 | 31 | 32 | ### Actual Behaviour: 33 | 34 | 35 | 36 | ### Stack Trace/Error Message: 37 | 38 | 39 | 40 | ### Additional Notes: 41 | (optional) 42 | -------------------------------------------------------------------------------- /source/module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {FormsModule, ReactiveFormsModule} from '@angular/forms'; 3 | import {NgRedux} from '@angular-redux/store'; 4 | 5 | import {NgReduxFormConnectModule} from './connect'; 6 | import {NgReduxFormConnectArrayModule} from './connect-array'; 7 | import {FormStore} from './form-store'; 8 | 9 | export function formStoreFactory(ngRedux: NgRedux) { 10 | return new FormStore(ngRedux); 11 | } 12 | 13 | @NgModule({ 14 | imports: [ 15 | FormsModule, 16 | ReactiveFormsModule, 17 | NgReduxFormConnectModule, 18 | NgReduxFormConnectArrayModule, 19 | ], 20 | exports: [ 21 | NgReduxFormConnectModule, 22 | NgReduxFormConnectArrayModule 23 | ], 24 | providers: [ 25 | { 26 | provide: FormStore, 27 | useFactory: formStoreFactory, 28 | deps: [NgRedux], 29 | }, 30 | ], 31 | }) 32 | export class NgReduxFormModule {} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | **/npm-debug.log* 5 | 6 | .idea 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Temporary editor files 14 | *.swp 15 | *~ 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules 37 | jspm_packages 38 | 39 | # Optional npm cache directory 40 | .npm 41 | 42 | # Optional REPL history 43 | .node_repl_history 44 | 45 | # Build output 46 | .awcache 47 | dist 48 | 49 | source/*ngfactory.ts 50 | source/*ngsummary.json 51 | 52 | *.tgz 53 | -------------------------------------------------------------------------------- /source/tests.entry.ts: -------------------------------------------------------------------------------- 1 | import 'core-js/es6'; 2 | import 'core-js/es7/reflect'; 3 | 4 | import 'reflect-metadata'; 5 | 6 | import 'zone.js/dist/zone'; 7 | import 'zone.js/dist/sync-test'; 8 | import 'zone.js/dist/async-test'; 9 | import 'zone.js/dist/fake-async-test'; 10 | import 'zone.js/dist/proxy'; 11 | import 'zone.js/dist/jasmine-patch'; 12 | 13 | import {TestBed} from '@angular/core/testing'; 14 | 15 | import { 16 | BrowserDynamicTestingModule, 17 | platformBrowserDynamicTesting, 18 | } from '@angular/platform-browser-dynamic/testing'; 19 | 20 | TestBed.initTestEnvironment( 21 | BrowserDynamicTestingModule, 22 | platformBrowserDynamicTesting()); 23 | 24 | const testContext = (<{ context?: Function }>require) 25 | .context('./', true, /^(.(?!tests\.entry))*\.ts$/); 26 | 27 | testContext('./index.ts'); 28 | 29 | const tests = testContext.keys().filter(f => /\.test\.ts$/.test(f)); 30 | 31 | for (const test of tests) { 32 | testContext(test); 33 | } 34 | -------------------------------------------------------------------------------- /source/configure.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Action, 3 | Store, 4 | } from 'redux'; 5 | 6 | import { 7 | AbstractStore, 8 | FormStore, 9 | } from './form-store'; 10 | 11 | /// Use this function in your providers list if you are not using @angular-redux/core. 12 | /// This will allow you to provide a preexisting store that you have already 13 | /// configured, rather than letting @angular-redux/core create one for you. 14 | export const provideReduxForms = (store: Store | any) => { 15 | const abstractStore = wrap(store); 16 | 17 | return [ 18 | {provide: FormStore, useValue: new FormStore( abstractStore)} 19 | ]; 20 | }; 21 | 22 | const wrap = (store: Store | any): AbstractStore => { 23 | const dispatch = (action: Action) => store.dispatch(action); 24 | 25 | const getState = () => store.getState(); 26 | 27 | const subscribe = 28 | (fn: (state: T) => void) => store.subscribe(() => fn(store.getState())); 29 | 30 | return {dispatch, getState, subscribe}; 31 | }; 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | "lib": ["es6", "dom"], 6 | "types": ["node", "chai", "jasmine"], 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "sourceMap": false, 11 | "inlineSourceMap": true, 12 | "declaration": true, 13 | "outDir": "dist", 14 | "rootDir": "", 15 | "strict": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "pretty": true, 20 | 21 | // Work around an issue in Angular itself with TS 2.4.1. 22 | "skipLibCheck": true 23 | }, 24 | "awesomeTypescriptLoaderOptions": { 25 | "emitRequireType": false, 26 | "useBabel": true, 27 | "useCache": false 28 | }, 29 | "angularCompilerOptions": { 30 | "strictMetadataEmit": true, 31 | "skipTemplateCodegen": true 32 | }, 33 | "exclude": [ 34 | "node_modules", 35 | "dist", 36 | "examples", 37 | "index.d.ts", 38 | "**/*test*.ts" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Chris Bond 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /source/form-store.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | 3 | import {NgForm} from '@angular/forms'; 4 | 5 | import {NgRedux} from '@angular-redux/store'; 6 | 7 | import {Action, Unsubscribe} from 'redux'; 8 | 9 | export interface AbstractStore { 10 | /// Dispatch an action 11 | dispatch(action: Action & {payload: any}): void; 12 | 13 | /// Retrieve the current application state 14 | getState(): RootState; 15 | 16 | /// Subscribe to changes in the store 17 | subscribe(fn: (state: RootState) => void): Unsubscribe; 18 | } 19 | 20 | export const FORM_CHANGED = '@@angular-redux/form/FORM_CHANGED'; 21 | 22 | @Injectable() 23 | export class FormStore { 24 | /// NOTE(cbond): The declaration of store is misleading. This class is 25 | /// actually capable of taking a plain Redux store or an NgRedux instance. 26 | /// But in order to make the ng dependency injector work properly, we 27 | /// declare it as an NgRedux type, since the @angular-redux/store use case involves 28 | /// calling the constructor of this class manually (from configure.ts), 29 | /// where a plain store can be cast to an NgRedux. (For our purposes, they 30 | /// have almost identical shapes.) 31 | constructor(private store: NgRedux) {} 32 | 33 | getState() { 34 | return this.store.getState(); 35 | } 36 | 37 | subscribe(fn: (state: any) => void): Unsubscribe { 38 | return this.store.subscribe(() => fn(this.getState())); 39 | } 40 | 41 | valueChanged(path: string[], form: NgForm, value: T) { 42 | this.store.dispatch({ 43 | type: FORM_CHANGED, 44 | payload: { 45 | path, 46 | form, 47 | valid: form.valid === true, 48 | value 49 | } 50 | }); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /source/tests.utilities.ts: -------------------------------------------------------------------------------- 1 | import {flushMicrotasks} from '@angular/core/testing'; 2 | 3 | import {Iterable} from 'immutable'; 4 | 5 | import {createLogger} from 'redux-logger'; 6 | 7 | export const logger = createLogger({ 8 | level: 'debug', 9 | collapsed: true, 10 | predicate: (getState, action) => true, 11 | stateTransformer: 12 | state => { 13 | const newState = new Object(); 14 | 15 | for (const i of Object.keys(state)) { 16 | newState[i] = Iterable.isIterable(state[i]) 17 | ? state[i].toJS() 18 | : state[i]; 19 | }; 20 | 21 | return newState; 22 | } 23 | }); 24 | 25 | export const simulateUserTyping = (control, text: string): Promise => { 26 | return new Promise((resolve, reject) => { 27 | try { 28 | dispatchKeyEvents(control, text); 29 | resolve(); 30 | } catch (error) { 31 | console.error('Failed to dispatch typing events', error); 32 | reject(error); 33 | } 34 | finally { 35 | flushMicrotasks(); 36 | } 37 | }); 38 | }; 39 | 40 | export const dispatchKeyEvents = (control, text: string) => { 41 | if (!text) { 42 | return; 43 | } 44 | 45 | control.focus(); 46 | 47 | for (const character of text) { 48 | const c = character.charCodeAt(0); 49 | 50 | const keyboardEventFactory = (eventType: string, value) => { 51 | return new KeyboardEvent(eventType, { 52 | altKey: false, 53 | cancelable: false, 54 | bubbles: true, 55 | ctrlKey: false, 56 | metaKey: false, 57 | detail: value, 58 | view: window, 59 | shiftKey: false, 60 | repeat: false, 61 | key: value, 62 | }); 63 | }; 64 | 65 | const eventFactory = (eventType: string) => { 66 | return new Event(eventType, { 67 | bubbles: true, 68 | cancelable: false, 69 | }); 70 | } 71 | 72 | control.dispatchEvent(keyboardEventFactory('keydown', c)); 73 | control.dispatchEvent(keyboardEventFactory('keypress', c)); 74 | control.dispatchEvent(keyboardEventFactory('keyup', c)); 75 | control.value += character; 76 | control.dispatchEvent(eventFactory('input')); 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /source/compose-reducers.test.ts: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | 3 | import { 4 | fromJS, 5 | List, 6 | Map, 7 | OrderedSet, 8 | Set 9 | } from 'immutable'; 10 | 11 | import {composeReducers} from './compose-reducers'; 12 | 13 | describe('composeReducers', () => { 14 | const compose = (s1, s2, s3) => { 15 | const r1 = (state = s1, action) => state; 16 | const r2 = (state = s2, action) => state; 17 | const r3 = (state = s3, action) => state; 18 | 19 | const reducer = composeReducers(r1, r2, r3); 20 | 21 | return reducer(undefined, {type: ''}); 22 | }; 23 | 24 | it('can compose plain-object initial states', () => { 25 | const state = compose({a: 1}, {b: 1}, {c: 1}); 26 | expect(state).not.to.be.undefined; 27 | expect(state).to.deep.equal({a: 1, b: 1, c: 1}); 28 | }); 29 | 30 | it('can compose array states', () => { 31 | const state = compose([1], [2], [3]); 32 | expect(state).not.to.be.undefined; 33 | expect(state).to.deep.equal([1, 2, 3]); 34 | }); 35 | 36 | it('can compose Immutable::Map initial states', () => { 37 | const state = compose(fromJS({a: 1}), fromJS({b: 1}), fromJS({c: 1})); 38 | expect(Map.isMap(state)).to.be.true; 39 | 40 | const plain = state.toJS(); 41 | expect(plain).not.to.be.null; 42 | expect(plain).to.deep.equal({a: 1, b: 1, c: 1}); 43 | }); 44 | 45 | it('can compose Immutable::Set initial states', () => { 46 | const state = compose(Set.of(1, 2, 3), Set.of(4, 5, 6), Set.of()); 47 | expect(Set.isSet(state)).to.be.true; 48 | 49 | const plain = state.toJS(); 50 | expect(plain).not.to.be.null; 51 | expect(plain).to.deep.equal([1, 2, 3, 4, 5, 6]); 52 | }); 53 | 54 | it('can compose Immutable::OrderedSet initial states', () => { 55 | const state = compose(Set.of(3, 2, 1), Set.of(4, 6, 5), Set.of()); 56 | expect(Set.isSet(state)).to.be.true; 57 | 58 | const plain = state.toJS(); 59 | expect(plain).not.to.be.null; 60 | expect(plain).to.deep.equal([3, 2, 1, 4, 6, 5]); 61 | }); 62 | 63 | it('can compose Immutable::List initial states', () => { 64 | const state = compose(List.of('a', 'b'), List.of('c', 'd'), List.of()); 65 | expect(List.isList(state)).to.be.true; 66 | 67 | const plain = state.toJS(); 68 | expect(plain).not.to.be.null; 69 | expect(plain).to.deep.equal(['a', 'b', 'c', 'd']); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # NOTE: For changelog information for v6.5.3 and above, please see the GitHub release notes. 2 | 3 | # 6.5.1 - Support typescript unused checks 4 | 5 | * https://github.com/angular-redux/form/pull/32 6 | * Minor README updates. 7 | 8 | # 6.5.0 - Added support for non-template forms. 9 | 10 | # 6.3.0 - Version bump to match Store@6.3.0 11 | 12 | https://github.com/angular-redux/store/blob/master/CHANGELOG.md 13 | 14 | # 6.2.0 - Version bump to match Store@6.2.0 15 | 16 | https://github.com/angular-redux/store/blob/master/CHANGELOG.md 17 | 18 | # 6.1.1 - Correct Peer Dependency 19 | 20 | # 6.1.0 - Angular 4 Support, Toolchain Fixes 21 | 22 | We now support versions 2 and 4 of Angular. However Angular 2 support is 23 | deprecated and will be removed in a future major version. 24 | 25 | Also updated the `npm` toolchain to build outputs on `npm publish` instead of 26 | on `npm install`. This fixes a number of toolchain/installation bugs people 27 | have reported. 28 | 29 | # 6.0.0 - The big-rename. 30 | 31 | Due to the impending release of Angular4, the name 'ng2-redux' no longer makes 32 | a ton of sense. The Angular folks have moved to a model where all versions are 33 | just called 'Angular', and we should match that. 34 | 35 | After discussion with the other maintainers, we decided that since we have to 36 | rename things anyway, this is a good opportunity to collect ng2-redux and its 37 | related libraries into a set of scoped packages. This will allow us to grow 38 | the feature set in a coherent but decoupled way. 39 | 40 | As of v6, the following packages are deprecated: 41 | 42 | * ng2-redux 43 | * ng2-redux-router 44 | * ng2-redux-form 45 | 46 | Those packages will still be available on npm for as long as they are being used. 47 | 48 | However we have published the same code under a new package naming scheme: 49 | 50 | * @angular-redux/store (formerly ng2-redux) 51 | * @angular-redux/router (formerly ng2-redux-router) 52 | * @angular-redux/form (formerly ng2-redux-form). 53 | 54 | We have also decided that it's easier to reason about things if these packages 55 | align at least on major versions. So everything has at this point been bumped 56 | to 6.0.0. 57 | 58 | # Breaking changes 59 | 60 | Apart from the rename, the following API changes are noted: 61 | 62 | * @angular-redux/store: none. 63 | * @angular-redux/router: none. 64 | * @angular-redux/form: `NgReduxForms` renamed to `NgReduxFormModule` for consistency. 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@angular-redux/form", 3 | "version": "9.0.1", 4 | "description": "Build Angular 2+ forms with Redux", 5 | "dependencies": { 6 | "immutable": "^3.8.1" 7 | }, 8 | "devDependencies": { 9 | "@angular-redux/store": "^9.0.0", 10 | "@angular/common": "^6.0.3", 11 | "@angular/compiler": "^6.0.3", 12 | "@angular/compiler-cli": "^6.0.3", 13 | "@angular/core": "^6.0.3", 14 | "@angular/forms": "^6.0.3", 15 | "@angular/platform-browser": "^6.0.3", 16 | "@angular/platform-browser-dynamic": "^6.0.3", 17 | "@ngtools/webpack": "^6.0.3", 18 | "@types/chai": "^3.4.34", 19 | "@types/jasmine": "^2.5.35", 20 | "@types/node": "^6.0.45", 21 | "babel-core": "^6.11.4", 22 | "babel-loader": "^6.2.5", 23 | "babel-preset-es2015": "^6.14.0", 24 | "chai": "^3.5.0", 25 | "cross-env": "^2.0.0", 26 | "istanbul-instrumenter-loader": "^0.2.0", 27 | "jasmine-core": "^2.4.1", 28 | "karma": "^1.1.1", 29 | "karma-chai": "^0.1.0", 30 | "karma-chrome-launcher": "^1.0.1", 31 | "karma-coverage": "^1.1.1", 32 | "karma-jasmine": "^1.0.2", 33 | "karma-remap-istanbul": "^0.1.1", 34 | "karma-sourcemap-loader": "^0.3.7", 35 | "karma-sourcemap-writer": "^0.1.2", 36 | "karma-spec-reporter": "0.0.26", 37 | "karma-transform-path-preprocessor": "0.0.3", 38 | "karma-webpack": "^1.7.0", 39 | "redux": "^4.0", 40 | "redux-logger": "^2.6.1", 41 | "reflect-metadata": "^0.1.3", 42 | "rimraf": "^2.5.4", 43 | "rxjs": "^6.1.0", 44 | "typescript": "2.7.2", 45 | "webpack": "^2.1.0-beta.25", 46 | "zone.js": "^0.8.26" 47 | }, 48 | "peerDependencies": { 49 | "@angular-redux/store": "^9.0.0", 50 | "@angular/common": "^6.0.0", 51 | "@angular/compiler": "^6.0.0", 52 | "@angular/core": "^6.0.0", 53 | "@angular/forms": "^6.0.0", 54 | "redux": "^4.0" 55 | }, 56 | "engines": { 57 | "node": ">=6.0" 58 | }, 59 | "scripts": { 60 | "prebuild": "npm run clean", 61 | "build": "ngc", 62 | "clean": "rimraf dist", 63 | "cover": "istanbul report --include=**/chrome/coverage-final.json text text-summary", 64 | "pretest": "rimraf coverage", 65 | "test": "cross-env NODE_ENV=development karma start --singleRun true", 66 | "posttest": "npm run cover", 67 | "test:watch": "cross-env NODE_ENV=development karma start --singleRun false", 68 | "prepublish": "npm run build" 69 | }, 70 | "main": "dist/source/index.js", 71 | "typings": "dist/source/index.d.ts", 72 | "repository": "https://github.com/angular-redux/form", 73 | "license": "MIT" 74 | } 75 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | process.env.TEST = true; 4 | 5 | const loaders = require('./webpack/loaders'); 6 | const plugins = require('./webpack/plugins'); 7 | 8 | module.exports = (config) => { 9 | const coverage = config.singleRun ? ['coverage'] : []; 10 | const logLevel = config.singleRun ? config.LOG_INFO : config.LOG_DEBUG; 11 | 12 | config.set({ 13 | frameworks: [ 14 | 'jasmine', 15 | 'chai', 16 | ], 17 | 18 | plugins: [ 19 | 'karma-jasmine', 20 | 'karma-chai', 21 | 'karma-sourcemap-writer', 22 | 'karma-sourcemap-loader', 23 | 'karma-webpack', 24 | 'karma-coverage', 25 | 'karma-remap-istanbul', 26 | 'karma-spec-reporter', 27 | 'karma-chrome-launcher', 28 | 'karma-transform-path-preprocessor', 29 | ], 30 | 31 | files: [ 32 | './source/tests.entry.ts', 33 | { 34 | pattern: '**/*.map', 35 | served: true, 36 | included: false, 37 | watched: true, 38 | }, 39 | ], 40 | 41 | preprocessors: { 42 | '**/*.ts': [ 43 | 'webpack', 44 | 'sourcemap', 45 | 'transformPath', 46 | ], 47 | '**/!(*.test|tests.*).(ts|js)': [ 48 | 'sourcemap', 49 | ], 50 | }, 51 | 52 | transformPathPreprocessor: { 53 | transformer: path => path.replace(/\.ts$/, '.js'), 54 | }, 55 | 56 | webpack: { 57 | plugins, 58 | entry: './source/tests.entry', 59 | devtool: 'inline-source-map', 60 | resolve: { 61 | extensions: ['.webpack.js', '.web.js', '.js', '.ts'], 62 | }, 63 | module: { 64 | rules: combinedLoaders().concat(config.singleRun ? [loaders.istanbulInstrumenter] : []) 65 | }, 66 | stats: { colors: true, reasons: true }, 67 | }, 68 | 69 | webpackServer: { noInfo: true }, 70 | 71 | reporters: ['spec'] 72 | .concat(coverage) 73 | .concat(coverage.length > 0 ? ['karma-remap-istanbul'] : []), 74 | 75 | remapIstanbulReporter: { 76 | src: 'coverage/chrome/coverage-final.json', 77 | reports: { 78 | html: 'coverage', 79 | }, 80 | }, 81 | 82 | coverageReporter: { 83 | reporters: [ 84 | { type: 'json' }, 85 | ], 86 | subdir: b => b.toLowerCase().split(/[ /-]/)[0], 87 | }, 88 | 89 | logLevel: logLevel, 90 | 91 | autoWatch: config.singleRun === false, 92 | 93 | browsers: [ 94 | 'Chrome', 95 | ], 96 | }); 97 | }; 98 | 99 | function combinedLoaders() { 100 | return Object.keys(loaders).reduce(function reduce(aggregate, k) { 101 | switch (k) { 102 | case 'istanbulInstrumenter': 103 | return aggregate; 104 | case 'ts': 105 | return aggregate.concat([ // force inline source maps 106 | Object.assign(loaders[k], 107 | { query: { babelOptions: { sourceMaps: 'both' }, skipDeclarationFilesCheck: true } })]); 108 | default: 109 | return aggregate.concat([loaders[k]]); 110 | } 111 | }, 112 | []); 113 | } 114 | 115 | -------------------------------------------------------------------------------- /source/connect/connect-base.ts: -------------------------------------------------------------------------------- 1 | import { Input } from '@angular/core'; 2 | 3 | import { 4 | AbstractControl, 5 | FormControl, 6 | FormGroup, 7 | FormArray, 8 | NgControl, 9 | } from '@angular/forms'; 10 | 11 | import { Subscription } from 'rxjs'; 12 | 13 | import { Unsubscribe } from 'redux'; 14 | 15 | import { debounceTime } from 'rxjs/operators'; 16 | 17 | import { FormStore } from '../form-store'; 18 | import { State } from '../state'; 19 | 20 | export interface ControlPair { 21 | path: Array; 22 | control: AbstractControl; 23 | } 24 | 25 | export class ConnectBase { 26 | 27 | @Input('connect') connect?: () => (string | number) | Array; 28 | private stateSubscription?: Unsubscribe; 29 | 30 | private formSubscription?: Subscription; 31 | protected store?: FormStore; 32 | protected form: any; 33 | 34 | public get path(): Array { 35 | const path = typeof this.connect === 'function' 36 | ? this.connect() 37 | : this.connect; 38 | 39 | switch (typeof path) { 40 | case 'object': 41 | if (State.empty(path)) { 42 | return []; 43 | } 44 | if (Array.isArray(path)) { 45 | return >path; 46 | } 47 | case 'string': 48 | return (path).split(/\./g); 49 | default: // fallthrough above (no break) 50 | throw new Error(`Cannot determine path to object: ${JSON.stringify(path)}`); 51 | } 52 | } 53 | 54 | ngOnDestroy() { 55 | if (this.formSubscription) { 56 | this.formSubscription.unsubscribe(); 57 | } 58 | 59 | if (typeof this.stateSubscription === 'function') { 60 | this.stateSubscription(); // unsubscribe 61 | } 62 | } 63 | 64 | ngAfterContentInit() { 65 | Promise.resolve().then(() => { 66 | this.resetState(); 67 | 68 | if (this.store) { 69 | this.stateSubscription = this.store.subscribe(() => this.resetState()); 70 | } 71 | 72 | Promise.resolve().then(() => { 73 | this.formSubscription = (this.form.valueChanges) 74 | .pipe(debounceTime(0)) 75 | .subscribe((values: any) => this.publish(values)); 76 | }); 77 | }); 78 | } 79 | 80 | private descendants(path: Array, formElement: any): Array { 81 | const pairs = new Array(); 82 | 83 | if (formElement instanceof FormArray) { 84 | formElement.controls.forEach((c, index) => { 85 | for (const d of this.descendants((path).concat([index]), c)) { 86 | pairs.push(d); 87 | } 88 | }) 89 | } 90 | else if (formElement instanceof FormGroup) { 91 | for (const k of Object.keys(formElement.controls)) { 92 | pairs.push({ path: path.concat([k]), control: formElement.controls[k] }); 93 | } 94 | } 95 | else if (formElement instanceof NgControl || formElement instanceof FormControl) { 96 | return [{ path: path, control: formElement }]; 97 | } 98 | else { 99 | throw new Error(`Unknown type of form element: ${formElement.constructor.name}`); 100 | } 101 | 102 | return pairs.filter(p => { 103 | const parent = (p.control as any)._parent; 104 | return parent === this.form.control || parent === this.form; 105 | }); 106 | } 107 | 108 | private resetState() { 109 | var formElement; 110 | if (this.form.control === undefined) { 111 | formElement = this.form; 112 | } 113 | else { 114 | formElement = this.form.control; 115 | } 116 | 117 | const children = this.descendants([], formElement); 118 | 119 | children.forEach(c => { 120 | const { path, control } = c; 121 | 122 | const value = State.get(this.getState(), this.path.concat(path)); 123 | 124 | if (control.value !== value) { 125 | control.setValue(value); 126 | } 127 | }); 128 | } 129 | 130 | private publish(value: any) { 131 | if (this.store) { 132 | this.store.valueChanged(this.path, this.form, value); 133 | } 134 | } 135 | 136 | private getState() { 137 | if (this.store) { 138 | return this.store.getState(); 139 | } 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /source/connect/connect.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ComponentFixtureNoNgZone, 3 | TestBed, 4 | fakeAsync, 5 | flushMicrotasks, 6 | inject, 7 | tick, 8 | } from '@angular/core/testing'; 9 | import { 10 | Component, 11 | Input, 12 | } from '@angular/core'; 13 | import { 14 | FormsModule, 15 | ReactiveFormsModule, 16 | FormControl, 17 | NgForm, 18 | FormGroup, 19 | } from '@angular/forms'; 20 | 21 | import { 22 | Store, 23 | applyMiddleware, 24 | compose, 25 | combineReducers, 26 | createStore, 27 | } from 'redux'; 28 | 29 | import {composeReducers} from './compose-reducers'; 30 | import {defaultFormReducer} from './form-reducer'; 31 | import {provideReduxForms} from './configure'; 32 | import {NgReduxFormModule} from './module'; 33 | 34 | import { 35 | logger, 36 | simulateUserTyping, 37 | } from './tests.utilities'; 38 | 39 | interface AppState { 40 | fooState?: FooState; 41 | } 42 | 43 | interface FooState { 44 | example: string; 45 | deepInside: { 46 | foo: string; 47 | } 48 | bar: string; 49 | checkExample: boolean; 50 | } 51 | 52 | const initialState: FooState = { 53 | example: 'Test!', 54 | deepInside: { 55 | foo: 'Bar!' 56 | }, 57 | bar: 'two', 58 | checkExample: true, 59 | }; 60 | 61 | const testReducer = (state = initialState, action = {type: ''}) => { 62 | return state; 63 | } 64 | 65 | const reducers = composeReducers( 66 | combineReducers({ 67 | fooState: testReducer 68 | }), 69 | defaultFormReducer()); 70 | 71 | @Component({ 72 | selector: 'test-component-1', 73 | template: ` 74 |
75 | 76 |
77 | `, 78 | }) 79 | export class BasicUsageComponent {} 80 | 81 | @Component({ 82 | selector: 'test-component-2', 83 | template: ` 84 |
85 | 86 |
87 | `, 88 | }) 89 | export class DeepConnectComponent {} 90 | 91 | @Component({ 92 | selector: 'test-component-3', 93 | template: ` 94 |
95 | 96 |
97 | `, 98 | }) 99 | export class CheckboxComponent {} 100 | 101 | @Component({ 102 | selector: 'test-component-4', 103 | template: ` 104 |
105 | 110 |
111 | `, 112 | }) 113 | export class SelectComponent {} 114 | 115 | @Component({ 116 | selector: 'test-component-5', 117 | template: ` 118 |
119 | 120 |
121 | ` 122 | }) 123 | export class UpdateTextComponent {} 124 | 125 | describe('connect directive', () => { 126 | let store: Store; 127 | 128 | beforeEach(done => { 129 | const create = compose(applyMiddleware(logger))(createStore); 130 | 131 | store = create(reducers, {}); 132 | 133 | TestBed.configureCompiler({ 134 | providers: [ 135 | {provide: ComponentFixtureNoNgZone, useValue: true}, 136 | ] 137 | }); 138 | 139 | TestBed.configureTestingModule({ 140 | imports: [ 141 | FormsModule, 142 | ReactiveFormsModule, 143 | NgReduxFormModule, 144 | ], 145 | declarations: [ 146 | BasicUsageComponent, 147 | DeepConnectComponent, 148 | CheckboxComponent, 149 | SelectComponent, 150 | UpdateTextComponent, 151 | ], 152 | providers: [ 153 | provideReduxForms(store), 154 | ] 155 | }); 156 | 157 | TestBed.compileComponents().then(() => done()); 158 | }); 159 | 160 | it('should bind all form controls to application state', 161 | fakeAsync(inject([], () => { 162 | const fixture = TestBed.createComponent(BasicUsageComponent); 163 | fixture.detectChanges(); 164 | 165 | tick(); 166 | flushMicrotasks(); 167 | 168 | const textbox = fixture.nativeElement.querySelector('input'); 169 | expect(textbox.value).toEqual('Test!'); 170 | }))); 171 | 172 | it('should bind a form control to element deep inside application state', 173 | () => { 174 | return fakeAsync(inject([], () => { 175 | const fixture = TestBed.createComponent(DeepConnectComponent); 176 | fixture.detectChanges(); 177 | 178 | tick(); 179 | flushMicrotasks(); 180 | 181 | const textbox = fixture.nativeElement.querySelector('input'); 182 | expect(textbox.value).toEqual('Bar!'); 183 | })); 184 | }); 185 | 186 | it('should bind a checkbox to a boolean state', 187 | fakeAsync(inject([], () => { 188 | const fixture = TestBed.createComponent(CheckboxComponent); 189 | fixture.detectChanges(); 190 | 191 | tick(); 192 | flushMicrotasks(); 193 | 194 | const checkbox = fixture.nativeElement.querySelector('input[type="checkbox"]'); 195 | expect(checkbox.checked).toEqual(true); 196 | }))); 197 | 198 | it('should bind a select dropdown to application state', 199 | fakeAsync(inject([], () => { 200 | const fixture = TestBed.createComponent(SelectComponent); 201 | fixture.detectChanges(); 202 | 203 | tick(); 204 | flushMicrotasks(); 205 | 206 | const select = fixture.nativeElement.querySelector('select'); 207 | expect(select.value).toEqual('two'); 208 | 209 | // TODO(cbond): How to simulate a click-select sequence on this control? 210 | // Just updating `value' does not appear to invoke all of the Angular 211 | // change routines and therefore does not update Redux. But manually clicking 212 | // and selecting does. Need to find a way to simulate that sequence. 213 | }))); 214 | 215 | it('should update Redux state when the user changes the value of a control', 216 | fakeAsync(inject([], () => { 217 | const fixture = TestBed.createComponent(UpdateTextComponent); 218 | fixture.detectChanges(); 219 | 220 | tick(); 221 | flushMicrotasks(); 222 | 223 | // validate initial data before we do the UI tests 224 | let state = store.getState(); 225 | expect(state.fooState.bar).toEqual('two'); 226 | 227 | const textbox = fixture.nativeElement.querySelector('input'); 228 | expect(textbox.value).toEqual('two'); 229 | 230 | return simulateUserTyping(textbox, 'abc') 231 | .then(() => { 232 | tick(); 233 | flushMicrotasks(); 234 | 235 | expect(textbox.value).toEqual('twoabc'); 236 | 237 | state = store.getState(); 238 | expect(state.fooState.bar).toEqual('twoabc'); 239 | }); 240 | }))); 241 | }); 242 | -------------------------------------------------------------------------------- /source/connect-array/connect-array.ts: -------------------------------------------------------------------------------- 1 | import { 2 | forwardRef, 3 | Host, 4 | Input, 5 | SkipSelf, 6 | Self, 7 | Inject, 8 | TemplateRef, 9 | ViewContainerRef, 10 | Directive, 11 | Optional, 12 | EmbeddedViewRef, 13 | OnInit, 14 | } from '@angular/core'; 15 | import { 16 | AbstractControl, 17 | FormArray, 18 | FormControl, 19 | FormGroup, 20 | FormGroupDirective, 21 | NgModelGroup, 22 | ControlContainer, 23 | } from '@angular/forms'; 24 | 25 | import { 26 | AsyncValidatorFn, 27 | ValidatorFn, 28 | Validators, 29 | } from '@angular/forms'; 30 | import { 31 | NG_ASYNC_VALIDATORS, 32 | NG_VALIDATORS 33 | } from '@angular/forms'; 34 | import {Unsubscribe} from 'redux'; 35 | 36 | import {ConnectBase} from '../connect'; 37 | import {FormStore} from '../form-store'; 38 | import {State} from '../state'; 39 | import {controlPath} from '../shims'; 40 | 41 | export class ConnectArrayTemplate { 42 | constructor( 43 | public $implicit: any, 44 | public index: number, 45 | public item: any 46 | ) {} 47 | } 48 | 49 | @Directive({ 50 | selector: '[connectArray]', 51 | providers: [{ 52 | provide: ControlContainer, 53 | useExisting: forwardRef(() => ConnectArray) 54 | }] 55 | }) 56 | export class ConnectArray extends ControlContainer implements OnInit { 57 | private stateSubscription: Unsubscribe; 58 | 59 | private array = new FormArray([]); 60 | 61 | private key?: string; 62 | 63 | constructor( 64 | @Optional() @Host() @SkipSelf() private parent: ControlContainer, 65 | @Optional() @Self() @Inject(NG_VALIDATORS) private rawValidators: any[], 66 | @Optional() @Self() @Inject(NG_ASYNC_VALIDATORS) private rawAsyncValidators: any[], 67 | private connection: ConnectBase, 68 | private templateRef: TemplateRef, 69 | private viewContainerRef: ViewContainerRef, 70 | private store: FormStore, 71 | ) { 72 | super(); 73 | 74 | this.stateSubscription = this.store.subscribe(state => this.resetState(state)); 75 | 76 | this.registerInternals(this.array); 77 | } 78 | 79 | @Input() 80 | set connectArrayOf(collection: any) { 81 | this.key = collection; 82 | 83 | this.resetState(this.store.getState()); 84 | } 85 | 86 | ngOnInit() { 87 | this.formDirective.addControl( this); 88 | } 89 | 90 | get name(): string { 91 | return this.key || ''; 92 | } 93 | 94 | get control(): FormArray { 95 | return this.array; 96 | } 97 | 98 | get formDirective(): FormGroupDirective { 99 | return this.parent.formDirective; 100 | } 101 | 102 | get path(): Array { 103 | return this.key ? 104 | controlPath(this.key, this.parent) : 105 | []; 106 | } 107 | 108 | get validator(): ValidatorFn | null { 109 | return Validators.compose(this.rawValidators); 110 | } 111 | 112 | get asyncValidator(): AsyncValidatorFn | null { 113 | return Validators.composeAsync(this.rawAsyncValidators); 114 | } 115 | 116 | updateValueAndValidity() {} 117 | 118 | ngOnDestroy() { 119 | this.viewContainerRef.clear(); 120 | 121 | if (this.key){ 122 | this.formDirective.form.removeControl(this.key); 123 | } 124 | 125 | this.stateSubscription() 126 | } 127 | 128 | private resetState(state: any) { 129 | if (this.key == null || this.key.length === 0) { 130 | return; // no state to retreive if no key is set 131 | } 132 | 133 | const iterable = State.get(state, this.connection.path.concat(this.path)); 134 | 135 | let index = 0; 136 | 137 | for (const value of iterable) { 138 | var viewRef = this.viewContainerRef.length > index 139 | ? >this.viewContainerRef.get(index) 140 | : null; 141 | 142 | if (viewRef == null) { 143 | const viewRef = this.viewContainerRef.createEmbeddedView( 144 | this.templateRef, 145 | new ConnectArrayTemplate( 146 | index, 147 | index, 148 | value), 149 | index); 150 | 151 | this.patchDescendantControls(viewRef); 152 | 153 | this.array.insert(index, this.transform(this.array, viewRef.context.item)); 154 | } 155 | else { 156 | Object.assign(viewRef.context, 157 | new ConnectArrayTemplate( 158 | index, 159 | index, 160 | value)); 161 | } 162 | 163 | ++index; 164 | }; 165 | 166 | while (this.viewContainerRef.length > index) { 167 | this.viewContainerRef.remove(this.viewContainerRef.length - 1); 168 | } 169 | } 170 | 171 | private registerInternals(array: any) { 172 | array.registerControl = () => {}; 173 | array.registerOnChange = () => {}; 174 | 175 | Object.defineProperties(this, { 176 | _rawValidators: { 177 | value: this.rawValidators || [], 178 | }, 179 | _rawAsyncValidators: { 180 | value: this.rawAsyncValidators || [], 181 | }, 182 | }); 183 | } 184 | 185 | private patchDescendantControls(viewRef: any) { 186 | const groups = Object.keys(viewRef._view) 187 | .map(k => viewRef._view[k]) 188 | .filter(c => c instanceof NgModelGroup); 189 | 190 | groups.forEach(c => { 191 | Object.defineProperties(c, { 192 | _parent: { 193 | value: this, 194 | }, 195 | _checkParentType: { 196 | value: () => {}, 197 | }, 198 | }); 199 | }); 200 | } 201 | 202 | private transform(parent: FormGroup | FormArray, reference: any): AbstractControl { 203 | const emptyControl = () => { 204 | const control = new FormControl(null); 205 | control.setParent(parent); 206 | return control; 207 | }; 208 | 209 | if (reference == null) { 210 | return emptyControl(); 211 | } 212 | 213 | if (typeof reference.toJS === 'function') { 214 | reference = reference.toJS(); 215 | } 216 | 217 | switch (typeof reference) { 218 | case 'string': 219 | case 'number': 220 | case 'boolean': 221 | return emptyControl(); 222 | } 223 | 224 | const iterate = (iterable: any): FormArray => { 225 | const array = new FormArray([]); 226 | 227 | this.registerInternals(array); 228 | 229 | for (let i = array.length; i > 0; i--) { 230 | array.removeAt(i); 231 | } 232 | 233 | for (const value of iterable) { 234 | const transformed = this.transform(array, value) 235 | if (transformed) { 236 | array.push(transformed); 237 | } 238 | } 239 | 240 | return array; 241 | } 242 | 243 | const associate = (value: any): FormGroup => { 244 | const group = new FormGroup({}); 245 | group.setParent(parent); 246 | 247 | for (const key of Object.keys(value)) { 248 | const transformed = this.transform(group, value[key]); 249 | if (transformed) { 250 | group.addControl(key, transformed); 251 | } 252 | } 253 | 254 | return group; 255 | }; 256 | 257 | if (Array.isArray(reference)) { 258 | return iterate(> reference); 259 | } 260 | else if (reference instanceof Set) { 261 | return iterate(> reference); 262 | } 263 | else if (reference instanceof Map) { 264 | return associate(> reference); 265 | } 266 | else if (reference instanceof Object) { 267 | return associate(reference); 268 | } 269 | else { 270 | throw new Error( 271 | `Cannot convert object of type ${typeof reference} / ${reference.toString()} to form element`); 272 | } 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /source/state.ts: -------------------------------------------------------------------------------- 1 | import {Iterable, Map as ImmutableMap} from 'immutable'; 2 | 3 | import {FormException} from './form-exception'; 4 | 5 | export interface Operations { 6 | /// Shallow clone the object 7 | clone(): T; 8 | 9 | /// Clone and merge 10 | merge(key: number | string | null, value: T): any; 11 | 12 | /// Clone the object and update a specific key inside of it 13 | update(key: number | string | null, value: T): any; 14 | } 15 | 16 | export interface TraverseCallback { 17 | (parent: any, key: number | string, remainingPath: string[], value?: any): any; 18 | } 19 | 20 | export abstract class State { 21 | static traverse(state: State, path: string[], fn?: TraverseCallback) { 22 | let deepValue = state; 23 | 24 | for (const k of path) { 25 | const parent = deepValue; 26 | 27 | if (Iterable.isIterable(deepValue)) { 28 | const m = > deepValue; 29 | if (typeof m.get === 'function') { 30 | deepValue = m.get(k); 31 | } 32 | else { 33 | throw new FormException(`Cannot retrieve value from immutable nonassociative container: ${k}`); 34 | } 35 | } 36 | else if (deepValue instanceof Map) { 37 | deepValue = (> deepValue).get(k); 38 | } 39 | else { 40 | deepValue = (deepValue as any)[k]; 41 | } 42 | 43 | if (typeof fn === 'function') { 44 | const transformed = fn(parent, k, path.slice(path.indexOf(k) + 1), deepValue); 45 | 46 | deepValue = transformed[k]; 47 | 48 | Object.assign(parent, transformed); 49 | } 50 | 51 | // If we were not able to find this state inside of our root state 52 | // structure, then we return undefined -- not null -- to indicate that 53 | // state. But this could be a perfectly normal use-case so we don't 54 | // want to throw an exception or anything along those lines. 55 | if (deepValue === undefined) { 56 | return undefined; 57 | } 58 | } 59 | 60 | return deepValue; 61 | } 62 | 63 | static get(state: State, path: string[]): any { 64 | return State.traverse(state, path); 65 | } 66 | 67 | static assign(state: State, path: string[], value?: any) { 68 | const operations = State.inspect(state); 69 | 70 | if (path.length === 0) { 71 | return operations.update(null, value); 72 | } 73 | 74 | const root = operations.clone(); 75 | 76 | // We want to shallow clone the object, and then trace a path to the place 77 | // we want to update, cloning each object we traversed on our way and then 78 | // finally updating the value on the last parent to be @value. This seems 79 | // to offer the best performance: we can shallow clone everything that has 80 | // not been modified, and {deep clone + update} the path down to the value 81 | // that we wish to update. 82 | State.traverse(root, path, 83 | (parent, key: number | string, remainingPath: string[], innerValue?) => { 84 | const parentOperations = State.inspect(parent); 85 | 86 | if (innerValue) { 87 | const innerOperations = State.inspect(innerValue); 88 | 89 | return parentOperations.update(key, 90 | remainingPath.length > 0 91 | ? innerOperations.clone() 92 | : innerOperations.merge(null, value)); 93 | } 94 | else { 95 | const getProbableType = (key: string | number) => { 96 | // NOTE(cbond): If your code gets here, you might not be using the library 97 | /// correctly. If you are assigning into a path in your state, try to 98 | /// ensure that there is a path to traverse, even if everything is just 99 | /// empty objects and arrays. If we have to guess the type of the containers 100 | /// and then create them ourselves, we may not get the types right. Use 101 | /// the Redux `initial state' construct to resolve this issue if you like. 102 | return typeof key === 'number' 103 | ? new Array() 104 | : Array.isArray(key) 105 | ? ImmutableMap() 106 | : new Object(); 107 | }; 108 | 109 | return parentOperations.update(key, 110 | remainingPath.length > 0 111 | ? getProbableType(remainingPath[0]) 112 | : value); 113 | } 114 | }); 115 | 116 | return root; 117 | } 118 | 119 | static inspect(object: K): Operations { 120 | const metaOperations = (update: Function, merge: Function, clone?: Function) => { 121 | const operations = { 122 | /// Clone the object (shallow) 123 | clone: typeof clone === 'function' 124 | ? () => clone( object) as any 125 | : () => object, 126 | 127 | /// Update a specific key inside of the container object 128 | update: (key: string, value: K) => update(operations.clone(), key, value), 129 | 130 | /// Merge existing values with new values 131 | merge: (key: string, value: K) => { 132 | const cloned = operations.clone(); 133 | return merge(cloned, key, value, (v: any) => update(cloned, key, v)); 134 | } 135 | }; 136 | 137 | return operations; 138 | }; 139 | 140 | if (Iterable.isIterable(object)) { 141 | return metaOperations( 142 | // Replace 143 | (parent: any, key: number | string, value: K) => { 144 | if (key != null) { 145 | return parent.set(key, value); 146 | } 147 | else { 148 | return value; 149 | } 150 | }, 151 | // Merge 152 | (parent: any, key: number | string | string[], value: K) => { 153 | if (key) { 154 | return parent.mergeDeepIn(Array.isArray(key) ? key : [key], value); 155 | } 156 | else { 157 | if (ImmutableMap.isMap(value)) { 158 | return parent.mergeDeep(value); 159 | } 160 | else { 161 | return parent.concat(value); 162 | } 163 | } 164 | }); 165 | } 166 | else if (Array.isArray(object)) { 167 | return metaOperations( 168 | // Replace array contents 169 | (parent: any, key: number, value: K) => { 170 | if (key != null) { 171 | parent[key] = value; 172 | } 173 | else { 174 | parent.splice.apply(parent, [0, parent.length] 175 | .concat(Array.isArray(value) ? value : [value])); 176 | } 177 | }, 178 | 179 | // Merge 180 | (parent: any, _: any, value: K, setter: (v: K) => K) => { 181 | setter(parent.concat(value)); 182 | return parent; 183 | }, 184 | 185 | // Clone 186 | () => Array.prototype.slice.call(object, 0) 187 | ); 188 | } 189 | else if (object instanceof Map) { 190 | return metaOperations( 191 | // Update map key 192 | (parent: any, key: number | string, value: K) => { 193 | if (key != null) { 194 | return parent.set(key, value); 195 | } 196 | else { 197 | const m = new Map( value); 198 | parent.clear(); 199 | m.forEach((value, index) => parent.set(index, value)); 200 | return parent; 201 | } 202 | }, 203 | 204 | // Merge 205 | (parent: Map, _: any, value: K) => { 206 | const m = new Map( value); 207 | m.forEach((value, key) => parent.set(key, value)); 208 | return parent; 209 | }, 210 | 211 | // Clone 212 | () => object instanceof WeakMap 213 | ? new WeakMap( object) 214 | : new Map( object) 215 | ); 216 | } 217 | else if (object instanceof WeakSet || object instanceof Set) { 218 | return metaOperations( 219 | // Update element at index in set 220 | (parent: any, key: number, value: K) => { 221 | if (key != null) { 222 | return parent.set(key, value); 223 | } 224 | else { 225 | const s = new Set( value); 226 | s.forEach((value, index) => parent.set(index, value)); 227 | s.clear(); 228 | return parent; 229 | } 230 | }, 231 | 232 | // Merge 233 | (parent: Set, _: any, value: any) => { 234 | for (const element of value) { 235 | parent.add(element); 236 | } 237 | return parent; 238 | }, 239 | 240 | // Clone 241 | () => object instanceof WeakSet 242 | ? new WeakSet( object) 243 | : new Set( object) 244 | ); 245 | } 246 | else if (object instanceof Date) { 247 | throw new FormException('Cannot understand why a Date object appears in the mutation path!'); 248 | } 249 | else { 250 | switch (typeof object) { 251 | case 'boolean': 252 | case 'function': 253 | case 'number': 254 | case 'string': 255 | case 'symbol': 256 | case 'undefined': 257 | break; 258 | case 'object': 259 | if (object == null) { 260 | break; 261 | } 262 | return metaOperations( 263 | (parent: any, key: any, value: K) => { 264 | if (key != null) { 265 | return Object.assign(parent, {[key]: value}); 266 | } 267 | return Object.assign(parent, value); 268 | }, 269 | (parent: any, _: any, value: K) => { 270 | for (const k of Object.keys(value)) { 271 | parent[k] = (value as any)[k]; 272 | } 273 | return parent; 274 | }, 275 | () => Object.assign({}, object) 276 | ) 277 | default: 278 | break; 279 | } 280 | } 281 | 282 | throw new Error( 283 | `An object of type ${typeof object} has appeared in the mutation path! Every element ` + 284 | 'in the mutation path should be an array, an associative container, or a set'); 285 | } 286 | 287 | static empty(value: any): boolean { 288 | return value == null 289 | || (value.length === 0 290 | || (typeof value.length === 'undefined' && Object.keys(value).length === 0)); 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ****REPO DEPRECATED**** 2 | 3 | Please note that this repo has been deprecated. Code and issues are being migrated to a monorepo at https://github.com/angular-redux/platform where we are beginning work on a new and improved v10. To file any new issues or see the state of the current code base, we would love to see you there! Thanks for your support! 4 | 5 | ## @angular-redux/form 6 | 7 | [![Join the chat at https://gitter.im/angular-redux/ng2-redux](https://badges.gitter.im/angular-redux/ng2-redux.svg)](https://gitter.im/angular-redux/ng2-redux?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 8 | [![npm version](https://img.shields.io/npm/v/@angular-redux/form.svg)](https://www.npmjs.com/package/@angular-redux/form) 9 | [![downloads per month](https://img.shields.io/npm/dm/@angular-redux/form.svg)](https://www.npmjs.com/package/@angular-redux/form) 10 | 11 | This library is a thin layer of connective tissue between Angular 2+ forms and 12 | Redux. It provides unidirectional data binding between your Redux state and 13 | your forms elements. It builds on existing Angular functionality like 14 | [NgModel](https://angular.io/docs/ts/latest/api/forms/index/NgModel-directive.html) 15 | and 16 | [NgControl](https://angular.io/docs/ts/latest/api/forms/index/NgControl-class.html) 17 | 18 | This supports both [Template driven forms](https://angular.io/guide/forms) and [Reactive driven forms](https://angular.io/guide/reactive-forms). 19 | 20 | #### Template Driven 21 | 22 | For the simplest use-cases, the API is very straightforward. Your template 23 | would look something like this: 24 | 25 | ```html 26 |
27 | 28 |
29 | ``` 30 | 31 | The important bit to note here is the `[connect]` directive. This is the only thing 32 | you should have to add to your form template in order to bind it to your Redux state. 33 | The argument provided to `connect` is basically a path to form state inside of your 34 | overall app state. So for example if my Redux app state looks like this: 35 | 36 | ```json 37 | { 38 | "foo": "bar", 39 | "myForm": { 40 | "address": "1 Foo St." 41 | } 42 | } 43 | ``` 44 | 45 | Then I would supply `myForm` as the argument to `[connect]`. If myForm were nested 46 | deeper inside of the app state, you could do something like this: 47 | 48 | ```html 49 |
50 | ... 51 |
52 | ``` 53 | 54 | Note that ImmutableJS integration is provided seamlessly. If `personalInfo` is an 55 | immutable Map structure, the library will automatically use `get()` or `getIn()` to 56 | find the appropriate bits of state. 57 | 58 | Then, in your application bootstrap code, you need to add a provider for 59 | the class that is responsible for connecting your forms to your Redux state. 60 | There are two ways of doing this: either using an `Redux.Store` object or 61 | an `NgRedux` object. There are no substantial differences between these 62 | approaches, but if you are already using 63 | [@angular-redux/store](https://github.com/angular-redux/store) or you wish to integrate 64 | it into your project, then you would do something like this: 65 | 66 | ```typescript 67 | import { NgReduxModule } from '@angular-redux/store'; 68 | import { NgReduxFormModule } from '@angular-redux/form'; 69 | 70 | @NgModule({ 71 | imports: [ 72 | BrowserModule, 73 | ReactiveFormsModule, 74 | FormsModule, 75 | NgReduxFormModule, 76 | NgReduxModule, 77 | ], 78 | bootstrap: [MyApplicationComponent] 79 | }) 80 | export class ExampleModule {} 81 | ``` 82 | 83 | Or if you are using Redux without `@angular-redux/store`, then your bootstrap call would look 84 | more like this (substitute your own store creation code): 85 | 86 | ```typescript 87 | import {provideReduxForms} from '@angular-redux/form'; 88 | 89 | const storeCreator = compose(applyMiddleware(logger))(createStore); 90 | const store = create(reducers, {}); 91 | 92 | @NgModule({ 93 | imports: [ 94 | BrowserModule, 95 | ReactiveFormsModule, 96 | FormsModule, 97 | NgReduxFormModule, 98 | ], 99 | providers: [ 100 | provideReduxForms(store), 101 | ], 102 | bootstrap: [MyApplicationComponent] 103 | }) 104 | export class ExampleModule {} 105 | ``` 106 | 107 | The essential bit of code in the above samples is the call to `provideReduxForms(...)`. 108 | This configures `@angular-redux/form` and provides access to your Redux store or NgRedux 109 | instance. The shape of the object that `provideReduxForms` expects is very 110 | basic: 111 | 112 | ```typescript 113 | export interface AbstractStore { 114 | /// Dispatch an action 115 | dispatch(action: Action & {payload?}): void; 116 | 117 | /// Retrieve the current application state 118 | getState(): RootState; 119 | 120 | /// Subscribe to changes in the store 121 | subscribe(fn: () => void): Redux.Unsubscribe; 122 | } 123 | ``` 124 | 125 | Both `NgRedux` and `Redux.Store` conform to this shape. If you have a more 126 | complicated use-case that is not covered here, you could even create your own store 127 | shim as long as it conforms to the shape of `AbstractStore`. 128 | 129 | ### How the bindings work 130 | 131 | The bindings work by inspecting the shape of your form and then binding to a Redux 132 | state object that has the same shape. The important element is `NgControl::path`. 133 | Each control in an Angular 2 form has a computed property called `path` which uses 134 | a very basic algorithm, ascending the tree from the leaf (control) to the root 135 | (the `
` element) and returning an array containing the name of each group or 136 | array in the path. So for example, let us take a look at this form that lets the 137 | user provide their full name and the names and types of their children: 138 | 139 | ```html 140 | 141 | 142 | 151 |
152 | ``` 153 | 154 | Our root `
` element has a `connect` directive that points to the state element 155 | `form1`. This means that the children within your form will all be bound to some 156 | bit of state inside of the `form1` object in your Redux state. Then we have a child 157 | input which is bound to a property called `fullname`. This is a basic text box. If 158 | you were to inspect it in the debugger, it would have a `path` value like this: 159 | 160 | ``` 161 | ['form1', 'fullname'] 162 | ``` 163 | 164 | And therefore it would bind to this piece of Redux state: 165 | 166 | ```json 167 | { 168 | "form1": { 169 | "fullname": "Chris Bond" 170 | } 171 | } 172 | ``` 173 | 174 | So far so good. But look at the array element inside our form, in the `