├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── angular.json ├── package-lock.json ├── package.json ├── projects └── rx-form-mapper │ ├── karma.conf.js │ ├── ng-package.json │ ├── package.json │ ├── src │ ├── lib │ │ ├── bind │ │ │ ├── index.ts │ │ │ ├── model-binder.ts │ │ │ └── reflect-metadata-design-types.ts │ │ ├── decorators │ │ │ ├── custom-control.decorator.ts │ │ │ ├── form-array.decorator.ts │ │ │ ├── form-control.decorator.ts │ │ │ ├── form-group.decorator.ts │ │ │ ├── form.decorator.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── interfaces │ │ │ ├── custom-control-mapper.ts │ │ │ └── index.ts │ │ ├── metadata │ │ │ ├── control-metadata.ts │ │ │ ├── control-visitor.ts │ │ │ ├── custom-control-metadata.ts │ │ │ ├── form-array-metadata.ts │ │ │ ├── form-control-metadata.ts │ │ │ ├── form-group-metadata.ts │ │ │ ├── form-metadata.ts │ │ │ ├── index.ts │ │ │ └── validable-metadata.ts │ │ ├── rx-form-mapper.module.ts │ │ ├── services │ │ │ ├── custom-mapper-resolver.ts │ │ │ ├── form-reader.ts │ │ │ ├── form-writer.ts │ │ │ ├── index.ts │ │ │ ├── rx-form-mapper.service.ts │ │ │ └── validator-resolver.ts │ │ ├── tests │ │ │ ├── custom-control-decorator.spec.ts │ │ │ ├── custom-mapper-resolver.spec.ts │ │ │ ├── form-array-decorator.spec.ts │ │ │ ├── form-control-decorator.spec.ts │ │ │ ├── form-decorator.spec.ts │ │ │ ├── form-group-decorator.spec.ts │ │ │ ├── rx-form-mapper-module.spec.ts │ │ │ ├── rx-form-mapper.spec.ts │ │ │ ├── utils.spec.ts │ │ │ └── validator-resolver.spec.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── public_api.ts │ └── test.ts │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ ├── tsconfig.spec.json │ └── tslint.json ├── renovate.json ├── resources └── logo_big.png ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = tab 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | lint: 7 | name: lint 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v1 11 | - uses: actions/setup-node@v1 12 | with: 13 | node-version: "14.x" 14 | - run: npm ci 15 | - run: npm run lint 16 | test: 17 | name: Test 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v1 21 | - uses: actions/setup-node@v1 22 | with: 23 | node-version: "14.x" 24 | - run: npm ci 25 | - run: npm run test-ci 26 | - uses: codecov/codecov-action@v1 27 | with: 28 | fail_ci_if_error: true 29 | directory: coverage 30 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release npm package 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@master 14 | - uses: actions/setup-node@v1 15 | with: 16 | node-version: "14.x" 17 | - run: npm ci 18 | - run: npm run lint 19 | - run: npm run test-ci 20 | - run: npm run build-ci 21 | - run: cp README.md dist/rx-form-mapper/ 22 | - run: cp LICENSE dist/rx-form-mapper/ 23 | - run: npx semantic-release 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # profiling files 12 | chrome-profiler-events.json 13 | speed-measure-plugin.json 14 | 15 | # IDEs and editors 16 | /.idea 17 | .project 18 | .classpath 19 | .c9/ 20 | *.launch 21 | .settings/ 22 | *.sublime-workspace 23 | 24 | # IDE - VSCode 25 | .vscode/* 26 | !.vscode/settings.json 27 | !.vscode/tasks.json 28 | !.vscode/launch.json 29 | !.vscode/extensions.json 30 | 31 | # misc 32 | /.sass-cache 33 | /connect.lock 34 | /coverage 35 | /libpeerconnection.log 36 | npm-debug.log 37 | yarn-error.log 38 | testem.log 39 | /typings 40 | 41 | # System Files 42 | .DS_Store 43 | Thumbs.db 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Iacopo Ciao 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![codecov](https://codecov.io/gh/KernelPanic92/rx-form-mapper/branch/master/graph/badge.svg)](https://codecov.io/gh/KernelPanic92/rx-form-mapper) 4 | [![npm version](https://badge.fury.io/js/rx-form-mapper.svg)](https://badge.fury.io/js/rx-form-mapper) 5 | [![dependencies Status](https://david-dm.org/KernelPanic92/rx-form-mapper/status.svg)](https://david-dm.org/KernelPanic92/rx-form-mapper) 6 | [![NPM License](https://img.shields.io/npm/l/rx-form-mapper.svg)](https://img.shields.io/npm/l/rx-form-mapper.svg) 7 | [![NPM bundle size](https://img.shields.io/bundlephobia/min/rx-form-mapper.svg)](https://img.shields.io/bundlephobia/min/rx-form-mapper.svg) 8 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](https://github.com/semantic-release/semantic-release) 9 | 10 | RxFormMapper is a framework developed for angular and allows you to convert, by annotation, classes into reactive form and vice versa. 11 | 12 | ## What is RxFormMapper 13 | 14 | Reactive forms use an explicit and immutable approach to managing the state of a form at a given point in time. Each change to the form state returns a new state, which maintains the integrity of the model between changes. Reactive forms are built around observable streams, where form inputs and values are provided as streams of input values, which can be accessed synchronously. 15 | 16 | So... Why RxFormMapper? 17 | 18 | Sometimes you want to transform the classes you have into reactive forms, for example you have a user model that you want to have filled out by a form: 19 | 20 | ```typescript 21 | 22 | export class User { 23 | name: string; 24 | surname: string; 25 | age: number; 26 | } 27 | 28 | ``` 29 | 30 | So what to do? How to make a user form ? Solution is to create new instances of Reactive Form object and manually copy all properties to new object. But things may go wrong very fast once you have a more complex object hierarchy. 31 | 32 | ```typescript 33 | 34 | new FormGroup( 35 | name: new FormControl(user.name), 36 | surname: new FormControl(user.surname), 37 | age: new FormControl(user.age), 38 | ); 39 | 40 | ``` 41 | 42 | To avoid all this you can use RxFormMapper: 43 | 44 | ```typescript 45 | 46 | export class User { 47 | 48 | @FormControl() 49 | name: string; 50 | 51 | @FormControl() 52 | surname: string; 53 | 54 | @FormControl() 55 | age: number; 56 | } 57 | 58 | ``` 59 | 60 | ```typescript 61 | 62 | import { Component } from '@angular/core'; 63 | import { FormControl } from '@angular/forms'; 64 | import { User } from 'src/app/models/user.model'; 65 | 66 | @Component({ 67 | selector: 'app-user-editor', 68 | templateUrl: './user-editor.component.html', 69 | styleUrls: ['./user-editor.component.css'] 70 | }) 71 | export class UserEditorComponent { 72 | 73 | public form: FormGroup; 74 | constructor(rxFormMapper: RxFormMapper) { 75 | this.form = rxFormMapper.writeForm(User); 76 | } 77 | } 78 | 79 | ``` 80 | 81 | ## Try it 82 | 83 | See it in action at https://stackblitz.com/edit/rx-form-mapper-example?file=src/app/user-registration.ts 84 | 85 | ## Getting started 86 | 87 | 88 | ### Install npm package 89 | 90 | ```bash 91 | npm i rx-form-mapper --save 92 | 93 | ``` 94 | 95 | `reflect-metadata` is required (with angular+ you should already have this dependency installed.) 96 | 97 | ```bash 98 | npm i reflect-metadata --save 99 | 100 | ``` 101 | 102 | ### Import the component modules 103 | Import the NgModule for RxFormMapper 104 | 105 | ```typescript 106 | import { RxFormMapperModule } from 'rx-form-mapper'; 107 | 108 | @NgModule({ 109 | ... 110 | imports: [RxFormMapperModule.forRoot()], 111 | ... 112 | }) 113 | export class MyAppModule { } 114 | ``` 115 | 116 | ### Inject RxFormMapper in your component 117 | 118 | ```typescript 119 | import { RxFormMapper } from 'rx-form-mapper'; 120 | 121 | @Component({ ... }) 122 | export class MyComponent { 123 | constructor(private readonly rxFormMapper: RxFormMapper) {} 124 | } 125 | ``` 126 | 127 | ### Build your form 128 | 129 | ```typescript 130 | import { RxFormMapper } from 'rx-form-mapper'; 131 | import { Component } from '@angular/core'; 132 | import { FormGroup } from '@angular/forms'; 133 | import { User } from 'src/app/models/user.model'; 134 | 135 | @Component({ ... }) 136 | export class MyComponent { 137 | public myForm: FormGroup; 138 | constructor(rxFormMapper: RxFormMapper) { 139 | this.myForm = rxFormMapper.writeForm(new User()); 140 | } 141 | } 142 | ``` 143 | 144 | ## Modules 145 | 146 | ### RxFormMapperModule 147 | 148 | This module enables RxFormMapper features 149 | 150 | ## Services 151 | 152 | ### RxFormMapper 153 | 154 | This service provides the methods to serialize and deserialize our objects 155 | 156 | ## Methods 157 | 158 | ### writeForm 159 | 160 | This method converts our class instance into reactive form instance 161 | 162 | ```typescript 163 | this.form = formMapper.writeForm(new Post()); 164 | ``` 165 | 166 | ### fromType 167 | 168 | This method converts our class type into reactive form instance 169 | 170 | ```typescript 171 | this.form = formMapper.fromType(Post); 172 | ``` 173 | 174 | ### readForm 175 | 176 | This method converts our form instance into specific class instance 177 | 178 | ```typescript 179 | const post: Post = formMapper.readForm(this.form, Post); 180 | ``` 181 | 182 | ## Decorators 183 | 184 | ### @FormControl 185 | 186 | If you want to expose some of properties as a FormControl, you can do it by @FormControl decorator 187 | 188 | ```typescript 189 | 190 | import { FormControl } from 'rx-form-mapper'; 191 | 192 | export class User { 193 | 194 | @FormControl() 195 | name: string; 196 | 197 | @FormControl() 198 | surname: string; 199 | 200 | @FormControl() 201 | age: number; 202 | } 203 | 204 | ``` 205 | 206 | ### @FormGroup 207 | 208 | If you want to expose some of properties as a FormGroup, you can do it by @FormGroup decorator 209 | 210 | ```typescript 211 | 212 | import { FormGroup } from 'rx-form-mapper'; 213 | 214 | export class Child {} 215 | 216 | export class User { 217 | @FormGroup() 218 | child: Child; 219 | } 220 | 221 | ``` 222 | 223 | ### @FormArray 224 | 225 | If you want to expose some of properties as a FormArray, you can do it by @FormArray decorator 226 | 227 | ```typescript 228 | 229 | import { FormGroup } from 'rx-form-mapper'; 230 | 231 | export class Child {} 232 | 233 | export class User { 234 | @FormArray(Child) 235 | children: Child[]; 236 | } 237 | 238 | ``` 239 | 240 | When you're trying to serialize a property into FormArray its required to known what type of object you are trying to convert. 241 | 242 | ### @Form 243 | 244 | If you want to add extra data to your form, you can do it by optional @Form decorator 245 | 246 | ```typescript 247 | 248 | import { Form } from 'rx-form-mapper'; 249 | 250 | @Form({ 251 | validators: Validators.required 252 | }) 253 | export class User { 254 | 255 | @FormControl() 256 | name: string; 257 | 258 | @FormControl() 259 | surname: string; 260 | 261 | @FormControl() 262 | age: number; 263 | } 264 | 265 | ``` 266 | 267 | ### @CustomControl 268 | 269 | If you want to create custom forms for specific fields, you can do it by @CustomControl decorator 270 | 271 | Declare your custom mapper class implementing `CustomControlMapper` interface 272 | 273 | ```typescript 274 | 275 | import { CustomControlMapper } from 'rx-form-mapper'; 276 | import { AbstractControlOptions, FormControl } from '@angular/forms'; 277 | 278 | export class CustomAuthorControlMapper implements CustomControlMapper { 279 | 280 | public writeForm(value: any, abstractControlOptions: AbstractControlOptions): AbstractControl { 281 | return new FormControl(value, abstractControlOptions); 282 | } 283 | 284 | public readForm(control: AbstractControl): ChildTestClass { 285 | return control.value; 286 | } 287 | 288 | } 289 | 290 | ``` 291 | 292 | And pass it's type as argument of CustomControl decorator 293 | 294 | 295 | ```typescript 296 | 297 | import { Form } from 'rx-form-mapper'; 298 | import { CustomAuthorControlMapper } from '.'; 299 | 300 | export class Post { 301 | 302 | @CustomControl(CustomAuthorControlMapper) 303 | author: Person; 304 | 305 | } 306 | 307 | ``` 308 | 309 | ## Injectable CustomMapper 310 | 311 | Sometimes you want to injects other services into your CustomMapper, RxFormMapper allows you to do it simple: 312 | 313 | Declare your CustomControlMapper class, decorate with `@Injectable` and includes it in a module as a normal service. 314 | 315 | ```typescript 316 | 317 | import { CustomControlMapper } from 'rx-form-mapper'; 318 | import { AbstractControlOptions, FormControl } from '@angular/forms'; 319 | 320 | @Injectable() 321 | export class CustomAuthorControlMapper implements CustomControlMapper { 322 | 323 | public writeForm(value: any, abstractControlOptions: AbstractControlOptions): AbstractControl { 324 | return new FormControl(value, abstractControlOptions); 325 | } 326 | 327 | public readForm(control: AbstractControl): ChildTestClass { 328 | return control.value; 329 | } 330 | 331 | } 332 | 333 | ``` 334 | 335 | And pass it's type as validator or asyncValidator option 336 | 337 | ```typescript 338 | 339 | import { Form } from 'rx-form-mapper'; 340 | import { CustomAuthorControlMapper } from '.'; 341 | 342 | export class Post { 343 | 344 | @CustomControl(CustomAuthorControlMapper) 345 | author: Person; 346 | 347 | } 348 | 349 | ``` 350 | 351 | ## Validators 352 | 353 | If you want to set a validator on a class or a property, you can do it by specifying `validators` option to `@Form`, `@FormControl`,`@CustomControl` or `@FormArray` decorators 354 | 355 | ```typescript 356 | 357 | import { FormControl } from 'rx-form-mapper'; 358 | 359 | export class User { 360 | 361 | @FormControl({ 362 | validators: Validators.required 363 | }) 364 | completeName: string; 365 | 366 | } 367 | 368 | ``` 369 | 370 | ## Async validators 371 | 372 | If you want to set an AsyncValidator on a class or a property, you can do it by specifying `asyncValidators` option to `@Form`, `@FormControl`,`@CustomControl` or `@FormArray` decorators 373 | 374 | ```typescript 375 | 376 | import { FormControl } from 'rx-form-mapper'; 377 | 378 | const asyncValidator = (control: AbstractControl) => return of(undefined); 379 | 380 | export class User { 381 | 382 | 383 | @FormControl({ 384 | asyncValidators: asyncValidator 385 | }) 386 | name: string; 387 | 388 | } 389 | 390 | ``` 391 | 392 | ## Injectable validators 393 | 394 | Sometimes you want to injects other services into your validator or asyncValidator, RxFormMapper allows you to do it simple with Angular Forms interfaces: 395 | 396 | Declare your validator class implementing `Validator` or `AsyncValidator` interfaces, decorate with `@Injectable` and includes it in a module as a normal service. 397 | 398 | ```typescript 399 | 400 | import { AsyncValidator } from '@angular/forms'; 401 | 402 | @Injectable() 403 | export class UniqueNameValidator implements AsyncValidator { 404 | 405 | constructor(private readonly http: HttpProvider) {} 406 | 407 | public validate(control: AbstractControl): Promise | Observable { 408 | // implementation 409 | } 410 | 411 | } 412 | 413 | ``` 414 | 415 | And pass it's type as validator or asyncValidator option 416 | 417 | ```typescript 418 | 419 | import { FormControl } from 'rx-form-mapper'; 420 | import { UniqueNameValidator } from 'src/app/validators/unique-Name.validator'; 421 | 422 | export class User { 423 | 424 | @FormControl({ 425 | asyncValidators: UniqueNameValidator 426 | }) 427 | name: string; 428 | 429 | } 430 | 431 | ``` 432 | 433 | ## Validation strategy 434 | 435 | Sometimes you want to change the default strategy of form validation, you can do it specifying `updateOn` option to `@Form`, `@FormControl`,`@CustomControl` or `@FormArray` decorators 436 | 437 | ```typescript 438 | 439 | import { FormControl } from 'rx-form-mapper'; 440 | 441 | export class User { 442 | 443 | @FormControl({ 444 | validators: Validators.required, 445 | updateOn: 'blur' 446 | }) 447 | name: string; 448 | 449 | } 450 | 451 | ``` 452 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "rx-form-mapper": { 7 | "root": "projects/rx-form-mapper", 8 | "sourceRoot": "projects/rx-form-mapper/src", 9 | "projectType": "library", 10 | "prefix": "lib", 11 | "architect": { 12 | "build": { 13 | "builder": "@angular-devkit/build-angular:ng-packagr", 14 | "options": { 15 | "tsConfig": "projects/rx-form-mapper/tsconfig.lib.json", 16 | "project": "projects/rx-form-mapper/ng-package.json" 17 | }, 18 | "configurations": { 19 | "production": { 20 | "tsConfig": "projects/rx-form-mapper/tsconfig.lib.prod.json" 21 | } 22 | } 23 | }, 24 | "test": { 25 | "builder": "@angular-devkit/build-angular:karma", 26 | "options": { 27 | "main": "projects/rx-form-mapper/src/test.ts", 28 | "tsConfig": "projects/rx-form-mapper/tsconfig.spec.json", 29 | "karmaConfig": "projects/rx-form-mapper/karma.conf.js", 30 | "codeCoverage": true 31 | } 32 | }, 33 | "lint": { 34 | "builder": "@angular-devkit/build-angular:tslint", 35 | "options": { 36 | "tsConfig": [ 37 | "projects/rx-form-mapper/tsconfig.lib.json", 38 | "projects/rx-form-mapper/tsconfig.spec.json" 39 | ], 40 | "exclude": [ 41 | "**/node_modules/**" 42 | ] 43 | } 44 | } 45 | } 46 | } 47 | }, 48 | "defaultProject": "rx-form-mapper" 49 | } 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rx-form-mapper", 3 | "version": "0.0.0-development", 4 | "description": "Proper decorator-based transformation / serialization / deserialization of plain javascript classes to angular reactive forms", 5 | "author": { 6 | "email": "iacopociao1992@gmail.com", 7 | "name": "KernelPanic92", 8 | "url": "https://github.com/KernelPanic92" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/KernelPanic92/rx-form-mapper" 13 | }, 14 | "license": "MIT", 15 | "keywords": [ 16 | "angular", 17 | "reactive-form", 18 | "mapper", 19 | "converter", 20 | "decorator-pattern", 21 | "typescript-library" 22 | ], 23 | "scripts": { 24 | "ng": "ng", 25 | "start": "ng serve", 26 | "build": "ng build", 27 | "build-ci": "ng build --prod", 28 | "test": "ng test", 29 | "test-ci": "ng test --watch=false --browsers=ChromeHeadless", 30 | "lint": "ng lint", 31 | "e2e": "ng e2e", 32 | "semantic-release": "semantic-release", 33 | "postinstall": "ngcc" 34 | }, 35 | "private": false, 36 | "dependencies": { 37 | "@angular/animations": "11.2.8", 38 | "@angular/common": "11.2.8", 39 | "@angular/compiler": "11.2.8", 40 | "@angular/core": "11.2.8", 41 | "@angular/forms": "11.2.8", 42 | "@angular/platform-browser": "11.2.8", 43 | "@angular/platform-browser-dynamic": "11.2.8", 44 | "@angular/router": "11.2.8", 45 | "@types/lodash": "4.14.168", 46 | "lodash": "4.17.21", 47 | "rxjs": "6.6.7", 48 | "tslib": "2.1.0", 49 | "zone.js": "0.11.4" 50 | }, 51 | "devDependencies": { 52 | "@angular-devkit/build-angular": "0.1102.6", 53 | "@angular/cli": "11.2.6", 54 | "@angular/compiler-cli": "11.2.8", 55 | "@types/jasmine": "3.6.9", 56 | "@types/node": "12.20.6", 57 | "codelyzer": "6.0.1", 58 | "cz-conventional-changelog": "3.3.0", 59 | "jasmine-core": "3.7.1", 60 | "jasmine-spec-reporter": "6.0.0", 61 | "karma": "6.3.1", 62 | "karma-chrome-launcher": "3.1.0", 63 | "karma-coverage": "2.0.3", 64 | "karma-jasmine": "4.0.1", 65 | "karma-jasmine-html-reporter": "1.5.4", 66 | "ng-packagr": "11.2.4", 67 | "protractor": "7.0.0", 68 | "semantic-release": "17.4.2", 69 | "ts-node": "9.1.1", 70 | "tslint": "6.1.3", 71 | "typescript": "4.1.5" 72 | }, 73 | "release": { 74 | "pkgRoot": "dist/rx-form-mapper" 75 | }, 76 | "config": { 77 | "commitizen": { 78 | "path": "./node_modules/cz-conventional-changelog" 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | jasmineHtmlReporter: { 19 | suppressAll: true // removes the duplicated traces 20 | }, 21 | coverageReporter: { 22 | dir: require('path').join(__dirname, '../../coverage'), 23 | subdir: '.', 24 | reporters: [ 25 | { type: 'html' }, 26 | { type: 'text-summary' }, 27 | {type: 'lcovonly'} 28 | ] 29 | }, 30 | reporters: ['progress', 'kjhtml'], 31 | port: 9876, 32 | colors: true, 33 | logLevel: config.LOG_INFO, 34 | autoWatch: true, 35 | browsers: ['Chrome'], 36 | singleRun: false 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/rx-form-mapper", 4 | "lib": { 5 | "entryFile": "src/public_api.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rx-form-mapper", 3 | "description" : "Proper decorator-based transformation / serialization / deserialization of plain javascript classes to angular reactive forms", 4 | "version": "0.9.0", 5 | "author": { 6 | "email": "iacopociao1992@gmail.com", 7 | "name": "KernelPanic92", 8 | "url": "https://github.com/KernelPanic92" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/KernelPanic92/rx-form-mapper" 13 | }, 14 | "license": "MIT", 15 | "keywords": [ 16 | "angular", 17 | "reactive-form", 18 | "mapper", 19 | "converter", 20 | "decorator-pattern", 21 | "typescript-library" 22 | ], 23 | "dependencies": { 24 | "tslib": "2.1.0" 25 | }, 26 | "peerDependencies": { 27 | "@angular/common": "^7.1.0 || ^8.0.0 || ^11.0.0", 28 | "@angular/core": "^7.1.0 || ^8.0.0 || ^11.0.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/bind/index.ts: -------------------------------------------------------------------------------- 1 | export * from './model-binder'; 2 | export * from './reflect-metadata-design-types'; 3 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/bind/model-binder.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@angular/core'; 2 | import 'reflect-metadata'; 3 | import { MetadataDesignTypes } from '.'; 4 | import { CustomControlOpts, FormArrayOpts, FormControlOpts, FormOpts } from '../decorators'; 5 | import { FormMetadata } from '../metadata'; 6 | 7 | export class ModelBinder { 8 | 9 | private metadataKey = 'rx-form-mapper-metadata'; 10 | 11 | public static readonly instance = new ModelBinder(); 12 | 13 | private constructor() {} 14 | 15 | public getMetadata(target: Type): FormMetadata { 16 | if (!Reflect.hasMetadata(this.metadataKey, target)) { 17 | Reflect.defineMetadata(this.metadataKey, new FormMetadata(target), target); 18 | } 19 | 20 | return Reflect.getMetadata(this.metadataKey, target); 21 | } 22 | 23 | public bindForm(target: Type, opts: FormOpts) { 24 | const formMetadata: FormMetadata = this.getMetadata(target); 25 | formMetadata.setValidators(opts); 26 | } 27 | 28 | public bindCustomControl(target: {constructor: Type}, propertyName: string, opts: CustomControlOpts) { 29 | this.getMetadata(target.constructor).setCustomControl(propertyName, opts.mapper, opts); 30 | } 31 | 32 | public bindFormControl(target: {constructor: Type}, propertyName: string, opts?: FormControlOpts): void { 33 | this.getMetadata(target.constructor).setFormControl(propertyName, opts); 34 | } 35 | 36 | public bindFormGroup(target: {constructor: Type}, propertyName: string, type?: Type): void { 37 | const propertyType = type ?? Reflect.getMetadata(MetadataDesignTypes.TYPE, target, propertyName); 38 | const propertyFormMetadata = this.getMetadata(propertyType); 39 | this.getMetadata(target.constructor).setFormGroup(propertyName, propertyFormMetadata); 40 | } 41 | 42 | public bindFormArray(target: {constructor: Type}, propertyName: string, opts: FormArrayOpts): void { 43 | const itemFormMetadata = this.getMetadata(opts.type); 44 | this.getMetadata(target.constructor).setFormArray(propertyName, itemFormMetadata, opts); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/bind/reflect-metadata-design-types.ts: -------------------------------------------------------------------------------- 1 | export enum MetadataDesignTypes { 2 | TYPE = 'design:type', 3 | RETURN_TYPE = 'design:returntype', 4 | PARAM_TYPE = 'design:paramtypes' 5 | } 6 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/decorators/custom-control.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@angular/core'; 2 | import { isNil } from 'lodash'; 3 | import 'reflect-metadata'; 4 | import { RxValidator, RxAsyncValidator } from '..'; 5 | import { CustomControlMapper } from '../interfaces/custom-control-mapper'; 6 | import { UpdateOn } from '../types'; 7 | import { ModelBinder } from './../bind/model-binder'; 8 | 9 | export interface CustomControlOpts { 10 | mapper: Type, 11 | validators?: RxValidator | RxValidator[]; 12 | asyncValidators?: RxAsyncValidator | RxAsyncValidator[]; 13 | updateOn?: UpdateOn; 14 | } 15 | 16 | export function CustomControl(mapper: Type): (target: any, propertyName: string) => void; 17 | export function CustomControl(opts: CustomControlOpts): (target: any, propertyName: string) => void; 18 | export function CustomControl(optsOrMapper: Type | CustomControlOpts): (target: any, propertyName: string) => void { 19 | return (target: any, propertyName: string) => { 20 | 21 | if (isNil(optsOrMapper)) { 22 | throw new Error(`unexpected CustomControl configuration: ${optsOrMapper}`); 23 | } 24 | 25 | if (typeof(optsOrMapper) !== 'object') { 26 | optsOrMapper = { mapper: optsOrMapper }; 27 | } 28 | 29 | ModelBinder.instance.bindCustomControl(target, propertyName, optsOrMapper); 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/decorators/form-array.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@angular/core'; 2 | import { isFunction, isNil } from 'lodash'; 3 | import 'reflect-metadata'; 4 | import { RxValidator, RxAsyncValidator, UpdateOn, isType } from '..'; 5 | import { ModelBinder } from '../bind/model-binder'; 6 | 7 | export interface FormArrayOpts { 8 | validators?: RxValidator | RxValidator[]; 9 | asyncValidators?: RxAsyncValidator | RxAsyncValidator[]; 10 | updateOn?: UpdateOn; 11 | type: Type; 12 | } 13 | 14 | export function FormArray(type: Type): (target: Object, propertyName: string) => void; 15 | export function FormArray(opts: FormArrayOpts): (target: Object, propertyName: string) => void; 16 | export function FormArray(optsOrType: FormArrayOpts | Type): (target: Object, propertyName: string) => void { 17 | return (target: any, propertyName: string) => { 18 | 19 | if (isNil(optsOrType)) { 20 | throw new Error(`unexpected FormArray configuration: ${optsOrType}`); 21 | } 22 | 23 | if (isType(optsOrType)) { 24 | optsOrType = { type: optsOrType }; 25 | } 26 | 27 | ModelBinder.instance.bindFormArray(target, propertyName, optsOrType); 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/decorators/form-control.decorator.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { RxAsyncValidator, RxValidator, UpdateOn } from '../types'; 3 | import { ModelBinder } from './../bind/model-binder'; 4 | 5 | export interface FormControlOpts { 6 | validators?: RxValidator | RxValidator[]; 7 | asyncValidators?: RxAsyncValidator | RxAsyncValidator[]; 8 | updateOn?: UpdateOn; 9 | } 10 | 11 | export function FormControl(opts?: FormControlOpts): (target: any, propertyName: string) => void { 12 | return (target: any, propertyName: string) => { 13 | ModelBinder.instance.bindFormControl(target, propertyName, opts); 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/decorators/form-group.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@angular/core'; 2 | import 'reflect-metadata'; 3 | import { ModelBinder } from '../bind/model-binder'; 4 | 5 | export function FormGroup(type?: Type): (target: any, propertyName: string) => void { 6 | return (target: any, propertyName: string) => { 7 | ModelBinder.instance.bindFormGroup(target, propertyName, type); 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/decorators/form.decorator.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { RxValidator, RxAsyncValidator, UpdateOn } from '..'; 3 | import { ModelBinder } from '../bind/model-binder'; 4 | 5 | export interface FormOpts { 6 | validators?: RxValidator | RxValidator[]; 7 | asyncValidators?: RxAsyncValidator | RxAsyncValidator[]; 8 | updateOn?: UpdateOn; 9 | } 10 | 11 | export function Form(opts?: FormOpts): (target: any) => void { 12 | return (target: any) => { 13 | ModelBinder.instance.bindForm(target, opts); 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './custom-control.decorator'; 2 | export * from './form-array.decorator'; 3 | export * from './form-control.decorator'; 4 | export * from './form-group.decorator'; 5 | export * from './form.decorator'; 6 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './bind'; 2 | export * from './decorators'; 3 | export * from './interfaces'; 4 | export * from './metadata'; 5 | export * from './rx-form-mapper.module'; 6 | export * from './services'; 7 | export * from './types'; 8 | export * from './utils'; 9 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/interfaces/custom-control-mapper.ts: -------------------------------------------------------------------------------- 1 | import { AbstractControl, AbstractControlOptions } from '@angular/forms'; 2 | 3 | 4 | export interface CustomControlMapper { 5 | writeForm(value: any, abstractControlOptions: AbstractControlOptions): AbstractControl; 6 | readForm(control: AbstractControl): any; 7 | } 8 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './custom-control-mapper'; 2 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/metadata/control-metadata.ts: -------------------------------------------------------------------------------- 1 | import { ControlVisitor } from './control-visitor'; 2 | 3 | export interface ControlMetadata { 4 | accept(visitor: ControlVisitor): T; 5 | } 6 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/metadata/control-visitor.ts: -------------------------------------------------------------------------------- 1 | import { FormControlMetadata } from './form-control-metadata'; 2 | import { FormGroupMetadata } from './form-group-metadata'; 3 | import { FormMetadata } from './form-metadata'; 4 | import { FormArrayMetadata } from './form-array-metadata'; 5 | import { CustomControlMetadata } from './custom-control-metadata'; 6 | 7 | export interface ControlVisitor { 8 | visitCustomControlMetadata(customControlMetadata: CustomControlMetadata): T; 9 | visitFormArrayMetadata(formArrayMetadata: FormArrayMetadata): T; 10 | visitFormControlMetadata(formControlMetadata: FormControlMetadata): T; 11 | visitFormGroupMetadata(formGroupMetadata: FormGroupMetadata): T; 12 | visitFormMetadata(formMetadata: FormMetadata): T; 13 | } 14 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/metadata/custom-control-metadata.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Type } from '@angular/core'; 3 | import { CustomControlMapper } from '../interfaces'; 4 | import { ControlVisitor } from './control-visitor'; 5 | import { ValidableMetadata } from './validable-metadata'; 6 | 7 | export class CustomControlMetadata extends ValidableMetadata { 8 | 9 | public constructor(public readonly mapper: Type) { 10 | super(); 11 | } 12 | 13 | public accept(visitor: ControlVisitor): T { 14 | return visitor.visitCustomControlMetadata(this); 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/metadata/form-array-metadata.ts: -------------------------------------------------------------------------------- 1 | import { ControlVisitor } from './control-visitor'; 2 | import { FormMetadata } from './form-metadata'; 3 | import { ValidableMetadata } from './validable-metadata'; 4 | 5 | export class FormArrayMetadata extends ValidableMetadata { 6 | 7 | public constructor(public readonly itemForm: FormMetadata) { 8 | super(); 9 | } 10 | 11 | public accept(visitor: ControlVisitor): T { 12 | return visitor.visitFormArrayMetadata(this); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/metadata/form-control-metadata.ts: -------------------------------------------------------------------------------- 1 | import { ControlVisitor } from './control-visitor'; 2 | import { ValidableMetadata } from './validable-metadata'; 3 | 4 | export class FormControlMetadata extends ValidableMetadata { 5 | 6 | public accept(visitor: ControlVisitor): T { 7 | return visitor.visitFormControlMetadata(this); 8 | } 9 | 10 | } 11 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/metadata/form-group-metadata.ts: -------------------------------------------------------------------------------- 1 | import { ControlMetadata } from './control-metadata'; 2 | import { ControlVisitor } from './control-visitor'; 3 | import { FormMetadata } from './form-metadata'; 4 | 5 | export class FormGroupMetadata implements ControlMetadata { 6 | 7 | public constructor(public readonly form: FormMetadata) {} 8 | 9 | public accept(visitor: ControlVisitor): T { 10 | return visitor.visitFormGroupMetadata(this); 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/metadata/form-metadata.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@angular/core'; 2 | import { CustomControlMapper } from '../interfaces'; 3 | import { ControlVisitor } from './control-visitor'; 4 | import { CustomControlMetadata } from './custom-control-metadata'; 5 | import { FormArrayMetadata } from './form-array-metadata'; 6 | import { FormControlMetadata } from './form-control-metadata'; 7 | import { FormGroupMetadata } from './form-group-metadata'; 8 | import { ValidableMetadata, ValidationOpts } from './validable-metadata'; 9 | 10 | type FormPropertyMetadata = FormControlMetadata | FormGroupMetadata | FormArrayMetadata; 11 | 12 | export class FormMetadata extends ValidableMetadata { 13 | 14 | private _controls: {[key: string]: FormPropertyMetadata} = {}; 15 | 16 | public constructor(public readonly type: Type) { 17 | super(); 18 | } 19 | 20 | public accept(visitor: ControlVisitor): T { 21 | return visitor.visitFormMetadata(this); 22 | } 23 | 24 | public get controls(): {[key: string]: FormPropertyMetadata} { 25 | return this._controls; 26 | } 27 | 28 | public setFormControl(name: string, opts: ValidationOpts): void { 29 | const control = new FormControlMetadata(); 30 | control.setValidators(opts); 31 | this.controls[name] = control; 32 | } 33 | 34 | public setFormGroup(name: string, form: FormMetadata): void { 35 | const control = new FormGroupMetadata(form); 36 | this.controls[name] = control; 37 | } 38 | 39 | public setCustomControl(name: string, mapper: Type, opts: ValidationOpts): void { 40 | const control = new CustomControlMetadata(mapper); 41 | control.setValidators(opts); 42 | this.controls[name] = control; 43 | } 44 | 45 | public setFormArray(name: string, itemform: FormMetadata, opts: ValidationOpts): void { 46 | const control = new FormArrayMetadata(itemform); 47 | control.setValidators(opts); 48 | this.controls[name] = control; 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/metadata/index.ts: -------------------------------------------------------------------------------- 1 | export * from './control-metadata'; 2 | export * from './control-visitor'; 3 | export * from './custom-control-metadata'; 4 | export * from './form-array-metadata'; 5 | export * from './form-control-metadata'; 6 | export * from './form-group-metadata'; 7 | export * from './form-metadata'; 8 | export * from './validable-metadata'; 9 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/metadata/validable-metadata.ts: -------------------------------------------------------------------------------- 1 | import { RxValidator, RxAsyncValidator, UpdateOn } from '..'; 2 | import { coerceArray } from '../utils'; 3 | import { ControlMetadata } from './control-metadata'; 4 | import { ControlVisitor } from './control-visitor'; 5 | 6 | export interface ValidationOpts { 7 | validators?: RxValidator | RxValidator[]; 8 | asyncValidators?: RxAsyncValidator | RxAsyncValidator[]; 9 | updateOn?: UpdateOn; 10 | } 11 | 12 | export abstract class ValidableMetadata implements ControlMetadata { 13 | 14 | private _validators: RxValidator[] = []; 15 | private _asyncValidators: RxAsyncValidator[] = []; 16 | private _updateOn: UpdateOn; 17 | 18 | public abstract accept(visitor: ControlVisitor): T; 19 | 20 | public setValidators(opts?: ValidationOpts): void { 21 | this._validators = coerceArray(opts?.validators); 22 | this._asyncValidators = coerceArray(opts?.asyncValidators); 23 | this._updateOn = opts?.updateOn; 24 | } 25 | 26 | public get validators(): RxValidator[] { 27 | return this._validators; 28 | } 29 | 30 | public get asyncValidators(): RxAsyncValidator[] { 31 | return this._asyncValidators; 32 | } 33 | 34 | public get updateOn(): UpdateOn { 35 | return this._updateOn; 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/rx-form-mapper.module.ts: -------------------------------------------------------------------------------- 1 | import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core'; 2 | import { RxFormMapper, ValidatorResolver, CustomMapperResolver } from './services'; 3 | 4 | @NgModule() 5 | export class RxFormMapperModule { 6 | 7 | public static forRoot(): ModuleWithProviders { 8 | return { 9 | ngModule: RxFormMapperModule, 10 | providers: [ 11 | RxFormMapper, 12 | CustomMapperResolver, 13 | ValidatorResolver 14 | ] 15 | }; 16 | } 17 | 18 | public constructor(@Optional() @SkipSelf() parentModule?: RxFormMapperModule) { 19 | if (parentModule) { 20 | throw new Error('RxFormMapperModule is already loaded. Import it in the AppModule only'); 21 | } 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/services/custom-mapper-resolver.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, InjectFlags, Injector, Type } from '@angular/core'; 2 | import { CustomControlMapper } from '../interfaces'; 3 | 4 | @Injectable() 5 | export class CustomMapperResolver { 6 | public constructor(private readonly injector: Injector) {} 7 | 8 | public resolve(type: Type): CustomControlMapper { 9 | return this.injector.get(type, null, InjectFlags.Optional) ?? new type(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/services/form-reader.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@angular/core'; 2 | import { AbstractControl, FormArray, FormControl, FormGroup } from '@angular/forms'; 3 | import { ControlVisitor, CustomControlMetadata, FormArrayMetadata, FormControlMetadata, FormGroupMetadata, FormMetadata } from '../metadata'; 4 | import { CustomMapperResolver } from './custom-mapper-resolver'; 5 | 6 | export class FormReader implements ControlVisitor { 7 | 8 | public constructor(private readonly control: AbstractControl, private readonly customMapperResolver: CustomMapperResolver) {} 9 | 10 | public visitCustomControlMetadata(customControlMetadata: CustomControlMetadata): any { 11 | return this.customMapperResolver.resolve(customControlMetadata.mapper).readForm(this.control); 12 | } 13 | 14 | public visitFormArrayMetadata(formArrayMetadata: FormArrayMetadata): any { 15 | if (!this.control) { 16 | return void(0); 17 | } 18 | 19 | this.checkControlType(this.control, FormArray); 20 | 21 | return (this.control as FormArray).controls.map(control => { 22 | const subFormReader = this.copyPrototype(control); 23 | return formArrayMetadata.itemForm.accept(subFormReader); 24 | }); 25 | } 26 | 27 | public visitFormControlMetadata(formControlMetadata: FormControlMetadata): any { 28 | if (!this.control) { 29 | return void(0); 30 | } 31 | 32 | this.checkControlType(this.control, FormControl); 33 | 34 | return this.control.value; 35 | } 36 | 37 | public visitFormGroupMetadata(formGroupMetadata: FormGroupMetadata): any { 38 | return this.visitFormMetadata(formGroupMetadata.form); 39 | } 40 | 41 | public visitFormMetadata(formMetadata: FormMetadata): any { 42 | if (!this.control) { 43 | return void(0); 44 | } 45 | 46 | this.checkControlType(this.control, FormGroup); 47 | 48 | const value = new formMetadata.type(); 49 | const formGroup: FormGroup = this.control as FormGroup; 50 | 51 | for (const [key, controlMetadata] of Object.entries(formMetadata.controls)) { 52 | const formField = formGroup.controls[key]; 53 | const formFieldReader = this.copyPrototype(formField); 54 | value[key] = controlMetadata.accept(formFieldReader); 55 | } 56 | 57 | return value; 58 | } 59 | 60 | private copyPrototype(control: AbstractControl): FormReader { 61 | return new FormReader(control, this.customMapperResolver); 62 | } 63 | 64 | private checkControlType(control: AbstractControl, type: Type): void { 65 | if (control instanceof type) { 66 | return; 67 | } 68 | 69 | throw new Error(`control is not ${type.name} instance`); 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/services/form-writer.ts: -------------------------------------------------------------------------------- 1 | import { InjectFlags, Injector, Type } from '@angular/core'; 2 | import { AbstractControl, AbstractControlOptions, FormArray, Validator, ValidatorFn, FormControl, FormGroup } from '@angular/forms'; 3 | 4 | import { CustomControlMapper } from '../interfaces'; 5 | import { ControlVisitor, CustomControlMetadata, FormArrayMetadata, FormControlMetadata, FormGroupMetadata, FormMetadata, ValidableMetadata } from '../metadata'; 6 | import { CustomMapperResolver } from './custom-mapper-resolver'; 7 | import { ValidatorResolver } from './validator-resolver'; 8 | 9 | export class FormWriter implements ControlVisitor { 10 | 11 | public constructor(private readonly value: any, private readonly customMapperResolver: CustomMapperResolver, private readonly validatorResolver: ValidatorResolver) {} 12 | 13 | public visitCustomControlMetadata(customControlMetadata: CustomControlMetadata): AbstractControl { 14 | const mapper = this.customMapperResolver.resolve(customControlMetadata.mapper); 15 | return mapper.writeForm(this.value, this.buildAbstractControlOptions(customControlMetadata)) 16 | } 17 | 18 | public visitFormArrayMetadata(formArrayMetadata: FormArrayMetadata): AbstractControl { 19 | const values: any[] = this.value ?? []; 20 | const controls: AbstractControl[] = []; 21 | 22 | for (const item of values) { 23 | const subWriter = this.copyPrototype(item); 24 | const control = formArrayMetadata.itemForm.accept(subWriter); 25 | controls.push(control); 26 | } 27 | 28 | return new FormArray(controls, this.buildAbstractControlOptions(formArrayMetadata)); 29 | } 30 | 31 | public visitFormControlMetadata(formControlMetadata: FormControlMetadata): AbstractControl { 32 | return new FormControl(this.value, this.buildAbstractControlOptions(formControlMetadata)); 33 | } 34 | 35 | public visitFormGroupMetadata(formGroupMetadata: FormGroupMetadata): AbstractControl { 36 | return this.visitFormMetadata(formGroupMetadata.form); 37 | } 38 | 39 | public visitFormMetadata(formMetadata: FormMetadata): AbstractControl { 40 | const controls: { [key: string]: AbstractControl } = {}; 41 | 42 | for (const [key, controlMetadata] of Object.entries(formMetadata.controls)) { 43 | const fieldValue = this.value?.[key]; 44 | const subWriter = this.copyPrototype(fieldValue); 45 | controls[key] = controlMetadata.accept(subWriter); 46 | } 47 | 48 | return new FormGroup(controls, this.buildAbstractControlOptions(formMetadata)); 49 | } 50 | 51 | 52 | private buildAbstractControlOptions(metadata: ValidableMetadata): AbstractControlOptions { 53 | return { 54 | validators: metadata.validators.map(v => this.validatorResolver.resolve(v)), 55 | asyncValidators: metadata.asyncValidators.map(v => this.validatorResolver.resolve(v)), 56 | updateOn: metadata.updateOn 57 | }; 58 | } 59 | 60 | private copyPrototype(value: any): FormWriter { 61 | return new FormWriter(value, this.customMapperResolver, this.validatorResolver); 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './custom-mapper-resolver'; 2 | export * from './form-reader'; 3 | export * from './form-writer'; 4 | export * from './rx-form-mapper.service'; 5 | export * from './validator-resolver'; 6 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/services/rx-form-mapper.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Injector, Type } from '@angular/core'; 2 | import { FormGroup } from '@angular/forms'; 3 | import { isNil } from 'lodash'; 4 | import { ModelBinder } from '../bind'; 5 | import { CustomMapperResolver } from './custom-mapper-resolver'; 6 | import { FormReader } from './form-reader'; 7 | import { FormWriter } from './form-writer'; 8 | import { ValidatorResolver } from './validator-resolver'; 9 | 10 | @Injectable() 11 | export class RxFormMapper { 12 | public constructor( 13 | private readonly customMapperResolver: CustomMapperResolver, 14 | private readonly validatorResolver: ValidatorResolver 15 | ) {} 16 | 17 | public fromType(type: Type): FormGroup { 18 | if (isNil(type)) { 19 | throw new Error('type cannot be inferred implicitly'); 20 | } 21 | 22 | const formWriter = new FormWriter(void(0), this.customMapperResolver, this.validatorResolver); 23 | return ModelBinder.instance.getMetadata(type).accept(formWriter) as FormGroup; 24 | } 25 | 26 | public writeForm(value: T): FormGroup; 27 | public writeForm(value: T, type: Type): FormGroup; 28 | public writeForm(value: T, type?: Type): FormGroup { 29 | if (isNil(value) && isNil(type)) { 30 | throw new Error('type cannot be inferred implicitly'); 31 | } 32 | 33 | const valueType = type ?? Object.getPrototypeOf(value).constructor; 34 | const formWriter = new FormWriter(value, this.customMapperResolver, this.validatorResolver); 35 | return ModelBinder.instance.getMetadata(valueType).accept(formWriter) as FormGroup; 36 | 37 | } 38 | 39 | public readForm(form: FormGroup, type: Type ): T { 40 | if (isNil(type)) { 41 | throw new Error('type cannot be inferred implicitly'); 42 | } 43 | 44 | const formReader = new FormReader(form, this.customMapperResolver); 45 | return ModelBinder.instance.getMetadata(type).accept(formReader); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/services/validator-resolver.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, InjectFlags, Injector } from '@angular/core'; 2 | import { AbstractControl, AsyncValidatorFn, ValidatorFn } from '@angular/forms'; 3 | import { isFunction } from 'lodash'; 4 | import { RxAsyncValidator, RxValidator } from '../types'; 5 | 6 | @Injectable() 7 | export class ValidatorResolver { 8 | 9 | public constructor(private readonly injector: Injector) {} 10 | 11 | public resolve(validator: RxAsyncValidator): AsyncValidatorFn; 12 | public resolve(validator: RxValidator): ValidatorFn; 13 | public resolve(validator: any): any { 14 | 15 | if (this.isValidatorFn(validator)) { 16 | return validator; 17 | } 18 | 19 | let validatorInstance = null; 20 | 21 | if (this.isValidatorInstance(validator)) { 22 | validatorInstance = validator; 23 | } else { 24 | validatorInstance = this.injector.get(validator as any, null, InjectFlags.Optional); 25 | validatorInstance ??= new validator(); 26 | } 27 | 28 | return (c: AbstractControl) => validatorInstance.validate(c); 29 | 30 | } 31 | 32 | private isValidatorFn(value: any): boolean { 33 | return isFunction(value) && !value.prototype.validate; 34 | } 35 | 36 | private isValidatorInstance(value: any): boolean { 37 | return 'validate' in value; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/tests/custom-control-decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { AbstractControl, AbstractControlOptions, FormControl } from '@angular/forms'; 2 | import { CustomControl, CustomControlMapper } from '..'; 3 | import { ModelBinder } from '../bind'; 4 | import { CustomControlMetadata } from '../metadata'; 5 | 6 | describe('CustomControl decorator', () => { 7 | 8 | it('should decorate with type', () => { 9 | 10 | class CustomControlMapperImpl implements CustomControlMapper { 11 | public writeForm(value: any, abstractControlOptions: AbstractControlOptions): AbstractControl { 12 | return new FormControl(value, abstractControlOptions); 13 | } 14 | 15 | public readForm(control: AbstractControl): any { 16 | return control.value; 17 | } 18 | } 19 | 20 | class Test { 21 | @CustomControl(CustomControlMapperImpl) 22 | public field: string[]; 23 | } 24 | 25 | expect(ModelBinder.instance.getMetadata(Test).controls.field instanceof CustomControlMetadata).toBeTruthy(); 26 | }); 27 | 28 | it('should decorate with opts', () => { 29 | 30 | class CustomControlMapperImpl implements CustomControlMapper { 31 | public writeForm(value: any, abstractControlOptions: AbstractControlOptions): AbstractControl { 32 | return new FormControl(value, abstractControlOptions); 33 | } 34 | 35 | public readForm(control: AbstractControl): any { 36 | return control.value; 37 | } 38 | } 39 | 40 | class Test { 41 | @CustomControl({ 42 | mapper: CustomControlMapperImpl 43 | }) 44 | public field: string[]; 45 | } 46 | 47 | expect(ModelBinder.instance.getMetadata(Test).controls.field instanceof CustomControlMetadata).toBeTruthy(); 48 | }); 49 | 50 | it('should throw error when configuration is invalid', ()=> { 51 | 52 | expect(() => { 53 | 54 | class ChildTestClass { 55 | public field: string; 56 | } 57 | 58 | class TestClass { 59 | @CustomControl(null) 60 | public field: ChildTestClass; 61 | } 62 | 63 | }).toThrow(); 64 | }); 65 | 66 | }); 67 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/tests/custom-mapper-resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Injector } from '@angular/core'; 2 | import { inject, TestBed } from '@angular/core/testing'; 3 | import { AbstractControl, AbstractControlOptions, FormGroup } from '@angular/forms'; 4 | import { RxFormMapperModule } from '..'; 5 | import { CustomControlMapper } from '../interfaces'; 6 | import { RxFormMapper } from '../services'; 7 | import { CustomMapperResolver } from '../services/custom-mapper-resolver'; 8 | 9 | class UninstantiableCustomControlMapper implements CustomControlMapper { 10 | 11 | public constructor() { 12 | throw new Error('invalid operation'); 13 | } 14 | 15 | public writeForm(value: any, abstractControlOptions: AbstractControlOptions): AbstractControl { 16 | return null; 17 | } 18 | 19 | public readForm(control: AbstractControl) { 20 | return null; 21 | } 22 | 23 | } 24 | 25 | class InstantiableCustomControlMapper extends UninstantiableCustomControlMapper { 26 | public constructor() { 27 | try{ 28 | super(); 29 | } catch (error) { /* do nothing */ } 30 | } 31 | } 32 | 33 | describe('CustomMapperResolver', () => { 34 | beforeEach(() => { 35 | TestBed.configureTestingModule({ 36 | imports: [RxFormMapperModule.forRoot()], 37 | }).compileComponents(); 38 | }); 39 | 40 | it('should be created', inject([CustomMapperResolver], (resolver: CustomMapperResolver) => { 41 | expect(resolver).toBeTruthy(); 42 | })); 43 | 44 | it('should resolve injected mapper', inject([Injector], (injector: Injector) => { 45 | spyOn(injector, 'get').and.returnValue(new InstantiableCustomControlMapper()); 46 | 47 | 48 | const mapper = new CustomMapperResolver(injector).resolve(UninstantiableCustomControlMapper); 49 | 50 | expect(mapper).toBeTruthy(); 51 | // tslint:disable-next-line: deprecation 52 | expect(injector.get).toHaveBeenCalled(); 53 | })); 54 | 55 | it('should instantiate mapper', inject([Injector], (injector: Injector) => { 56 | spyOn(injector, 'get').and.returnValue(null); 57 | 58 | const mapper = new CustomMapperResolver(injector).resolve(InstantiableCustomControlMapper); 59 | 60 | expect(mapper).toBeTruthy(); 61 | })); 62 | }); 63 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/tests/form-array-decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { ModelBinder } from '../bind'; 2 | import { FormArray } from '../decorators'; 3 | import { FormArrayMetadata } from '../metadata'; 4 | 5 | describe('FormArray decorator', () => { 6 | 7 | it('should decorate with type', () => { 8 | class Test { 9 | @FormArray(String) 10 | public field: string[]; 11 | } 12 | 13 | expect(ModelBinder.instance.getMetadata(Test).controls.field instanceof FormArrayMetadata).toBeTruthy(); 14 | const formArrayMetadata = ModelBinder.instance.getMetadata(Test).controls.field as FormArrayMetadata; 15 | expect(formArrayMetadata.itemForm.type === String).toBeTruthy(); 16 | }); 17 | 18 | it('should decorate with opts', () => { 19 | class Test { 20 | @FormArray({type: String}) 21 | public field: string[]; 22 | } 23 | 24 | const formArrayMetadata = ModelBinder.instance.getMetadata(Test).controls.field as FormArrayMetadata; 25 | expect(formArrayMetadata.itemForm.type === String).toBeTruthy(); 26 | }); 27 | 28 | it('should throw error when configuration is invalid', ()=> { 29 | 30 | expect(() => { 31 | 32 | class TestClass { 33 | @FormArray(null) 34 | public field: []; 35 | } 36 | 37 | }).toThrow(); 38 | }); 39 | 40 | }); 41 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/tests/form-control-decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { ModelBinder } from '../bind'; 2 | import { FormControl } from '../decorators'; 3 | import { FormControlMetadata } from '../metadata'; 4 | 5 | describe('FormControl decorator', () => { 6 | 7 | it('should decorate', () => { 8 | class Test { 9 | @FormControl() 10 | public field: string[]; 11 | } 12 | 13 | expect(ModelBinder.instance.getMetadata(Test).controls.field instanceof FormControlMetadata).toBeTruthy(); 14 | }); 15 | 16 | }); 17 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/tests/form-decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { Validators } from '@angular/forms'; 2 | import { ModelBinder } from '../bind'; 3 | import { Form } from '../decorators'; 4 | 5 | describe('Form decorator', () => { 6 | 7 | it('should decorate', () => { 8 | 9 | @Form({ validators: Validators.required }) 10 | class Test { 11 | 12 | public field: string[]; 13 | 14 | } 15 | 16 | expect(ModelBinder.instance.getMetadata(Test).validators).toHaveSize(1); 17 | }); 18 | 19 | }); 20 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/tests/form-group-decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { ModelBinder } from '../bind'; 2 | import { FormGroup } from '../decorators'; 3 | import { FormGroupMetadata } from '../metadata'; 4 | 5 | describe('FormGroup decorator', () => { 6 | 7 | it('should decorate', () => { 8 | class Test { 9 | @FormGroup() 10 | public field: string[]; 11 | } 12 | 13 | expect(ModelBinder.instance.getMetadata(Test).controls.field instanceof FormGroupMetadata).toBeTruthy(); 14 | }); 15 | 16 | it('should decorate with type', () => { 17 | class Test { 18 | @FormGroup(String) 19 | public field: string[]; 20 | } 21 | 22 | expect(ModelBinder.instance.getMetadata(Test).controls.field instanceof FormGroupMetadata).toBeTruthy(); 23 | }); 24 | 25 | }); 26 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/tests/rx-form-mapper-module.spec.ts: -------------------------------------------------------------------------------- 1 | import { Component, NgModule, NgModuleFactoryLoader } from '@angular/core'; 2 | import { TestBed } from '@angular/core/testing'; 3 | import { Router, RouterModule } from '@angular/router'; 4 | import { RxFormMapperModule } from '..'; 5 | import { RouterTestingModule } from '@angular/router/testing'; 6 | 7 | describe('RxFormMapperModule', () => { 8 | 9 | @Component({ template: '' }) 10 | class TestComponent { } 11 | 12 | @NgModule({ 13 | imports: [RxFormMapperModule.forRoot(), RouterModule.forChild([{ path: '', component: TestComponent }])] 14 | }) 15 | class ChildModule { } 16 | 17 | beforeEach(() => { 18 | TestBed.configureTestingModule({ 19 | imports: [RxFormMapperModule.forRoot(), RouterTestingModule.withRoutes([{ path: '', loadChildren: './test/ChildModule#ChildModule' }])], 20 | }).compileComponents(); 21 | 22 | 23 | }); 24 | 25 | it('Should not provide twice', async () => { 26 | // tslint:disable-next-line: deprecation 27 | const loader: any = TestBed.inject(NgModuleFactoryLoader); 28 | const router = TestBed.inject(Router); 29 | 30 | loader.stubbedModules = { 31 | './test/ChildModule#ChildModule': ChildModule, 32 | }; 33 | 34 | let error: Error = null; 35 | 36 | try { 37 | await router.navigate([]); 38 | } catch (e) { 39 | error = e; 40 | } 41 | 42 | expect(error.message).toEqual('RxFormMapperModule is already loaded. Import it in the AppModule only'); 43 | }); 44 | 45 | }); 46 | 47 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/tests/rx-form-mapper.spec.ts: -------------------------------------------------------------------------------- 1 | import { inject, TestBed } from '@angular/core/testing'; 2 | import { CustomControl, CustomControlMapper, Form, FormArray, FormControl, FormGroup, RxFormMapperModule } from '..'; 3 | import { RxFormMapper } from '../services'; 4 | import { 5 | AbstractControl, 6 | AbstractControlOptions, 7 | FormArray as NgFormArray, 8 | FormControl as NgFormControl, 9 | FormGroup as NgFormGroup, 10 | Validators, 11 | } from '@angular/forms'; 12 | import { of } from 'rxjs'; 13 | 14 | describe('RxFormMapper', () => { 15 | beforeEach(() => { 16 | TestBed.configureTestingModule({ 17 | imports: [RxFormMapperModule.forRoot()] 18 | }).compileComponents(); 19 | }); 20 | 21 | it('should be created', inject([RxFormMapper], (formMapper: RxFormMapper) => { 22 | expect(formMapper).toBeTruthy(); 23 | })); 24 | 25 | it('writeForm should not detect type', inject([RxFormMapper], (formMapper: RxFormMapper) => { 26 | expect(() => formMapper.writeForm(null)).toThrow(); 27 | })); 28 | 29 | it('writeForm should auto detect type', inject([RxFormMapper], (formMapper: RxFormMapper) => { 30 | class Test { 31 | 32 | } 33 | expect(formMapper.writeForm(new Test())).toBeTruthy(); 34 | })); 35 | 36 | it('writeForm should write with specific type', inject([RxFormMapper], (formMapper: RxFormMapper) => { 37 | class Test { 38 | 39 | } 40 | expect(formMapper.writeForm(new Test(), Test)).toBeTruthy(); 41 | })); 42 | 43 | it('fromType should create form', inject([RxFormMapper], (formMapper: RxFormMapper) => { 44 | class Test { 45 | @FormControl() 46 | public name: string; 47 | } 48 | 49 | expect(formMapper.fromType(Test).get('name')).toBeTruthy(); 50 | })); 51 | 52 | it('fromType should throw error', inject([RxFormMapper], (formMapper: RxFormMapper) => { 53 | class Test { 54 | @FormControl() 55 | public name: string; 56 | } 57 | 58 | expect(() => formMapper.fromType(null)).toThrow(); 59 | })); 60 | 61 | it('writeForm should not accept undefined type', inject([RxFormMapper], (mapper: RxFormMapper) => { 62 | expect(() => mapper.writeForm(null, null)).toThrow(); 63 | })); 64 | 65 | it('writeForm should return FormGroup', inject([RxFormMapper], (mapper: RxFormMapper) => { 66 | class TestClass {} 67 | expect(mapper.writeForm(null, TestClass) instanceof NgFormGroup).toBeTruthy(); 68 | })); 69 | 70 | it('should write FormControl field', inject([RxFormMapper], (mapper: RxFormMapper) => { 71 | class TestClass { 72 | @FormControl() 73 | public field: string; 74 | } 75 | const form = mapper.writeForm(new TestClass(), TestClass); 76 | expect(form.get('field') instanceof NgFormControl).toBeTruthy(); 77 | })); 78 | 79 | it('should write FormControl value', inject([RxFormMapper], (mapper: RxFormMapper) => { 80 | class TestClass { 81 | @FormControl() 82 | public field: string; 83 | } 84 | const testValue = new TestClass(); 85 | testValue.field = 'test'; 86 | expect(mapper.writeForm(testValue, TestClass).get('field').value).toEqual('test'); 87 | })); 88 | 89 | it('should write FormGroup field', inject([RxFormMapper], (mapper: RxFormMapper) => { 90 | 91 | class ChildTestClass { 92 | @FormControl() 93 | public field: string; 94 | } 95 | 96 | class TestClass { 97 | @FormGroup() 98 | public field: ChildTestClass; 99 | } 100 | 101 | expect(mapper.writeForm(new TestClass(), TestClass).get('field') instanceof NgFormGroup).toBeTruthy(); 102 | })); 103 | 104 | it('should write FormGroup value', inject([RxFormMapper], (mapper: RxFormMapper) => { 105 | 106 | class ChildTestClass { 107 | @FormControl() 108 | public field: string; 109 | } 110 | 111 | class TestClass { 112 | @FormGroup() 113 | public field: ChildTestClass; 114 | } 115 | 116 | const testValue = new TestClass(); 117 | testValue.field = new ChildTestClass(); 118 | testValue.field.field = 'test'; 119 | expect(mapper.writeForm(testValue, TestClass).get('field.field').value).toEqual('test'); 120 | })); 121 | 122 | it('should write FormArray field', inject([RxFormMapper], (mapper: RxFormMapper) => { 123 | 124 | class ChildTestClass { 125 | @FormControl() 126 | public field: string; 127 | } 128 | 129 | class TestClass { 130 | @FormArray(ChildTestClass) 131 | public fields: ChildTestClass[]; 132 | } 133 | 134 | expect(mapper.writeForm(new TestClass(), TestClass).get('fields') instanceof NgFormArray).toBeTruthy(); 135 | })); 136 | 137 | it('should write FormArray value', inject([RxFormMapper], (mapper: RxFormMapper) => { 138 | 139 | class ChildTestClass { 140 | @FormControl() 141 | public field: string; 142 | } 143 | 144 | class TestClass { 145 | @FormArray(ChildTestClass) 146 | public fields: ChildTestClass[]; 147 | } 148 | 149 | const testValue = new TestClass(); 150 | testValue.fields = [new ChildTestClass()]; 151 | testValue.fields[0].field = 'test'; 152 | const form = mapper.writeForm(testValue, TestClass); 153 | expect((form.get('fields') as NgFormArray).controls[0].get('field').value).toEqual('test'); 154 | })); 155 | 156 | it('should write CustomControl value', inject([RxFormMapper], (mapper: RxFormMapper) => { 157 | 158 | class ChildTestClass { 159 | public field: string; 160 | } 161 | 162 | class CustomControlMapperTest implements CustomControlMapper { 163 | public writeForm(value: ChildTestClass, abstractControlOptions: AbstractControlOptions): AbstractControl { 164 | return new NgFormControl(value, abstractControlOptions); 165 | } 166 | 167 | public readForm(control: AbstractControl): ChildTestClass { 168 | return control.value; 169 | } 170 | } 171 | 172 | class TestClass { 173 | @CustomControl(CustomControlMapperTest) 174 | public field: ChildTestClass; 175 | } 176 | 177 | const form = mapper.writeForm(new TestClass(), TestClass); 178 | expect(form.get('field') instanceof NgFormControl).toBeTruthy(); 179 | })); 180 | 181 | it('readForm should return null', inject([RxFormMapper], (formMapper: RxFormMapper) => { 182 | class Test { 183 | 184 | } 185 | 186 | expect(formMapper.readForm(null, Test)).toBeUndefined(); 187 | })); 188 | 189 | it('readForm should not detect type', inject([RxFormMapper], (formMapper: RxFormMapper) => { 190 | 191 | expect(() => formMapper.readForm(new NgFormGroup({}), null)).toThrow(); 192 | })); 193 | 194 | it('should return undefined when control not exists', inject([RxFormMapper], (mapper: RxFormMapper) => { 195 | class TestClass { 196 | @FormControl() 197 | public field: string; 198 | } 199 | 200 | const formGroup = new NgFormGroup({}); 201 | expect(mapper.readForm(formGroup, TestClass).field).toBeUndefined(); 202 | })); 203 | 204 | it('should throw error when field is not FormControl', inject([RxFormMapper], (mapper: RxFormMapper) => { 205 | class TestClass { 206 | @FormControl() 207 | public field: string; 208 | } 209 | 210 | const formGroup = new NgFormGroup({field: new NgFormGroup({})}); 211 | expect(() => mapper.readForm(formGroup, TestClass)).toThrow(); 212 | })); 213 | 214 | it('should read FormControl field', inject([RxFormMapper], (mapper: RxFormMapper) => { 215 | class TestClass { 216 | @FormControl() 217 | public field: string; 218 | } 219 | 220 | const formGroup = new NgFormGroup({field: new NgFormControl('test')}); 221 | expect(mapper.readForm(formGroup, TestClass).field).toEqual('test'); 222 | })); 223 | 224 | it('should throw error when field is not FormGroup', inject([RxFormMapper], (mapper: RxFormMapper) => { 225 | class TestClass { 226 | @FormGroup() 227 | public field: string; 228 | } 229 | 230 | const formGroup = new NgFormGroup({field: new NgFormControl({})}); 231 | expect(() => mapper.readForm(formGroup, TestClass)).toThrow(); 232 | })); 233 | 234 | it('should read FormGroup field', inject([RxFormMapper], (mapper: RxFormMapper) => { 235 | class TestClass { 236 | @FormControl() 237 | public name: string; 238 | @FormGroup() 239 | public field: TestClass; 240 | } 241 | 242 | const formGroup = new NgFormGroup({field: new NgFormGroup({name: new NgFormControl('test')})}); 243 | expect(mapper.readForm(formGroup, TestClass).field.name).toEqual('test'); 244 | })); 245 | 246 | it('should throw error when field is not FormArray', inject([RxFormMapper], (mapper: RxFormMapper) => { 247 | class TestClass { 248 | @FormArray(TestClass) 249 | public field: TestClass[]; 250 | } 251 | 252 | const formGroup = new NgFormGroup({field: new NgFormControl({})}); 253 | expect(() => mapper.readForm(formGroup, TestClass)).toThrow(); 254 | })); 255 | 256 | it('should read FormArray field', inject([RxFormMapper], (mapper: RxFormMapper) => { 257 | class TestClass { 258 | @FormControl() 259 | public name: string; 260 | @FormArray(TestClass) 261 | public field: TestClass[]; 262 | } 263 | 264 | const formGroup = new NgFormGroup({field: new NgFormArray([new NgFormGroup({name: new NgFormControl('test')})])}); 265 | expect(mapper.readForm(formGroup, TestClass).field[0].name).toEqual('test'); 266 | })); 267 | 268 | it('should read CustomControl value', inject([RxFormMapper], (mapper: RxFormMapper) => { 269 | 270 | class ChildTestClass { 271 | constructor(public field: string) {} 272 | } 273 | 274 | class CustomControlMapperTest implements CustomControlMapper { 275 | public writeForm(value: ChildTestClass, abstractControlOptions: AbstractControlOptions): AbstractControl { 276 | return new NgFormControl(value, abstractControlOptions); 277 | } 278 | 279 | public readForm(control: AbstractControl): ChildTestClass { 280 | return control.value; 281 | } 282 | } 283 | 284 | class TestClass { 285 | @CustomControl(CustomControlMapperTest) 286 | public field: ChildTestClass; 287 | } 288 | 289 | const form = new NgFormGroup({field: new NgFormControl(new ChildTestClass('hello'))}); 290 | 291 | expect(mapper.readForm(form, TestClass).field.field).toEqual('hello'); 292 | })); 293 | 294 | it('should set validator', inject([RxFormMapper], (mapper: RxFormMapper) => { 295 | 296 | @Form({validators: Validators.required, asyncValidators: c => of(null)}) 297 | class TestClass { 298 | @FormControl() 299 | public field: string; 300 | } 301 | 302 | expect(mapper.writeForm(new TestClass(), TestClass).validator).toBeTruthy(); 303 | })); 304 | 305 | }); 306 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/tests/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { coerceArray } from '../utils'; 2 | 3 | describe('utils', () => { 4 | 5 | it('coerceArray should return array when value is array', () => { 6 | expect(coerceArray([1])).toEqual([1]); 7 | }); 8 | 9 | it('coerceArray should return array when value is empty array', () => { 10 | expect(coerceArray([])).toEqual([]); 11 | }); 12 | 13 | it('coerceArray should return array when value is not array', () => { 14 | expect(coerceArray(1)).toEqual([1]); 15 | }); 16 | 17 | it('coerceArray should return array when value is undefined', () => { 18 | expect(coerceArray(undefined)).toEqual([]); 19 | }); 20 | 21 | }); 22 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/tests/validator-resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Injector } from '@angular/core'; 2 | import { inject, TestBed } from '@angular/core/testing'; 3 | import { AbstractControl, AbstractControlOptions, FormGroup, ValidationErrors, Validator } from '@angular/forms'; 4 | import { RxFormMapperModule } from '..'; 5 | import { CustomControlMapper } from '../interfaces'; 6 | import { RxFormMapper, ValidatorResolver } from '../services'; 7 | import { CustomMapperResolver } from '../services/custom-mapper-resolver'; 8 | 9 | describe('ValidatorResolver', () => { 10 | beforeEach(() => { 11 | TestBed.configureTestingModule({ 12 | imports: [RxFormMapperModule.forRoot()], 13 | }).compileComponents(); 14 | }); 15 | 16 | it('should be created', inject([ValidatorResolver], (resolver: ValidatorResolver) => { 17 | expect(resolver).toBeTruthy(); 18 | })); 19 | 20 | it('should resolve validatorFn', inject([ValidatorResolver], (resolver: ValidatorResolver) => { 21 | const validatorFn = (c) => ({}); 22 | 23 | const actual = resolver.resolve(validatorFn); 24 | 25 | expect(actual).toEqual(validatorFn); 26 | })); 27 | 28 | it('should resolve validator instance', inject([ValidatorResolver], (resolver: ValidatorResolver) => { 29 | 30 | class SimpleValidator implements Validator { 31 | validate(control: AbstractControl): ValidationErrors { 32 | return { error: true }; 33 | } 34 | } 35 | 36 | const actual = resolver.resolve(new SimpleValidator()); 37 | 38 | expect(actual(null).error).toEqual(true); 39 | })); 40 | 41 | it('should resolve validator type', inject([ValidatorResolver], (resolver: ValidatorResolver) => { 42 | 43 | class SimpleValidator implements Validator { 44 | validate(control: AbstractControl): ValidationErrors { 45 | return { error: true }; 46 | } 47 | } 48 | 49 | const actual = resolver.resolve(SimpleValidator); 50 | 51 | expect(actual(null).error).toEqual(true); 52 | })); 53 | 54 | it('should resolve injectable validator type', inject([ValidatorResolver, Injector], (resolver: ValidatorResolver, injector: Injector) => { 55 | 56 | class NotInjectableValidator implements Validator { 57 | validate(control: AbstractControl): ValidationErrors { 58 | return { error: true } 59 | } 60 | } 61 | 62 | class InjectableValidator extends NotInjectableValidator { 63 | validate(control: AbstractControl): ValidationErrors { 64 | return { error: false } 65 | } 66 | } 67 | 68 | spyOn(injector, 'get').and.returnValue(new InjectableValidator()); 69 | 70 | const actual = new ValidatorResolver(injector).resolve(NotInjectableValidator); 71 | 72 | expect(actual(null).error).toEqual(false); 73 | })); 74 | 75 | }); 76 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@angular/core'; 2 | import { Validator, ValidatorFn, AsyncValidator, AsyncValidatorFn } from '@angular/forms'; 3 | 4 | export type RxValidator = Validator | ValidatorFn | Type; 5 | export type RxAsyncValidator = AsyncValidator | AsyncValidatorFn | Type; 6 | export type UpdateOn = 'change' | 'blur' | 'submit' | null | undefined; 7 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@angular/core'; 2 | import { castArray, isFunction, isNil, negate } from 'lodash'; 3 | 4 | const isNotNil = negate(isNil); 5 | 6 | export function coerceArray(value: T | ReadonlyArray): Array { 7 | return castArray(value).filter(isNotNil); 8 | } 9 | 10 | export function isType(value: any): value is Type { 11 | return isFunction(value); 12 | } 13 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/public_api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of rx-form-mapper 3 | */ 4 | 5 | export * from './lib/index'; 6 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | import 'zone.js/dist/zone'; 3 | import 'zone.js/dist/zone-testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: { 11 | context(path: string, deep?: boolean, filter?: RegExp): { 12 | keys(): string[]; 13 | (id: string): T; 14 | }; 15 | }; 16 | 17 | // First, initialize the Angular testing environment. 18 | getTestBed().initTestEnvironment( 19 | BrowserDynamicTestingModule, 20 | platformBrowserDynamicTesting() 21 | ); 22 | // Then we find all the tests. 23 | const context = require.context('./', true, /\.spec\.ts$/); 24 | 25 | // And load the modules. 26 | context.keys().forEach(context); 27 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/lib", 5 | "declarationMap": true, 6 | "target": "es2015", 7 | "module": "es2015", 8 | "moduleResolution": "node", 9 | "declaration": true, 10 | "sourceMap": true, 11 | "inlineSources": true, 12 | "emitDecoratorMetadata": true, 13 | "experimentalDecorators": true, 14 | "importHelpers": true, 15 | "types": [], 16 | "lib": [ 17 | "dom", 18 | "es2018" 19 | ] 20 | }, 21 | "angularCompilerOptions": { 22 | "skipTemplateCodegen": true, 23 | "strictMetadataEmit": true, 24 | "fullTemplateTypeCheck": true, 25 | "strictInjectionParameters": true, 26 | "enableResourceInlining": true 27 | }, 28 | "exclude": [ 29 | "src/test.ts", 30 | "**/*.spec.ts" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "enableIvy": false 8 | } 9 | } -------------------------------------------------------------------------------- /projects/rx-form-mapper/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts" 12 | ], 13 | "include": [ 14 | "**/*.spec.ts", 15 | "**/*.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /projects/rx-form-mapper/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | true, 6 | "attribute", 7 | "lib", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | true, 12 | "element", 13 | "lib", 14 | "kebab-case" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /resources/logo_big.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KernelPanic92/rx-form-mapper/4661e56a30c33b71c65d57baea710431b20302a8/resources/logo_big.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "declaration": false, 8 | "module": "es2020", 9 | "moduleResolution": "node", 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "importHelpers": true, 13 | "target": "es5", 14 | "typeRoots": [ 15 | "node_modules/@types" 16 | ], 17 | "lib": [ 18 | "es2018", 19 | "dom" 20 | ], 21 | "paths": { 22 | "rx-form-mapper": [ 23 | "dist/rx-form-mapper" 24 | ], 25 | "rx-form-mapper/*": [ 26 | "dist/rx-form-mapper/*" 27 | ] 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "rules": { 7 | "unified-signatures": false, 8 | "indent": [true, "tabs"], 9 | "no-var-keyword": true, 10 | "quotemark": [ 11 | true, 12 | "single" 13 | ], 14 | "deprecation": { 15 | "severity": "warning" 16 | }, 17 | "array-type": false, 18 | "interface-name": false, 19 | "no-shadowed-variable": false, 20 | "arrow-parens": false, 21 | "trailing-comma": true, 22 | "ban-types": false, 23 | "max-line-length": false, 24 | "variable-name": false, 25 | "max-classes-per-file": false, 26 | "object-literal-sort-keys": false, 27 | "curly": false 28 | } 29 | } 30 | --------------------------------------------------------------------------------