├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ ├── auto-release.yml │ └── ngx-form-ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── angular.json ├── package-lock.json ├── package.json ├── projects ├── ngx-form-app │ ├── .eslintrc.json │ ├── browserslist │ ├── e2e │ │ ├── protractor.conf.js │ │ ├── src │ │ │ ├── app.e2e-spec.ts │ │ │ └── app.po.ts │ │ └── tsconfig.json │ ├── karma.conf.js │ ├── src │ │ ├── app │ │ │ ├── address-form │ │ │ │ ├── address-form.component.html │ │ │ │ └── address-form.component.ts │ │ │ ├── app-routing.module.ts │ │ │ ├── app │ │ │ │ ├── app.component.html │ │ │ │ └── app.component.ts │ │ │ ├── company-form │ │ │ │ ├── company-form.component.html │ │ │ │ └── company-form.component.ts │ │ │ ├── empty │ │ │ │ └── empty.component.ts │ │ │ ├── form │ │ │ │ ├── address.form.ts │ │ │ │ ├── company.form.ts │ │ │ │ ├── signup.form.ts │ │ │ │ └── user.form.ts │ │ │ ├── not-empty │ │ │ │ ├── app.component.scss │ │ │ │ ├── not-empty.component.html │ │ │ │ └── not-empty.component.ts │ │ │ └── validators │ │ │ │ └── matching-passwords.validator.ts │ │ ├── assets │ │ │ └── .gitkeep │ │ ├── environments │ │ │ ├── environment.prod.ts │ │ │ └── environment.ts │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── main.ts │ │ ├── styles.scss │ │ └── test.ts │ ├── tsconfig.app.json │ └── tsconfig.spec.json └── ngx-form │ ├── .eslintrc.json │ ├── README.md │ ├── karma.conf.js │ ├── ng-package.json │ ├── package.json │ ├── src │ ├── lib │ │ ├── common │ │ │ ├── common.spec.ts │ │ │ ├── common.ts │ │ │ ├── decorator.common.spec.ts │ │ │ ├── decorator.common.ts │ │ │ ├── functions │ │ │ │ ├── set.spec.ts │ │ │ │ └── set.ts │ │ │ └── typing.ts │ │ ├── core │ │ │ ├── async-validator.resolver.spec.ts │ │ │ ├── async-validator.resolver.ts │ │ │ ├── handler │ │ │ │ ├── disable-on.handler.ts │ │ │ │ └── on-value-changes.handler.ts │ │ │ ├── ngx-form.builder.spec.ts │ │ │ ├── ngx-form.builder.ts │ │ │ ├── validator.resolver.spec.ts │ │ │ └── validator.resolver.ts │ │ ├── decorator │ │ │ ├── async-validator.decorator.spec.ts │ │ │ ├── async-validator.decorator.ts │ │ │ ├── build-form.decorator.spec.ts │ │ │ ├── build-form.decorator.ts │ │ │ ├── disable-on.decorator.ts │ │ │ ├── form-array.decorator.spec.ts │ │ │ ├── form-array.decorator.ts │ │ │ ├── form-child.decorator.spec.ts │ │ │ ├── form-child.decorator.ts │ │ │ ├── form-control.decorator.spec.ts │ │ │ ├── form-control.decorator.ts │ │ │ ├── form-group.decorator.spec.ts │ │ │ ├── form-group.decorator.ts │ │ │ ├── on-value-changes.decorator.spec.ts │ │ │ ├── on-value-changes.decorator.ts │ │ │ ├── update-on.decorator.spec.ts │ │ │ ├── update-on.decorator.ts │ │ │ ├── validator.decorator.spec.ts │ │ │ └── validator.decorator.ts │ │ ├── factory │ │ │ ├── async-validator.factory.ts │ │ │ ├── disable-on.factory.ts │ │ │ └── validator.factory.ts │ │ ├── model │ │ │ ├── interface │ │ │ │ ├── ngx-form-collection.ts │ │ │ │ └── ngx-form.ts │ │ │ ├── ngx-form-array.model.spec.ts │ │ │ ├── ngx-form-array.model.ts │ │ │ ├── ngx-form-control.model.spec.ts │ │ │ ├── ngx-form-control.model.ts │ │ │ ├── ngx-form-group.model.spec.ts │ │ │ └── ngx-form-group.model.ts │ │ ├── ngx-form.module.spec.ts │ │ └── ngx-form.module.ts │ ├── public-api.ts │ └── test.ts │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ └── tsconfig.spec.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": [ 4 | "projects/**/*" 5 | ], 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "*.ts" 10 | ], 11 | "parserOptions": { 12 | "project": [ 13 | "tsconfig.json" 14 | ], 15 | "createDefaultProgram": true 16 | }, 17 | "extends": [ 18 | "plugin:@angular-eslint/recommended", 19 | "plugin:@angular-eslint/template/process-inline-templates" 20 | ], 21 | "rules": { 22 | "@angular-eslint/directive-selector": [ 23 | "error", 24 | { 25 | "type": "attribute", 26 | "prefix": "lib", 27 | "style": "camelCase" 28 | } 29 | ], 30 | "@angular-eslint/component-selector": [ 31 | "error", 32 | { 33 | "type": "element", 34 | "prefix": "lib", 35 | "style": "kebab-case" 36 | } 37 | ] 38 | } 39 | }, 40 | { 41 | "files": [ 42 | "*.html" 43 | ], 44 | "extends": [ 45 | "plugin:@angular-eslint/template/recommended" 46 | ], 47 | "rules": {} 48 | } 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/auto-release.yml: -------------------------------------------------------------------------------- 1 | name: 'auto-release' 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | type: 'string' 8 | description: 'Version to release' 9 | required: true 10 | default: 'x.y.z' 11 | 12 | jobs: 13 | release: 14 | name: Release 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout Repository 18 | uses: actions/checkout@v2 19 | 20 | - name: Set package.json version 21 | uses: HarmvZ/set-package-json-version-action@v0.2.5 22 | with: 23 | version: ${{ inputs.version }} 24 | path: projects/ngx-form 25 | 26 | - name: Add & Commit 27 | uses: EndBug/add-and-commit@v9.1.4 28 | with: 29 | author_name: '@paddls' 30 | message: 'release: v${{ inputs.version }}' 31 | github_token: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/ngx-form-ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ '**' ] 8 | 9 | jobs: 10 | build: 11 | name: Build NgxForm 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | node-version: [ 18.19.1 ] 16 | 17 | steps: 18 | - name: Checkout Repository 19 | uses: actions/checkout@v2 20 | 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - name: Installation 27 | run: npm ci 28 | 29 | - name: Build NgxForm 30 | run: npm run build:ngx-form 31 | env: 32 | CI: true 33 | 34 | - name: Archive build artifact 35 | uses: actions/upload-artifact@master 36 | with: 37 | name: ngx-form-build 38 | path: ./dist/ngx-form 39 | 40 | lint: 41 | name: Lint 42 | runs-on: ubuntu-latest 43 | strategy: 44 | matrix: 45 | node-version: [ 18.19.1 ] 46 | 47 | steps: 48 | - name: Checkout Repository 49 | uses: actions/checkout@v2 50 | 51 | - name: Use Node.js ${{ matrix.node-version }} 52 | uses: actions/setup-node@v1 53 | with: 54 | node-version: ${{ matrix.node-version }} 55 | 56 | - name: Installation 57 | run: npm ci 58 | 59 | - name: Run Linter 60 | run: npm run lint 61 | 62 | test: 63 | name: Test 64 | runs-on: ubuntu-latest 65 | strategy: 66 | matrix: 67 | node-version: [ 18.19.1 ] 68 | 69 | steps: 70 | - name: Checkout Repository 71 | uses: actions/checkout@v2 72 | 73 | - name: Use Node.js ${{ matrix.node-version }} 74 | uses: actions/setup-node@v1 75 | with: 76 | node-version: ${{ matrix.node-version }} 77 | 78 | - name: Installation 79 | run: npm ci 80 | 81 | - name: Run Tests 82 | run: npm run test:ci 83 | 84 | - name: Archive Coverage Artifact 85 | uses: actions/upload-artifact@master 86 | with: 87 | name: ngx-form-coverage 88 | path: ./coverage 89 | 90 | coverall: 91 | name: Coverall 92 | needs: test 93 | runs-on: ubuntu-latest 94 | if: github.ref == 'refs/heads/master' 95 | strategy: 96 | matrix: 97 | node-version: [ 18.19.1 ] 98 | 99 | steps: 100 | - name: Checkout Repository 101 | uses: actions/checkout@v2 102 | 103 | - name: Download Coverage Artifact 104 | uses: actions/download-artifact@master 105 | with: 106 | name: ngx-form-coverage 107 | path: ./coverage 108 | 109 | - name: Send coverage to Coverall 110 | uses: coverallsapp/github-action@v1.0.1 111 | with: 112 | github-token: ${{ secrets.GITHUB_TOKEN }} 113 | path-to-lcov: ./coverage/ngx-form/lcov.info 114 | 115 | deploy_paddls: 116 | name: Deploy NgxForm @paddls 117 | needs: [ build, lint, test ] 118 | if: github.ref == 'refs/heads/master' 119 | runs-on: ubuntu-latest 120 | strategy: 121 | matrix: 122 | node-version: [ 18.19.1 ] 123 | 124 | steps: 125 | - name: Checkout Repository 126 | uses: actions/checkout@v2 127 | 128 | - name: Download Build Artifact 129 | uses: actions/download-artifact@master 130 | with: 131 | name: ngx-form-build 132 | path: ./dist/ngx-form 133 | 134 | - name: "Publish @paddls/ngx-form" 135 | uses: pascalgn/npm-publish-action@1.3.9 136 | with: 137 | tag_name: "v%s" 138 | tag_message: "v%s" 139 | commit_pattern: "^release: v(\\S+)" 140 | workspace: "./dist/ngx-form" 141 | publish_args: "--access public" 142 | env: 143 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 144 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN_PADDLS }} 145 | 146 | deploy_witty: 147 | name: Deploy NgxForm @witty-services 148 | needs: [ build, lint, test ] 149 | if: github.ref == 'refs/heads/master' 150 | runs-on: ubuntu-latest 151 | strategy: 152 | matrix: 153 | node-version: [ 18.19.1 ] 154 | 155 | steps: 156 | - name: Checkout Repository 157 | uses: actions/checkout@v2 158 | 159 | - name: Download Build Artifact 160 | uses: actions/download-artifact@master 161 | with: 162 | name: ngx-form-build 163 | path: ./dist/ngx-form 164 | 165 | - name: "PrePublish @witty-services/ngx-form" 166 | uses: jossef/action-set-json-field@v1 167 | with: 168 | file: "./dist/ngx-form/package.json" 169 | field: name 170 | value: "@witty-services/ngx-form" 171 | 172 | - name: "Publish @witty-services/ngx-form" 173 | uses: pascalgn/npm-publish-action@1.3.9 174 | with: 175 | commit_pattern: "^release: v(\\S+)" 176 | workspace: "./dist/ngx-form" 177 | publish_args: "--access public" 178 | create_tag: "false" 179 | env: 180 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 181 | NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN_PADDLS }} 182 | -------------------------------------------------------------------------------- /.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 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | 48 | /.angular/cache 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2022 Paddls and contributors 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 | # NGX-FORM 2 | 3 | ![ngx-form-ci](https://github.com/paddls/ngx-form/workflows/build/badge.svg?branch=master) 4 | [![Coverage Status](https://coveralls.io/repos/github/paddls/ngx-form/badge.svg?branch=master)](https://coveralls.io/github/paddls/ngx-form?branch=master) 5 | [![npm version](https://badge.fury.io/js/%40paddls%2Fngx-form.svg)](https://badge.fury.io/js/%40paddls%2Fngx-form) 6 | ![GitHub](https://img.shields.io/github/license/paddls/ngx-form) 7 | ![GitHub repo size](https://img.shields.io/github/repo-size/paddls/ngx-form) 8 | ![GitHub last commit](https://img.shields.io/github/last-commit/paddls/ngx-form) 9 | ![GitHub issues](https://img.shields.io/github/issues/paddls/ngx-form) 10 | ![GitHub top language](https://img.shields.io/github/languages/top/paddls/ngx-form) 11 | 12 | Model based typed reactive forms made easy. 13 | 14 | ## Summary 15 | 16 | * [How to install](#how-to-install) 17 | * [Basic usage](#basic-usage) 18 | * [Create a form](#create-a-form) 19 | * [Build a form](#build-a-form) 20 | * [FormGroup](#formgroup) 21 | * [FormArray](#formarray) 22 | * [Validator, AsyncValidator and UpdateOn](#validator-asyncvalidator-and-updateon) 23 | * [ValidatorFactory](#validatorfactory) 24 | * [AsyncValidatorFactory](#asyncvalidatorfactory) 25 | * [FormChild](#formchild) 26 | * [Form lifecycle](#form-lifecycle) 27 | * [getValue()](#getvalue--) 28 | * [setValue()](#setvalue--) 29 | * [patchValue()](#patchvalue--) 30 | * [restore()](#restore--) 31 | * [empty()](#empty--) 32 | * [cancel()](#cancel--) 33 | * [markAllAsDirty()](#markallasdirty--) 34 | * [markAllAsPending()](#markallaspending--) 35 | * [markAllAsPristine()](#markallaspristine--) 36 | * [markAllAsUntouched()](#markallasuntouched--) 37 | * [add()](#add--) 38 | * [DisableOn](#disableon) 39 | * [OnValueChanges] 40 | 41 | ## How to install 42 | 43 | First install the library in your project : 44 | 45 | ```shell script 46 | npm install --save @paddls/ngx-form 47 | ``` 48 | 49 | ### Recommended Angular versions 50 | 51 | | `Angular` | `NgxForm` | 52 | |--------------------|-------------------| 53 | | `19.0.0` and above | `9.0.0` and above | 54 | | `18.0.0` and above | `8.0.0` and above | 55 | | `17.0.0` and above | `7.0.0` and above | 56 | | `16.0.0` and above | `6.0.0` and above | 57 | | `15.0.0` and above | `5.0.0` and above | 58 | | `14.0.0` and above | `4.0.1` and above | 59 | | `13.0.0` and above | `3.0.1` and above | 60 | | `12.0.0` and above | `2.0.0` and above | 61 | | `11.0.0` and above | `1.0.0` and above | 62 | 63 | After that, import `NgxFormModule` as follows : 64 | 65 | ```typescript 66 | import { ReactiveFormsModule } from '@angular/forms'; 67 | import { NgxFormModule } from '@paddls/ngx-form'; 68 | 69 | @NgModule({ 70 | imports: [ 71 | ReactiveFormsModule, 72 | NgxFormModule 73 | ] 74 | }) 75 | export class AppModule { 76 | } 77 | ``` 78 | 79 | ## Basic usage 80 | 81 | ### Create a form 82 | 83 | With ngx-form, the form creation is model driven. Therefore, to create a form, you need to create a model class which 84 | will represent the form. 85 | 86 | A form model should look like this : 87 | 88 | ```typescript 89 | import { FormControl } from '@paddls/ngx-form'; 90 | 91 | export class AddressForm { 92 | 93 | @FormControl() 94 | public city: string; 95 | 96 | @FormControl({defaultValue: 3}) 97 | public streetNumber: number; 98 | 99 | @FormControl({defaultValue: '10055', name: 'postalCode'}) 100 | public zipCode: string; 101 | 102 | @FormControl('street') 103 | public route: string; 104 | 105 | public constructor(data: Partial = {}) { 106 | Object.assign(this, data); 107 | } 108 | } 109 | ``` 110 | 111 | Each class attribute with the ``@FormControl()`` decorator will be marked as a form control. Inside 112 | the ``@FormControl()`` annotation, you can add some context at your will. Two properties are 113 | available : ``defaultValue`` and ``name``. 114 | 115 | By specifying a ``defaultValue``, the form control will be initialized with this value at the form creation. 116 | 117 | With the ``name`` property, you can differentiate the name given to the field in your model and the name of the control 118 | in the created form. 119 | 120 | You can define only one of the properties in the context, or both. If you just want to specify the 121 | ``name`` property, you can do it just by passing a string as the context like in the example above. 122 | 123 | ### Build a form 124 | 125 | Once you've created the model, you can build the form wherever you like through your entire Angular application using 126 | the ``@BuildForm()`` decorator. 127 | 128 | ```typescript 129 | import { Component } from '@angular/core'; 130 | import { BuildForm, NgxFormGroup } from '@paddls/ngx-form'; 131 | import { AddressForm } from './form/address.form'; 132 | 133 | @Component({ 134 | selector: 'app-root', 135 | templateUrl: './app.component.html' 136 | }) 137 | export class AppComponent { 138 | 139 | @BuildForm(() => AddressForm) 140 | public addressForm: NgxFormGroup; 141 | 142 | public onSubmit(): void { 143 | console.log(this.addressForm.getValue()); 144 | } 145 | } 146 | ``` 147 | 148 | That's it ! You can now use your newly created form juste like any other reactive form. Furthermore, the form is 149 | strongly typed. To retrieve the strongly typed result, just call ``form.getValue()`` method. 150 | 151 | ## FormGroup 152 | 153 | ```typescript 154 | import { FormControl, FormGroup } from '@paddls/ngx-form'; 155 | import { AddressForm } from './address.form'; 156 | 157 | export class UserForm { 158 | 159 | @FormControl({value: 'Brad'}) 160 | public firstName: string; 161 | 162 | @FormControl({value: 'Pitt'}) 163 | public lastName: string; 164 | 165 | @FormGroup(() => AddressForm) 166 | public personalAddress: AddressForm; 167 | 168 | @FormGroup({type: () => AddressForm, defaultValue: myAddressValue, name: 'companyAddress'}) 169 | public workAddress: AddressForm; 170 | } 171 | ``` 172 | 173 | To nest forms, use the ``@FormGroup()`` decorator. Do not forget to specify the type of your child form in the decorator 174 | context. 175 | 176 | You can also specify ``defaultValue`` and ``name`` properties if necessary. If you don't need to specify them, you can 177 | specify the type in the context directly like in the example above. 178 | 179 | ## FormArray 180 | 181 | ```typescript 182 | import { FormArray, FormControl } from '@paddls/ngx-form'; 183 | import { CompanyForm } from './company.form'; 184 | 185 | export class UserForm { 186 | 187 | @FormControl({defaultValue: 'Leonardo'}) 188 | public firstName: string; 189 | 190 | @FormControl() 191 | public lastName: string; 192 | 193 | @FormArray({defaultValue: 'Default skill', defaultValues: ['Java', 'C++'], updateOn: 'blur', name: 'userSkills'}) 194 | public skills: string[]; 195 | 196 | @FormArray(() => CompanyForm) 197 | public companies: CompanyForm[]; 198 | } 199 | ``` 200 | 201 | To add a ``FormArray`` to your form model, just add an attribute with the ``@FormArray()`` 202 | decorator. Again, you can specify ``defaultValue`` and ``name`` properties if necessary. Like with the ``@FormGroup()`` 203 | decorator, you can specify a type if you wish to create an array of nested forms. 204 | 205 | Additionally, you can add ``defaultValues`` or ``updateOn`` properties to the context. 206 | 207 | Like with the ``@FormControl()`` or ``@FormGroup()`` decorators, shorthands for ``name`` and ``type`` 208 | properties are available. Just specify the properties directly in the context if they are the only property used. 209 | 210 | ## Validator, AsyncValidator and UpdateOn 211 | 212 | ```typescript 213 | import { AsyncValidator, FormControl, FormGroup, UpdateOn, Validator } from '@paddls/ngx-form'; 214 | import { AddressForm } from './address.form'; 215 | import { Validators } from '@angular/forms'; 216 | 217 | @UpdateOn('change') 218 | export class UserForm { 219 | 220 | @FormControl({defaultValue: 'Thomas'}) 221 | @Validator(Validators.required) 222 | @UpdateOn('blur') 223 | public firstName: string; 224 | 225 | @AsyncValidator(myAsyncValidator) 226 | @FormControl('lastname') 227 | public lastName: string; 228 | 229 | @Validator([Validators.required, Validators.min(0)]) 230 | public age: number; 231 | 232 | @FormGroup({type: () => AddressForm, defaultValue: structuredClone(defaultAddress)}) 233 | @UpdateOn('submit') 234 | public personalAddress: AddressForm; 235 | } 236 | ``` 237 | 238 | To add Validators, AsyncValidators or specify the ``updateOn`` property on any form, ``FormControl``, 239 | ``FormGroup`` or ``FormArray``, just add a ``@Validator``, ``@AsyncValidator`` or ``@UpdateOn`` decorator on any form 240 | model attribute or on the form model class if you want to apply it to a form. 241 | 242 | The ``@Validator`` or ``@AsyncValidator`` decorators take any ``ValidatorFn``, ``AsyncValidatorFn`` or arrays of 243 | ``ValidatorFn`` and ``AsyncValidatorFn`` as parameters to apply them to the desired control or form. The 244 | ``@UpdateOn`` decorator takes ``'change'``, ``'blur'`` or ``'submit'`` value as parameter to apply it to the desired 245 | control or form. 246 | 247 | ### ValidatorFactory 248 | 249 | You can use a `ValidatorFactory` to build a `ValidatorFn` using values from any provider (`@Injectable()`) with the 250 | following syntax : 251 | 252 | ```typescript 253 | class Form { 254 | 255 | @Validator([ 256 | ValidatorFactory.of(((provider: MyProvider) => Validators.min(provider.length)), [MyProvider]) 257 | ]) 258 | @FormControl() 259 | public secondaryAddress: string; 260 | } 261 | ``` 262 | 263 | You can mix standard `ValidatorFn` and `ValidatorFactory` inside the `@Validator` decorator. 264 | 265 | ### AsyncValidatorFactory 266 | 267 | You can also use a `AsyncValidatorFactory`, it has the same behaviour as `ValidatorFactory`. 268 | 269 | ```typescript 270 | class Form { 271 | 272 | @AsyncValidator([ 273 | AsyncValidatorFactory.of( 274 | (service: MyService) => () => service.httpCall().pipe(map((result: string) => ({error: result}))), 275 | [MyService] 276 | ) 277 | ]) 278 | @FormControl() 279 | public secondaryAddress: string; 280 | } 281 | ``` 282 | 283 | ## FormChild 284 | 285 | ```typescript 286 | import { Component } from '@angular/core'; 287 | import { BuildForm, FormChild, NgxFormArray, NgxFormGroup } from '@paddls/ngx-form'; 288 | import { UserForm } from '../form/user.form'; 289 | import { CompanyForm } from '../form/company.form'; 290 | 291 | @Component({ 292 | selector: 'app-root', 293 | templateUrl: './app.component.html', 294 | styleUrls: ['./app.component.scss'] 295 | }) 296 | export class AppComponent { 297 | 298 | @BuildForm(() => UserForm) 299 | public userForm: NgxFormGroup; 300 | 301 | @FormChild({attribute: 'userForm', path: 'skills'}) 302 | public skillForms: NgxFormArray; 303 | 304 | @FormChild({attribute: 'userForm', path: 'companies'}) 305 | public companyForms: NgxFormArray; 306 | } 307 | ``` 308 | 309 | ``@FormChild`` attribute can be used to access a subform of a built parent form directly. To do that, add an attribute 310 | with the ``@FormChild`` decorator in the same class as your built parent form. Just specify the attribute name of the 311 | parent form with the ``attribute`` 312 | decorator parameter, the ``path`` of the form child you want to directly access, and you're done ! 313 | 314 | ## Form lifecycle 315 | 316 | A `NgxForm` object exposes the following methods : 317 | 318 | ### getValue() 319 | 320 | Returns the current strongly typed value of the form. 321 | 322 | ```typescript 323 | import { Component } from '@angular/core'; 324 | import { BuildForm, NgxFormGroup } from '@paddls/ngx-form'; 325 | import { AddressForm } from './form/address.form'; 326 | 327 | @Component({ 328 | selector: 'app-root', 329 | templateUrl: './app.component.html' 330 | }) 331 | export class AppComponent { 332 | 333 | @BuildForm(() => AddressForm) 334 | public addressForm: NgxFormGroup; 335 | 336 | public onSubmit(): void { 337 | console.log(this.addressForm.getValue()); 338 | } 339 | } 340 | ``` 341 | 342 | ### setValue() 343 | 344 | Sets a new value on the form. The behaviour of this method is similar to classic reactive forms. 345 | 346 | ```typescript 347 | import { Component } from '@angular/core'; 348 | import { BuildForm, NgxFormGroup } from '@paddls/ngx-form'; 349 | import { AddressForm } from './form/address.form'; 350 | 351 | @Component({ 352 | selector: 'app-root', 353 | templateUrl: './app.component.html' 354 | }) 355 | export class AppComponent { 356 | 357 | @BuildForm(() => AddressForm) 358 | public addressForm: NgxFormGroup; 359 | 360 | public constructor() { 361 | this.addressForm.setValue(new AddressForm({city: 'New York City'})) 362 | } 363 | } 364 | ``` 365 | 366 | ### patchValue() 367 | 368 | Patches a new value on the form. The behaviour of this method is similar to classic reactive forms. 369 | 370 | ```typescript 371 | import { Component } from '@angular/core'; 372 | import { BuildForm, NgxFormGroup } from '@paddls/ngx-form'; 373 | import { AddressForm } from './form/address.form'; 374 | 375 | @Component({ 376 | selector: 'app-root', 377 | templateUrl: './app.component.html' 378 | }) 379 | export class AppComponent { 380 | 381 | @BuildForm(() => AddressForm) 382 | public addressForm: NgxFormGroup; 383 | 384 | public constructor() { 385 | this.addressForm.patchValue(new AddressForm({city: 'New York City'})) 386 | } 387 | } 388 | ``` 389 | 390 | ### restore() 391 | 392 | Restores the form value to the initial value. Each control initial value is defined with the ``defaultValue`` attribute 393 | available in the ``@FormControl()``, ``@FormGroup()`` and ``@FormArray()`` decorators. 394 | 395 | ```typescript 396 | import { Component } from '@angular/core'; 397 | import { BuildForm, NgxFormGroup } from '@paddls/ngx-form'; 398 | import { AddressForm } from './form/address.form'; 399 | 400 | @Component({ 401 | selector: 'app-root', 402 | templateUrl: './app.component.html' 403 | }) 404 | export class AppComponent { 405 | 406 | @BuildForm(() => AddressForm) 407 | public addressForm: NgxFormGroup; 408 | 409 | public onRestore(): void { 410 | this.addressForm.restore(); 411 | } 412 | } 413 | ``` 414 | 415 | ### empty() 416 | 417 | Empties all values of the form. This calls ``reset()`` method on each form control and ``clear()`` method on all form 418 | arrays. 419 | 420 | ```typescript 421 | import { Component } from '@angular/core'; 422 | import { BuildForm, NgxFormGroup } from '@paddls/ngx-form'; 423 | import { AddressForm } from './form/address.form'; 424 | 425 | @Component({ 426 | selector: 'app-root', 427 | templateUrl: './app.component.html' 428 | }) 429 | export class AppComponent { 430 | 431 | @BuildForm(() => AddressForm) 432 | public addressForm: NgxFormGroup; 433 | 434 | public onRestore(): void { 435 | this.addressForm.empty(); 436 | } 437 | } 438 | ``` 439 | 440 | ### cancel() 441 | 442 | Cancels the last ``setValue()`` applied on the form. 443 | 444 | ```typescript 445 | import { Component } from '@angular/core'; 446 | import { BuildForm, NgxFormGroup } from '@paddls/ngx-form'; 447 | import { AddressForm } from './form/address.form'; 448 | 449 | @Component({ 450 | selector: 'app-root', 451 | templateUrl: './app.component.html' 452 | }) 453 | export class AppComponent { 454 | 455 | @BuildForm(() => AddressForm) 456 | public addressForm: NgxFormGroup; 457 | 458 | public onRestore(): void { 459 | this.addressForm.cancel(); 460 | } 461 | } 462 | ``` 463 | 464 | ### markAllAsDirty() 465 | 466 | Sets all controls to ``DIRTY`` state. 467 | 468 | ### markAllAsPending() 469 | 470 | Sets all controls to ``PENDING`` state. 471 | 472 | ### markAllAsPristine() 473 | 474 | Sets all controls to ``PRISTINE`` state. 475 | 476 | ### markAllAsUntouched() 477 | 478 | Sets all controls to ``UNTOUCHED`` state. 479 | 480 | ### add() 481 | 482 | Adds a new element to a form array. Prefer this method over ``push()`` method available on classic reactive forms as you 483 | don't need to explicitly pass a new form control to add : by default, NgxForm will add a new instance of the type of the 484 | form array. You can set a default value to the new element and set a specific index. 485 | 486 | ## DisableOn 487 | 488 | The `@DisableOn()` decorator allows you to control when to enable/disable a control. The decorated control will be 489 | disabled 490 | when the `Observable` passed as an argument is truthy, and enabled otherwise. 491 | 492 | If you want to use a context from providers, you can use a `DisableOnFactory` and inject your providers. 493 | 494 | Options can also be passed as a second argument of the decorator. 495 | 496 | ```typescript 497 | 498 | class UserForm { 499 | 500 | @DisableOn(of(true)) 501 | @FormControl() 502 | public surname: string; 503 | 504 | @DisableOn(DisableOnFactory.of((provider: MyProvider) => provider.disableWithTimeout(), [MyProvider])) 505 | @FormControl() 506 | public disabledWithTimeoutAddress: string; 507 | 508 | @DisableOn(DisableOnFactory.of((provider: MyProvider) => provider.disableWithTimeout(), [MyProvider]), {emitEvent: false}) 509 | @FormControl() 510 | public disabledWithTimeoutAddressWithoutEvents: string; 511 | } 512 | ``` 513 | 514 | > ⚠️ Any instance (built with `@BuildForm()`) of a form using this feature must 515 | > have `unsubscribeOn` parameter provided in the config as follows : 516 | 517 | ```typescript 518 | class ConsumerComponent { 519 | 520 | public readonly destroyRef: DestroyRef = inject(DestroyRef); 521 | 522 | @BuildForm(() => UserForm, {unsubscribeOn: destroyRef}) 523 | public form: NgxFormGroup; 524 | } 525 | ``` 526 | 527 | ## OnValueChanges 528 | 529 | The `@OnValueChanges()` decorator allows you to call a method when a form value is changed. Pass the 530 | control name(s) for which you wish to listen to changes. If you wish to listen to changes on the whole 531 | form, you can apply the decorator without any parameters. 532 | 533 | ```typescript 534 | class SumForm { 535 | 536 | @FormControl() 537 | public a: number; 538 | 539 | @FormControl() 540 | public b: number; 541 | 542 | @FormControl() 543 | public sum: number; 544 | 545 | @OnValueChanges(['a', 'b']) 546 | public computeSum(instance: NgxFormGroup): void { 547 | this.sum = this.a + this.b; 548 | instance.setValue(this, {emitEvent: false}); 549 | } 550 | } 551 | ``` 552 | 553 | > ⚠️ Any instance (built with `@BuildForm()`) of a form using this feature must 554 | > have `unsubscribeOn` parameter provided in the config as follows : 555 | 556 | ```typescript 557 | class ConsumerComponent { 558 | 559 | public readonly destroyRef: DestroyRef = inject(DestroyRef); 560 | 561 | @BuildForm(() => UserForm, {unsubscribeOn: destroyRef}) 562 | public form: NgxFormGroup; 563 | } 564 | ``` 565 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ngx-form": { 7 | "projectType": "library", 8 | "root": "projects/ngx-form", 9 | "sourceRoot": "projects/ngx-form/src", 10 | "prefix": "lib", 11 | "architect": { 12 | "build": { 13 | "builder": "@angular-devkit/build-angular:ng-packagr", 14 | "options": { 15 | "tsConfig": "projects/ngx-form/tsconfig.lib.json", 16 | "project": "projects/ngx-form/ng-package.json" 17 | }, 18 | "configurations": { 19 | "production": { 20 | "tsConfig": "projects/ngx-form/tsconfig.lib.prod.json" 21 | } 22 | } 23 | }, 24 | "test": { 25 | "builder": "@angular-devkit/build-angular:karma", 26 | "options": { 27 | "main": "projects/ngx-form/src/test.ts", 28 | "tsConfig": "projects/ngx-form/tsconfig.spec.json", 29 | "karmaConfig": "projects/ngx-form/karma.conf.js" 30 | } 31 | }, 32 | "lint": { 33 | "builder": "@angular-eslint/builder:lint", 34 | "options": { 35 | "lintFilePatterns": [ 36 | "projects/ngx-form/**/*.ts", 37 | "projects/ngx-form/**/*.html" 38 | ] 39 | } 40 | } 41 | } 42 | }, 43 | "ngx-form-app": { 44 | "projectType": "application", 45 | "schematics": { 46 | "@schematics/angular:component": { 47 | "style": "scss" 48 | } 49 | }, 50 | "root": "projects/ngx-form-app", 51 | "sourceRoot": "projects/ngx-form-app/src", 52 | "prefix": "app", 53 | "architect": { 54 | "build": { 55 | "builder": "@angular-devkit/build-angular:application", 56 | "options": { 57 | "outputPath": { 58 | "base": "dist/ngx-form-app" 59 | }, 60 | "index": "projects/ngx-form-app/src/index.html", 61 | "tsConfig": "projects/ngx-form-app/tsconfig.app.json", 62 | "aot": true, 63 | "assets": [ 64 | "projects/ngx-form-app/src/favicon.ico", 65 | "projects/ngx-form-app/src/assets" 66 | ], 67 | "styles": [ 68 | "projects/ngx-form-app/src/styles.scss" 69 | ], 70 | "scripts": [], 71 | "browser": "projects/ngx-form-app/src/main.ts", 72 | "polyfills": [ 73 | "zone.js" 74 | ] 75 | }, 76 | "configurations": { 77 | "production": { 78 | "fileReplacements": [ 79 | { 80 | "replace": "projects/ngx-form-app/src/environments/environment.ts", 81 | "with": "projects/ngx-form-app/src/environments/environment.prod.ts" 82 | } 83 | ], 84 | "optimization": true, 85 | "outputHashing": "all", 86 | "sourceMap": false, 87 | "namedChunks": false, 88 | "extractLicenses": true, 89 | "budgets": [ 90 | { 91 | "type": "initial", 92 | "maximumWarning": "2mb", 93 | "maximumError": "5mb" 94 | }, 95 | { 96 | "type": "anyComponentStyle", 97 | "maximumWarning": "6kb", 98 | "maximumError": "10kb" 99 | } 100 | ] 101 | } 102 | } 103 | }, 104 | "serve": { 105 | "builder": "@angular-devkit/build-angular:dev-server", 106 | "options": { 107 | "buildTarget": "ngx-form-app:build" 108 | }, 109 | "configurations": { 110 | "production": { 111 | "buildTarget": "ngx-form-app:build:production" 112 | } 113 | } 114 | }, 115 | "extract-i18n": { 116 | "builder": "@angular-devkit/build-angular:extract-i18n", 117 | "options": { 118 | "buildTarget": "ngx-form-app:build" 119 | } 120 | }, 121 | "test": { 122 | "builder": "@angular-devkit/build-angular:karma", 123 | "options": { 124 | "main": "projects/ngx-form-app/src/test.ts", 125 | "polyfills": "projects/ngx-form-app/src/polyfills.ts", 126 | "tsConfig": "projects/ngx-form-app/tsconfig.spec.json", 127 | "karmaConfig": "projects/ngx-form-app/karma.conf.js", 128 | "assets": [ 129 | "projects/ngx-form-app/src/favicon.ico", 130 | "projects/ngx-form-app/src/assets" 131 | ], 132 | "styles": [ 133 | "projects/ngx-form-app/src/styles.scss" 134 | ], 135 | "scripts": [] 136 | } 137 | }, 138 | "lint": { 139 | "builder": "@angular-eslint/builder:lint", 140 | "options": { 141 | "lintFilePatterns": [ 142 | "projects/ngx-form-app/**/*.ts", 143 | "projects/ngx-form-app/**/*.html" 144 | ] 145 | } 146 | }, 147 | "e2e": { 148 | "builder": "@angular-devkit/build-angular:protractor", 149 | "options": { 150 | "protractorConfig": "projects/ngx-form-app/e2e/protractor.conf.js", 151 | "devServerTarget": "ngx-form-app:serve" 152 | }, 153 | "configurations": { 154 | "production": { 155 | "devServerTarget": "ngx-form-app:serve:production" 156 | } 157 | } 158 | } 159 | } 160 | } 161 | }, 162 | "cli": { 163 | "analytics": false, 164 | "schematicCollections": [ 165 | "@angular-eslint/schematics" 166 | ] 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@paddls/ngx-form", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve --project ngx-form-app", 7 | "build:ngx-form": "ng build --configuration production --project ngx-form && cp README.md dist/ngx-form", 8 | "test:ngx-form": "ng test --project ngx-form --code-coverage", 9 | "doc:ngx-form": "typedoc ./projects/ngx-form", 10 | "test:ci:ngx-form": "npm run test:ngx-form -- --no-watch --no-progress --browsers=ChromeHeadlessCI", 11 | "test:ci": "npm run test:ci:ngx-form", 12 | "lint": "ng lint", 13 | "publish:ngx-form": "npm run build:ngx-form && cd dist/ngx-form && npm publish --access=public" 14 | }, 15 | "private": true, 16 | "dependencies": { 17 | "@angular/animations": "^19.0.4", 18 | "@angular/common": "^19.0.4", 19 | "@angular/compiler": "^19.0.4", 20 | "@angular/core": "^19.0.4", 21 | "@angular/forms": "^19.0.4", 22 | "@angular/platform-browser": "^19.0.4", 23 | "@angular/platform-browser-dynamic": "^19.0.4", 24 | "@angular/router": "^19.0.4", 25 | "rxjs": "~7.8.1", 26 | "tslib": "^2.8.1", 27 | "zone.js": "~0.15.0" 28 | }, 29 | "devDependencies": { 30 | "@angular-devkit/build-angular": "^19.0.5", 31 | "@angular-eslint/builder": "19.0.2", 32 | "@angular-eslint/eslint-plugin": "19.0.2", 33 | "@angular-eslint/eslint-plugin-template": "19.0.2", 34 | "@angular-eslint/schematics": "19.0.2", 35 | "@angular-eslint/template-parser": "19.0.2", 36 | "@angular/cli": "^19.0.5", 37 | "@angular/compiler-cli": "^19.0.4", 38 | "@types/jasmine": "~5.1.5", 39 | "@types/node": "^18.19.68", 40 | "@typescript-eslint/eslint-plugin": "^8.18.1", 41 | "@typescript-eslint/parser": "^8.18.1", 42 | "codelyzer": "^6.0.2", 43 | "eslint": "^8.57.1", 44 | "jasmine-core": "~5.1.2", 45 | "jasmine-spec-reporter": "~7.0.0", 46 | "karma": "^6.4.4", 47 | "karma-chrome-launcher": "~3.2.0", 48 | "karma-coverage": "~2.2.1", 49 | "karma-jasmine": "~5.1.0", 50 | "karma-jasmine-html-reporter": "^2.1.0", 51 | "ng-packagr": "^19.0.1", 52 | "protractor": "~7.0.0", 53 | "reflect-metadata": "^0.2.2", 54 | "ts-node": "~10.9.2", 55 | "typescript": "~5.6.3" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /projects/ngx-form-app/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc.json", 3 | "ignorePatterns": [ 4 | "!**/*", 5 | "tsconfig.app.json", 6 | "test.ts", 7 | "src/environments/*", 8 | "e2e/*" 9 | ], 10 | "overrides": [ 11 | { 12 | "files": [ 13 | "*.ts" 14 | ], 15 | "parserOptions": { 16 | "project": [ 17 | "projects/ngx-form-app/tsconfig.app.json" 18 | ], 19 | "createDefaultProgram": true 20 | }, 21 | "rules": { 22 | "@angular-eslint/directive-selector": [ 23 | "error", 24 | { 25 | "type": "attribute", 26 | "prefix": "app", 27 | "style": "camelCase" 28 | } 29 | ], 30 | "@angular-eslint/component-selector": [ 31 | "error", 32 | { 33 | "type": "element", 34 | "prefix": "app", 35 | "style": "kebab-case" 36 | } 37 | ] 38 | } 39 | }, 40 | { 41 | "files": [ 42 | "*.html" 43 | ], 44 | "rules": {} 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /projects/ngx-form-app/browserslist: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # You can see what browsers were selected by your queries by running: 6 | # npx browserslist 7 | 8 | > 0.5% 9 | last 2 versions 10 | Firefox ESR 11 | not dead 12 | not IE 9-11 # For IE 9-11 support, remove 'not'. -------------------------------------------------------------------------------- /projects/ngx-form-app/e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Protractor configuration file, see link for more information 3 | // https://github.com/angular/protractor/blob/master/lib/config.ts 4 | 5 | const {SpecReporter} = require('jasmine-spec-reporter'); 6 | 7 | /** 8 | * @type { import("protractor").Config } 9 | */ 10 | exports.config = { 11 | allScriptsTimeout: 11000, 12 | specs: [ 13 | './src/**/*.e2e-spec.ts' 14 | ], 15 | capabilities: { 16 | browserName: 'chrome' 17 | }, 18 | directConnect: true, 19 | baseUrl: 'http://localhost:4200/', 20 | framework: 'jasmine', 21 | jasmineNodeOpts: { 22 | showColors: true, 23 | defaultTimeoutInterval: 30000, 24 | print: function () { 25 | } 26 | }, 27 | onPrepare() { 28 | require('ts-node').register({ 29 | project: require('path').join(__dirname, './tsconfig.json') 30 | }); 31 | jasmine.getEnv().addReporter(new SpecReporter({spec: {displayStacktrace: true}})); 32 | } 33 | }; 34 | -------------------------------------------------------------------------------- /projects/ngx-form-app/e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | import { browser, logging } from 'protractor'; 3 | 4 | describe('workspace-project App', () => { 5 | let page: AppPage; 6 | 7 | beforeEach(() => { 8 | page = new AppPage(); 9 | }); 10 | 11 | it('should display welcome message', () => { 12 | page.navigateTo(); 13 | expect(page.getTitleText()).toBeDefined(); 14 | }); 15 | 16 | afterEach(async () => { 17 | // Assert that there are no errors emitted from the browser 18 | const logs: logging.Entry[] = await browser.manage().logs().get(logging.Type.BROWSER); 19 | expect(logs).not.toContain(jasmine.objectContaining({ 20 | level: logging.Level.SEVERE, 21 | } as logging.Entry)); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /projects/ngx-form-app/e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | 5 | public navigateTo(): Promise { 6 | return browser.get(browser.baseUrl) as Promise; 7 | } 8 | 9 | public getTitleText(): Promise { 10 | return element(by.css('app-root .content span')).getText() as Promise; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /projects/ngx-form-app/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../../out-tsc/e2e", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "types": [ 8 | "jasmine", 9 | "jasminewd2", 10 | "node" 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /projects/ngx-form-app/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 | reporters: ['progress', 'kjhtml'], 19 | port: 9876, 20 | colors: true, 21 | logLevel: config.LOG_INFO, 22 | autoWatch: true, 23 | browsers: ['Chrome'], 24 | customLaunchers: { 25 | ChromeHeadlessCI: { 26 | base: 'ChromeHeadless', 27 | flags: ['--no-sandbox'] 28 | } 29 | }, 30 | singleRun: false, 31 | restartOnFileChange: true 32 | }); 33 | }; 34 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/app/address-form/address-form.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 8 |
9 | 10 |
11 | 12 | 16 |
17 | 18 |
19 | 20 | 24 |
25 | 26 |
27 | 28 | 32 |
33 |
34 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/app/address-form/address-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject, OnInit } from '@angular/core'; 2 | import { ControlContainer, ReactiveFormsModule } from '@angular/forms'; 3 | import { AddressForm } from '../form/address.form'; 4 | import { NgxFormGroup } from '@paddls/ngx-form'; 5 | 6 | @Component({ 7 | selector: 'app-address-form', 8 | templateUrl: './address-form.component.html', 9 | imports: [ 10 | ReactiveFormsModule 11 | ] 12 | }) 13 | export class AddressFormComponent implements OnInit { 14 | 15 | private readonly controlContainer: ControlContainer = inject(ControlContainer); 16 | 17 | public addressForm: NgxFormGroup; 18 | 19 | public ngOnInit(): void { 20 | this.addressForm = this.controlContainer.control as NgxFormGroup; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | const routes: Routes = [ 5 | { 6 | path: '', 7 | loadComponent: () => import('./not-empty/not-empty.component').then(m => m.NotEmptyComponent) 8 | }, 9 | { 10 | path: 'empty', 11 | loadComponent: () => import('./empty/empty.component').then(m => m.EmptyComponent) 12 | } 13 | ]; 14 | 15 | @NgModule({ 16 | imports: [RouterModule.forRoot(routes)], 17 | exports: [RouterModule] 18 | }) 19 | export class AppRoutingModule { 20 | } 21 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/app/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/app/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { RouterOutlet } from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'app-root', 6 | templateUrl: './app.component.html', 7 | imports: [ 8 | RouterOutlet 9 | ] 10 | }) 11 | export class AppComponent { 12 | } 13 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/app/company-form/company-form.component.html: -------------------------------------------------------------------------------- 1 |
2 |
Company
3 | 4 |
5 | 6 | 10 |
11 | 12 |
13 | 14 | 18 |
19 | 20 | 21 |
22 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/app/company-form/company-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject, OnInit } from '@angular/core'; 2 | import { ControlContainer, ReactiveFormsModule } from '@angular/forms'; 3 | import { NgxFormGroup } from '@paddls/ngx-form'; 4 | import { CompanyForm } from '../form/company.form'; 5 | import { AddressFormComponent } from '../address-form/address-form.component'; 6 | 7 | @Component({ 8 | selector: 'app-company-form', 9 | templateUrl: './company-form.component.html', 10 | imports: [ 11 | ReactiveFormsModule, 12 | AddressFormComponent 13 | ] 14 | }) 15 | export class CompanyFormComponent implements OnInit { 16 | 17 | private readonly controlContainer: ControlContainer = inject(ControlContainer); 18 | 19 | public companyForm: NgxFormGroup; 20 | 21 | public ngOnInit(): void { 22 | this.companyForm = this.controlContainer.control as NgxFormGroup; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/app/empty/empty.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { RouterLink } from '@angular/router'; 3 | 4 | @Component({ 5 | selector: 'app-empty', 6 | template: 'NotEmpty', 7 | imports: [ 8 | RouterLink 9 | ] 10 | }) 11 | export class EmptyComponent { 12 | } 13 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/app/form/address.form.ts: -------------------------------------------------------------------------------- 1 | import { FormControl, UpdateOn } from '@paddls/ngx-form'; 2 | 3 | @UpdateOn('blur') 4 | export class AddressForm { 5 | 6 | @FormControl({defaultValue: 0}) 7 | public streetNumber: number; 8 | 9 | @FormControl({defaultValue: 'Default route'}) 10 | public route: string; 11 | 12 | @FormControl({defaultValue: 'Default zipCode'}) 13 | public zipCode: string; 14 | 15 | @FormControl({defaultValue: 'Default city'}) 16 | public city: string; 17 | 18 | public constructor(data: Partial = {}) { 19 | Object.assign(this, data); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/app/form/company.form.ts: -------------------------------------------------------------------------------- 1 | import { FormControl, FormGroup } from '@paddls/ngx-form'; 2 | import { AddressForm } from './address.form'; 3 | 4 | export class CompanyForm { 5 | 6 | @FormControl() 7 | public name: string; 8 | 9 | @FormControl() 10 | public siret: string; 11 | 12 | @FormGroup(() => AddressForm) 13 | public address: AddressForm; 14 | 15 | public constructor(data: Partial = {}) { 16 | Object.assign(this, data); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/app/form/signup.form.ts: -------------------------------------------------------------------------------- 1 | import { FormControl, Validator } from '@paddls/ngx-form'; 2 | import { Validators } from '@angular/forms'; 3 | import { matchingPasswords } from '../validators/matching-passwords.validator'; 4 | 5 | @Validator(matchingPasswords('password', 'confirmPassword')) 6 | export class SignupForm { 7 | 8 | @FormControl() 9 | @Validator([Validators.required, Validators.email]) 10 | public email: string; 11 | 12 | @FormControl() 13 | @Validator(Validators.required) 14 | public password: string; 15 | 16 | @FormControl() 17 | public confirmPassword: string; 18 | } 19 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/app/form/user.form.ts: -------------------------------------------------------------------------------- 1 | import { FormArray, FormControl, FormGroup, OnValueChanges, UpdateOn, Validator } from '@paddls/ngx-form'; 2 | import { CompanyForm } from './company.form'; 3 | import { AddressForm } from './address.form'; 4 | import { Validators } from '@angular/forms'; 5 | 6 | const defaultAddress: AddressForm = new AddressForm({ 7 | route: 'User route', 8 | city: 'User city', 9 | zipCode: 'User zipCode' 10 | }); 11 | 12 | @UpdateOn('change') 13 | export class UserForm { 14 | 15 | @FormControl({defaultValue: 'Thomas'}) 16 | @Validator(Validators.required) 17 | @UpdateOn('blur') 18 | public firstName: string; 19 | 20 | @FormControl('lastname') 21 | public lastName: string; 22 | 23 | @Validator([Validators.required, Validators.min(0)]) 24 | public age: number; 25 | 26 | @FormArray({defaultValue: 'Default skill', defaultValues: ['Java', 'C++']}) 27 | public skills: string[]; 28 | 29 | @FormArray(() => CompanyForm) 30 | public companies: CompanyForm[]; 31 | 32 | @FormGroup({type: () => AddressForm, defaultValue: new AddressForm({...defaultAddress})}) 33 | @UpdateOn('submit') 34 | public personalAddress: AddressForm; 35 | 36 | public constructor(data: Partial = {}) { 37 | Object.assign(this, data); 38 | } 39 | 40 | public getFullName(): string { 41 | return `${this.firstName} ${this.lastName}`; 42 | } 43 | 44 | @OnValueChanges('firstName') 45 | public test(firstName: string): void { 46 | console.log('Test', firstName); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/app/not-empty/app.component.scss: -------------------------------------------------------------------------------- 1 | app-company-form { 2 | display: block; 3 | padding-left: 30px; 4 | } 5 | 6 | input[type="submit"] { 7 | margin-top: 50px; 8 | } 9 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/app/not-empty/not-empty.component.html: -------------------------------------------------------------------------------- 1 | 4 | 7 | 10 | 13 | 16 | Empty route 17 | 18 |

User profile :

19 | 20 |
22 |

Personal informations :

23 |
24 | 25 | 29 |
30 | 31 |
32 | 33 | 37 |
38 | 39 |

Address :

40 | 41 | 42 |

Skills :

43 |
    44 | @for (control of skillForms.controls; track control; let i = $index) { 45 |
  • 46 | 50 |
  • 51 | } 52 |
53 | 54 | 57 | 58 |

Companies :

59 | @for (control of companyForms.controls; track control; let i = $index) { 60 | 61 | 62 | 63 | } 64 | 65 | 68 | 69 | 71 |
72 | 73 |
75 |
76 | 77 | 81 |
82 | 83 |
84 | 85 | 89 |
90 | 91 |
92 | 93 | 97 |
98 | 99 | 101 |
102 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/app/not-empty/not-empty.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, DestroyRef, inject } from '@angular/core'; 2 | import { ReactiveFormsModule } from '@angular/forms'; 3 | import { AddressFormComponent } from '../address-form/address-form.component'; 4 | import { CompanyFormComponent } from '../company-form/company-form.component'; 5 | import { UserForm } from '../form/user.form'; 6 | import { CompanyForm } from '../form/company.form'; 7 | import { SignupForm } from '../form/signup.form'; 8 | import { AddressForm } from '../form/address.form'; 9 | import { BuildForm, FormChild, NgxFormArray, NgxFormGroup } from '@paddls/ngx-form'; 10 | import { RouterLink } from '@angular/router'; 11 | 12 | @Component({ 13 | selector: 'app-not-empty', 14 | templateUrl: 'not-empty.component.html', 15 | imports: [ 16 | ReactiveFormsModule, 17 | AddressFormComponent, 18 | CompanyFormComponent, 19 | RouterLink 20 | ] 21 | }) 22 | export class NotEmptyComponent { 23 | 24 | private readonly _destroyRef: DestroyRef = inject(DestroyRef); 25 | 26 | @BuildForm(() => UserForm, {unsubscribeOn: '_destroyRef'}) 27 | public readonly userForm: NgxFormGroup; 28 | 29 | @FormChild({attribute: 'userForm', path: 'skills'}) 30 | public readonly skillForms: NgxFormArray; 31 | 32 | @FormChild({attribute: 'userForm', path: 'companies'}) 33 | public readonly companyForms: NgxFormArray; 34 | 35 | @BuildForm(() => SignupForm) 36 | public readonly signUpForm: NgxFormGroup; 37 | 38 | private valueToSet: UserForm = new UserForm({ 39 | firstName: 'Thomas', 40 | lastName: 'Nisole', 41 | skills: ['Angular', 'NestJS'], 42 | personalAddress: new AddressForm({ 43 | city: 'Cesson', 44 | route: 'rue des Myosotis', 45 | streetNumber: 1, 46 | zipCode: '35510' 47 | }), 48 | companies: [ 49 | new CompanyForm({ 50 | name: 'Witty SARL', 51 | address: new AddressForm({ 52 | city: 'Cesson', 53 | route: 'a rue des Charmilles', 54 | streetNumber: 7, 55 | zipCode: '35510' 56 | }), 57 | siret: '1010101010101010' 58 | }) 59 | 60 | ] 61 | }); 62 | 63 | public constructor() { 64 | console.log(this.userForm); 65 | console.log(this.signUpForm); 66 | } 67 | 68 | public onResetForm(): void { 69 | this.userForm.reset(); 70 | this.signUpForm.reset(); 71 | } 72 | 73 | public onRestore(): void { 74 | this.userForm.restore(); 75 | this.signUpForm.restore(); 76 | } 77 | 78 | public onEmpty(): void { 79 | this.userForm.empty(); 80 | this.signUpForm.empty(); 81 | } 82 | 83 | public onCancel(): void { 84 | this.userForm.cancel(); 85 | this.signUpForm.cancel(); 86 | } 87 | 88 | public onLoadUserProfile(): void { 89 | this.userForm.setValue(this.valueToSet); 90 | 91 | setTimeout(() => { 92 | this.userForm.patchValue(new UserForm({ 93 | companies: [...this.userForm.getValue().companies, new CompanyForm({ 94 | name: 'Romain SA', 95 | address: new AddressForm({ 96 | city: 'Cesson', 97 | route: 'a rue des Charmilles', 98 | streetNumber: 7, 99 | zipCode: '35510' 100 | }), 101 | siret: '1010101010101010' 102 | })] 103 | })); 104 | }, 2000); 105 | } 106 | 107 | public onAddSkill(): void { 108 | this.skillForms.add(); 109 | } 110 | 111 | public onAddCompany(): void { 112 | this.companyForms.add(); 113 | } 114 | 115 | public onSubmit(): void { 116 | console.log(this.userForm); 117 | console.log(this.userForm.getValue()); 118 | console.log(this.userForm.value); 119 | } 120 | 121 | public onSubmitSignUp(): void { 122 | console.log(this.signUpForm); 123 | console.log(this.signUpForm.getValue()); 124 | console.log(this.signUpForm.value); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/app/validators/matching-passwords.validator.ts: -------------------------------------------------------------------------------- 1 | import { AbstractControl, UntypedFormGroup, ValidatorFn } from '@angular/forms'; 2 | 3 | export function matchingPasswords(passwordKey: string, confirmedKey: string): ValidatorFn { 4 | return (group: UntypedFormGroup): {[key: string]: any} => { 5 | const password: AbstractControl = group?.controls[passwordKey]; 6 | const confirmed: AbstractControl = group?.controls[confirmedKey]; 7 | 8 | if (!password || !password.value || !confirmed || !confirmed.value) { 9 | return null; 10 | } 11 | 12 | if (password.value !== confirmed.value) { 13 | return { 14 | mismatchedPasswords: true 15 | }; 16 | } 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paddls/ngx-form/5b9a84a302bf1951f3477e35d52c62247c48f86b/projects/ngx-form-app/src/assets/.gitkeep -------------------------------------------------------------------------------- /projects/ngx-form-app/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment: any = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | export const environment: any = { 2 | production: false 3 | }; 4 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paddls/ngx-form/5b9a84a302bf1951f3477e35d52c62247c48f86b/projects/ngx-form-app/src/favicon.ico -------------------------------------------------------------------------------- /projects/ngx-form-app/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NgxFormApp 6 | 7 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode, importProvidersFrom } from '@angular/core'; 2 | import { environment } from './environments/environment'; 3 | import { provideNgxForm } from '@paddls/ngx-form'; 4 | import { AppRoutingModule } from './app/app-routing.module'; 5 | import { bootstrapApplication, BrowserModule } from '@angular/platform-browser'; 6 | import { ReactiveFormsModule } from '@angular/forms'; 7 | import { AppComponent } from './app/app/app.component'; 8 | 9 | if (environment.production) { 10 | enableProdMode(); 11 | } 12 | 13 | bootstrapApplication(AppComponent, { 14 | providers: [ 15 | importProvidersFrom(AppRoutingModule, BrowserModule, ReactiveFormsModule), 16 | provideNgxForm() 17 | ] 18 | }) 19 | .catch((err: any) => console.error(err)); 20 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | -------------------------------------------------------------------------------- /projects/ngx-form-app/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/zone-testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 6 | 7 | // First, initialize the Angular testing environment. 8 | getTestBed().initTestEnvironment( 9 | BrowserDynamicTestingModule, 10 | platformBrowserDynamicTesting() 11 | ); 12 | -------------------------------------------------------------------------------- /projects/ngx-form-app/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/app", 5 | "types": [], 6 | "paths": { 7 | "@paddls/ngx-form": [ 8 | "projects/ngx-form/src/public-api.ts" 9 | ] 10 | } 11 | }, 12 | "files": [ 13 | "src/main.ts" 14 | ], 15 | "include": [ 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /projects/ngx-form-app/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 | "src/**/*.spec.ts", 15 | "src/**/*.d.ts" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /projects/ngx-form/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../.eslintrc.json", 3 | "ignorePatterns": [ 4 | "!**/*", 5 | "tsconfig.app.json", 6 | "*.spec.ts", 7 | "test.ts" 8 | ], 9 | "overrides": [ 10 | { 11 | "files": [ 12 | "*.ts" 13 | ], 14 | "parserOptions": { 15 | "project": [ 16 | "projects/ngx-form/tsconfig.lib.json" 17 | ], 18 | "createDefaultProgram": true 19 | }, 20 | "rules": { 21 | "@angular-eslint/directive-selector": [ 22 | "error", 23 | { 24 | "type": "attribute", 25 | "prefix": "lib", 26 | "style": "camelCase" 27 | } 28 | ], 29 | "@angular-eslint/component-selector": [ 30 | "error", 31 | { 32 | "type": "element", 33 | "prefix": "lib", 34 | "style": "kebab-case" 35 | } 36 | ] 37 | } 38 | }, 39 | { 40 | "files": [ 41 | "*.html" 42 | ], 43 | "rules": {} 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /projects/ngx-form/README.md: -------------------------------------------------------------------------------- 1 | # NgxForm 2 | 3 | This library was generated with [Angular CLI](https://github.com/angular/angular-cli) version 9.1.12. 4 | 5 | ## Code scaffolding 6 | 7 | Run `ng generate component component-name --project ngx-form` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module --project ngx-form`. 8 | > Note: Don't forget to add `--project ngx-form` or else it will be added to the default project in your `angular.json` file. 9 | 10 | ## Build 11 | 12 | Run `ng build ngx-form` to build the project. The build artifacts will be stored in the `dist/` directory. 13 | 14 | ## Publishing 15 | 16 | After building your library with `ng build ngx-form`, go to the dist folder `cd dist/ngx-form` and run `npm publish`. 17 | 18 | ## Running unit tests 19 | 20 | Run `ng test ngx-form` to execute the unit tests via [Karma](https://karma-runner.github.io). 21 | 22 | ## Further help 23 | 24 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). 25 | -------------------------------------------------------------------------------- /projects/ngx-form/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 | coverageReporter: { 19 | dir: require('path').join(__dirname, '../../coverage/ngx-form'), 20 | reporters: [ 21 | {type: 'html', subdir: 'html'}, 22 | {type: 'lcovonly', subdir: '.', file: 'lcov.info'}, 23 | {type: 'json', subdir: '.', file: 'coverage.json'} 24 | ], 25 | fixWebpackSourcePaths: true 26 | }, 27 | reporters: ['progress', 'kjhtml', 'coverage'], 28 | port: 9876, 29 | colors: true, 30 | logLevel: config.LOG_INFO, 31 | autoWatch: true, 32 | browsers: ['Chrome'], 33 | customLaunchers: { 34 | ChromeHeadlessCI: { 35 | base: 'ChromeHeadless', 36 | flags: ['--no-sandbox'] 37 | } 38 | }, 39 | singleRun: false, 40 | restartOnFileChange: true 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /projects/ngx-form/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/ngx-form", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /projects/ngx-form/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@paddls/ngx-form", 3 | "version": "9.1.3", 4 | "peerDependencies": { 5 | "@angular/common": ">= 19.0.0", 6 | "@angular/core": ">= 19.0.0", 7 | "reflect-metadata": "^0.2.2" 8 | }, 9 | "dependencies": { 10 | "tslib": "^2.0.0" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/paddls/ngx-form.git" 15 | }, 16 | "author": "Thomas Nisole, Oscar Guérin", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/paddls/ngx-form/issues" 20 | }, 21 | "homepage": "https://github.com/paddls/ngx-form#readme" 22 | } 23 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/common/common.spec.ts: -------------------------------------------------------------------------------- 1 | import { FormControl } from '../decorator/form-control.decorator'; 2 | import { FormGroupContext } from '../decorator/form-group.decorator'; 3 | import { NgxFormBuilder } from '../core/ngx-form.builder'; 4 | import { NgxFormGroup } from '../model/ngx-form-group.model'; 5 | import { transformSmartValueToValue, transformValueToSmartValue } from './common'; 6 | import { FormArray } from '../decorator/form-array.decorator'; 7 | import { TestBed } from '@angular/core/testing'; 8 | import { provideNgxForm } from '../ngx-form.module'; 9 | import { NgxFormControl } from '../model/ngx-form-control.model'; 10 | import { NgxFormArray } from '../model/ngx-form-array.model'; 11 | 12 | class UserForm { 13 | 14 | @FormControl({defaultValue: 'Thomas'}) 15 | public firstName: string; 16 | 17 | @FormControl() 18 | public lastName: string; 19 | 20 | @FormControl() 21 | public file: File; 22 | 23 | @FormArray({defaultValue: 'Default skill'}) 24 | public skills: string[]; 25 | 26 | public getFullName(): string { 27 | return `${this.firstName} ${this.lastName}`; 28 | } 29 | } 30 | 31 | let builder: NgxFormBuilder; 32 | let form: NgxFormGroup; 33 | 34 | describe('Common', () => { 35 | 36 | const formGroupContextConfiguration: FormGroupContext = { 37 | type: () => UserForm 38 | }; 39 | 40 | beforeEach(() => { 41 | TestBed.configureTestingModule({ 42 | providers: [ 43 | provideNgxForm(), 44 | ] 45 | }) 46 | 47 | builder = TestBed.inject(NgxFormBuilder); 48 | form = builder.build(formGroupContextConfiguration); 49 | }); 50 | 51 | it('should transform value to smart value', () => { 52 | expect(transformValueToSmartValue(form, '')).toBeInstanceOf(UserForm); 53 | }) 54 | 55 | it('should transform smart value to value', () => { 56 | expect(transformSmartValueToValue(new UserForm())).toBeInstanceOf(Object); 57 | }) 58 | 59 | it('should not expose functions as controls', () => { 60 | expect(form.controls['getFullName']).toBeUndefined(); 61 | }) 62 | 63 | it('should expose all controls', () => { 64 | expect(form.controls.firstName).toBeDefined(); 65 | expect(form.controls.lastName).toBeDefined(); 66 | expect(form.controls.skills).toBeDefined(); 67 | }) 68 | 69 | it('should expose all controls as FormControl', () => { 70 | expect(form.controls.firstName).toBeInstanceOf(NgxFormControl); 71 | expect(form.controls.lastName).toBeInstanceOf(NgxFormControl); 72 | expect(form.controls.skills).toBeInstanceOf(NgxFormArray); 73 | }); 74 | 75 | it('should expose functions when retrieving value', () => { 76 | expect(form.getValue().getFullName).toBeDefined(); 77 | }); 78 | 79 | it('should correctly type primitive types forms', () => { 80 | form.controls.file.setValue(new File([], 'test.txt')); 81 | 82 | expect(form.controls.file.value).toBeInstanceOf(File); 83 | }) 84 | 85 | }) 86 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/common/common.ts: -------------------------------------------------------------------------------- 1 | import { findPropertyFormContexts, FormContextCommon } from './decorator.common'; 2 | import { FORM_CONTROL_SUFFIX_METADATA_KEY } from '../decorator/form-control.decorator'; 3 | import { FORM_GROUP_SUFFIX_METADATA_KEY, FormGroupContext } from '../decorator/form-group.decorator'; 4 | import { FORM_ARRAY_SUFFIX_METADATA_KEY, FormArrayContext } from '../decorator/form-array.decorator'; 5 | import { NgxFormArray } from '../model/ngx-form-array.model'; 6 | import { FORM_GROUP_INSTANCE_METADATA_KEY, NgxFormGroup } from '../model/ngx-form-group.model'; 7 | import { Observable } from 'rxjs'; 8 | import { NgxFormControl } from '../model/ngx-form-control.model'; 9 | import { set } from './functions/set'; 10 | import { ConstructorFunction } from './typing'; 11 | 12 | export interface Handler { 13 | 14 | handle(type: () => ConstructorFunction, instance: NgxFormGroup, unsubscribeOn: Observable): void; 15 | 16 | } 17 | 18 | export function transformValueToSmartValue(formGroup: NgxFormGroup, parent: string): V { 19 | const formGroupContext: FormGroupContext = Reflect.getMetadata(FORM_GROUP_INSTANCE_METADATA_KEY, formGroup); 20 | const value: V = new (formGroupContext.type())(); 21 | 22 | const controlContexts: {[key: string]: FormContextCommon} = findPropertyFormContexts(formGroupContext.type().prototype, FORM_CONTROL_SUFFIX_METADATA_KEY); 23 | Object.keys(controlContexts).forEach((key: string) => { 24 | const controlContext: FormContextCommon = controlContexts[key]; 25 | set(value, key, formGroup.get(controlContext.name)?.value); 26 | }); 27 | 28 | const arrayContexts: {[key: string]: FormContextCommon} = findPropertyFormContexts(formGroupContext.type().prototype, FORM_ARRAY_SUFFIX_METADATA_KEY); 29 | Object.keys(arrayContexts).forEach((key: string) => { 30 | const formArrayContext: FormArrayContext = arrayContexts[key] as FormArrayContext; 31 | 32 | if (!value[key]) { 33 | value[key] = []; 34 | } 35 | 36 | if (formArrayContext.type) { 37 | value[key] = (formGroup.get(formArrayContext.name) as unknown as NgxFormArray)?.controls.map((item: any, index: number) => (item as NgxFormGroup).getValue(parent + index)); 38 | } else { 39 | value[key] = (formGroup.get(formArrayContext.name) as unknown as NgxFormArray)?.controls.map((item: any) => (item as NgxFormControl).value); 40 | } 41 | }); 42 | 43 | const groupContexts: {[key: string]: FormContextCommon} = findPropertyFormContexts(formGroupContext.type().prototype, FORM_GROUP_SUFFIX_METADATA_KEY); 44 | Object.keys(groupContexts).forEach((key: string) => { 45 | const groupContext: FormGroupContext = groupContexts[key] as FormGroupContext; 46 | set(value, key, (formGroup.get(groupContext.name) as unknown as NgxFormArray)?.getValue(parent + key)) 47 | }); 48 | 49 | return value; 50 | } 51 | 52 | export function transformSmartValueToValue(value: V): any { 53 | const obj: any = {}; 54 | 55 | const controlContexts: {[key: string]: FormContextCommon} = {}; 56 | Object.assign(controlContexts, { 57 | ...findPropertyFormContexts(value.constructor.prototype, FORM_CONTROL_SUFFIX_METADATA_KEY), 58 | ...findPropertyFormContexts(value.constructor.prototype, FORM_GROUP_SUFFIX_METADATA_KEY) 59 | }); 60 | Object.keys(controlContexts).forEach((key: string) => { 61 | const controlContext: FormContextCommon = controlContexts[key]; 62 | if (value[key] !== undefined) { 63 | set(obj, controlContext.name, value[key]); 64 | } 65 | }); 66 | 67 | const arrayContexts: {[key: string]: FormContextCommon} = findPropertyFormContexts(value.constructor.prototype, FORM_ARRAY_SUFFIX_METADATA_KEY); 68 | Object.keys(arrayContexts).forEach((key: string) => { 69 | const formArrayContext: FormArrayContext = arrayContexts[key] as FormArrayContext; 70 | 71 | if (value[key] === undefined) { 72 | return; 73 | } 74 | 75 | if (!obj[formArrayContext.name]) { 76 | obj[formArrayContext.name] = []; 77 | } 78 | 79 | obj[formArrayContext.name] = value[key]; 80 | }); 81 | 82 | return obj; 83 | } 84 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/common/decorator.common.spec.ts: -------------------------------------------------------------------------------- 1 | import { addFormContextCommon, PROPERTY_CONFIGURATIONS_METADATA_KEY } from './decorator.common'; 2 | 3 | describe('DecoratorCommon', () => { 4 | 5 | describe('#addFormContextCommon', () => { 6 | 7 | it('should place metadata on target', () => { 8 | const firstConf: any = {}; 9 | const target: any = {}; 10 | addFormContextCommon(target, firstConf, 'firstConf', 'keyy'); 11 | expect(Reflect.getMetadata(`${PROPERTY_CONFIGURATIONS_METADATA_KEY}:keyy`, target)).toEqual({firstConf}); 12 | }); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/common/decorator.common.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { FORM_CONTROL_SUFFIX_METADATA_KEY } from '../decorator/form-control.decorator'; 3 | import { FORM_GROUP_SUFFIX_METADATA_KEY } from '../decorator/form-group.decorator'; 4 | import { FORM_ARRAY_SUFFIX_METADATA_KEY } from '../decorator/form-array.decorator'; 5 | 6 | export const PROPERTY_CONFIGURATIONS_METADATA_KEY: string = 'ngx-form:form-context-commons'; 7 | 8 | export type FormHooks = 'change' | 'blur' | 'submit'; 9 | 10 | export interface FormContextCommon { 11 | 12 | name?: string; 13 | 14 | defaultValue?: any; 15 | } 16 | 17 | export function addFormContextCommon(target: T, formControlContext: FormContextCommon, propertyKey: string, suffixKey: string): void { 18 | applyPropertyConfiguration(target, propertyKey, formControlContext, suffixKey); 19 | } 20 | 21 | export function findAllPropertyFormContexts(target: T): {[key: string]: FormContextCommon} { 22 | return [ 23 | FORM_CONTROL_SUFFIX_METADATA_KEY, 24 | FORM_GROUP_SUFFIX_METADATA_KEY, 25 | FORM_ARRAY_SUFFIX_METADATA_KEY 26 | ].reduce((acc: {[key: string]: FormContextCommon}, curr: string) => ({...acc, ...findPropertyFormContexts(target, curr)}), {}); 27 | } 28 | 29 | export function findPropertyFormContexts(target: T, suffixKey: string): {[key: string]: FormContextCommon} { 30 | return Reflect.getMetadata(`${PROPERTY_CONFIGURATIONS_METADATA_KEY}:${suffixKey}`, target) || {}; 31 | } 32 | 33 | function applyPropertyConfiguration(target: T, propertyKey: string, controlConfiguration: FormContextCommon, suffixKey: string): void { 34 | const configurations: {[key: string]: FormContextCommon} = findPropertyFormContexts(target, suffixKey); 35 | configurations[propertyKey] = controlConfiguration; 36 | 37 | Reflect.defineMetadata(`${PROPERTY_CONFIGURATIONS_METADATA_KEY}:${suffixKey}`, configurations, target); 38 | } 39 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/common/functions/set.spec.ts: -------------------------------------------------------------------------------- 1 | import { set } from './set'; 2 | 3 | describe('set', () => { 4 | it('should set a value at a given path with dot notation', () => { 5 | const obj = {a: {b: undefined}}; 6 | set(obj, 'a.b.c', 42); 7 | expect(obj).toEqual({a: {b: {c: 42}}}); 8 | }); 9 | 10 | it('should set a value at a given path with array notation', () => { 11 | const obj = {a: {b: undefined}}; 12 | set(obj, ['a', 'b', 'c'], 42); 13 | expect(obj).toEqual({a: {b: {c: 42}}}); 14 | }); 15 | 16 | it('should create nested objects if they do not exist', () => { 17 | const obj = {}; 18 | set(obj, 'x.y.z', 'test'); 19 | expect(obj).toEqual({x: {y: {z: 'test'}}}); 20 | }); 21 | 22 | it('should overwrite existing values at the given path', () => { 23 | const obj = {a: {b: {c: 10}}}; 24 | set(obj, 'a.b.c', 42); 25 | expect(obj).toEqual({a: {b: {c: 42}}}); 26 | }); 27 | 28 | it('should handle empty path array correctly', () => { 29 | const obj = {a: 1}; 30 | set(obj, [], 42); 31 | expect(obj).toEqual({a: 1}); 32 | }); 33 | 34 | it('should handle empty path string correctly', () => { 35 | const obj = {a: 1}; 36 | set(obj, '', 42); 37 | expect(obj).toEqual({a: 1}); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/common/functions/set.ts: -------------------------------------------------------------------------------- 1 | export const set = (obj: {[key: string]: any}, path: string | string[], value: any): void => { 2 | const pathArray = Array.isArray(path) ? path : path.match(/([^[.\]])+/g); 3 | 4 | if (pathArray === null) { 5 | return; 6 | } 7 | 8 | pathArray.reduce((acc: {[key: string]: any}, key, i) => { 9 | if (acc[key] === undefined) acc[key] = {}; 10 | if (i === pathArray.length - 1) acc[key] = value; 11 | 12 | return acc[key]; 13 | }, obj); 14 | }; 15 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/common/typing.ts: -------------------------------------------------------------------------------- 1 | import { NgxFormControl } from '../model/ngx-form-control.model'; 2 | import { NgxFormArray } from '../model/ngx-form-array.model'; 3 | import { NgxFormGroup } from '../model/ngx-form-group.model'; 4 | 5 | export type MarkFunctionProperties = { 6 | [Key in keyof T]: T[Key] extends Function ? never : Key; 7 | }; 8 | 9 | export type ExcludedFunctionPropertyNames = MarkFunctionProperties[keyof T]; 10 | 11 | export type ExcludeFunctions = Pick>; 12 | 13 | export type ConstructorFunction = new(...args: any[]) => T; 14 | 15 | export type Primitive = 16 | | null 17 | | undefined 18 | | string 19 | | number 20 | | boolean 21 | | symbol 22 | | bigint 23 | | Date 24 | | File; 25 | 26 | export type Arrayable = T[]; 27 | 28 | export type IterableElement = 29 | TargetIterable extends Iterable ? 30 | ElementType : 31 | TargetIterable extends AsyncIterable ? 32 | ElementType : 33 | never; 34 | 35 | export type DataToFormType = keyof ExcludeFunctions> = { 36 | [key in K]: V[key] extends Primitive ? NgxFormControl : V[key] extends Arrayable> ? NgxFormArray> : NgxFormGroup 37 | }; 38 | export type DataFormType = Partial & {[key: string]: any}; 39 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/core/async-validator.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { provideNgxForm } from '../ngx-form.module'; 3 | import { Injectable } from '@angular/core'; 4 | import { AsyncValidatorFn, FormControl, ValidationErrors } from '@angular/forms'; 5 | import { Observable, of } from 'rxjs'; 6 | import { AsyncValidatorResolver } from './async-validator.resolver'; 7 | import { delay, map } from 'rxjs/operators'; 8 | import { AsyncValidatorFactory, AsyncValidatorFactoryWithProviders } from '../factory/async-validator.factory'; 9 | 10 | const myAsyncValidator: AsyncValidatorFn = () => 11 | of(void 0).pipe( 12 | delay(10), 13 | map(() => ({asyncError: true})) 14 | ); 15 | 16 | @Injectable() 17 | class MyProvider { 18 | 19 | public readonly length$: Observable = of(5); 20 | 21 | } 22 | 23 | describe('AsyncValidatorResolver', () => { 24 | 25 | let resolver: AsyncValidatorResolver; 26 | 27 | beforeEach(() => { 28 | TestBed.configureTestingModule({ 29 | providers: [ 30 | provideNgxForm(), 31 | AsyncValidatorResolver, 32 | MyProvider 33 | ] 34 | }); 35 | 36 | resolver = TestBed.inject(AsyncValidatorResolver); 37 | }); 38 | 39 | it('should return undefined if no config', () => { 40 | expect(resolver.resolve(undefined)).toBeUndefined(); 41 | }); 42 | 43 | it('should return async validator fn as is', () => { 44 | expect(resolver.resolve(myAsyncValidator)).toEqual(myAsyncValidator); 45 | }); 46 | 47 | it('should return async validator fn array as is', () => { 48 | const validators: AsyncValidatorFn[] = [myAsyncValidator, myAsyncValidator]; 49 | 50 | expect(resolver.resolve(validators)).toEqual(validators); 51 | }); 52 | 53 | it('should build async validator fn', (done: DoneFn) => { 54 | const validator: AsyncValidatorFactoryWithProviders = 55 | AsyncValidatorFactory.of((() => myAsyncValidator), []); 56 | 57 | ((resolver.resolve(validator) as AsyncValidatorFn)(new FormControl(0)) as Observable).subscribe((errors: any) => { 58 | expect(errors).toEqual({asyncError: true}); 59 | done(); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/core/async-validator.resolver.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable, Injector, Type } from '@angular/core'; 2 | import { AsyncValidatorFn } from '@angular/forms'; 3 | import { AsyncValidatorConfig } from '../decorator/async-validator.decorator'; 4 | 5 | @Injectable() 6 | export class AsyncValidatorResolver { 7 | 8 | private readonly injector: Injector = inject(Injector); 9 | 10 | public resolve(config: AsyncValidatorConfig | AsyncValidatorConfig[]): AsyncValidatorFn | AsyncValidatorFn[] { 11 | if (!config) { 12 | return; 13 | } else if (Array.isArray(config)) { 14 | return config.map((c: AsyncValidatorConfig) => this.asyncValidatorConfigToAsyncValidatorFn(c)); 15 | } else { 16 | return this.asyncValidatorConfigToAsyncValidatorFn(config); 17 | } 18 | } 19 | 20 | private asyncValidatorConfigToAsyncValidatorFn(config: AsyncValidatorConfig): AsyncValidatorFn { 21 | if (typeof config === 'function') { 22 | return config; 23 | } else if (typeof config === 'object') { 24 | return config.factory(...config.providers.map((provider: Type) => this.injector.get(provider))); 25 | } else { 26 | throw new Error('Invalid @AsyncValidator argument'); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/core/handler/disable-on.handler.ts: -------------------------------------------------------------------------------- 1 | import { DestroyRef, inject, Injectable, Injector, Type } from '@angular/core'; 2 | import { Handler } from '../../common/common'; 3 | import { NgxFormGroup } from '../../model/ngx-form-group.model'; 4 | import { DISABLE_ON_METADATA_KEY, DisableOnContext } from '../../decorator/disable-on.decorator'; 5 | import { Observable } from 'rxjs'; 6 | import { takeUntil } from 'rxjs/operators'; 7 | import { findAllPropertyFormContexts, findPropertyFormContexts, FormContextCommon } from '../../common/decorator.common'; 8 | import { AbstractControl } from '@angular/forms'; 9 | import { FORM_GROUP_SUFFIX_METADATA_KEY, FormGroupContext } from '../../decorator/form-group.decorator'; 10 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 11 | import { ConstructorFunction } from '../../common/typing'; 12 | 13 | @Injectable() 14 | export class DisableOnHandler implements Handler { 15 | 16 | private readonly injector: Injector = inject(Injector); 17 | 18 | public handle(type: () => ConstructorFunction, instance: NgxFormGroup, unsubscribeOn: Observable | DestroyRef): void { 19 | if (!Reflect.hasMetadata(DISABLE_ON_METADATA_KEY, type().prototype) && !Reflect.hasMetadata(DISABLE_ON_METADATA_KEY, type())) { 20 | return; 21 | } 22 | 23 | if (!unsubscribeOn) { 24 | throw new Error(`Please provide 'unsubscribeOn' property with a valid Observable property name on @BuildForm decorator context to use @DisableOn`); 25 | } 26 | 27 | const children: {[key: string]: FormGroupContext} = findPropertyFormContexts(type().prototype, FORM_GROUP_SUFFIX_METADATA_KEY) as {[key: string]: FormGroupContext}; 28 | 29 | Object.keys(children).forEach((key: string) => this.handle(children[key].type, instance.get(children[key].name) as unknown as NgxFormGroup, unsubscribeOn)); 30 | 31 | const disableOnContexts: DisableOnContext[] = [ 32 | ...(Reflect.getMetadata(DISABLE_ON_METADATA_KEY, type().prototype) || []), 33 | ...(Reflect.getMetadata(DISABLE_ON_METADATA_KEY, type()) || []) 34 | ]; 35 | 36 | const formContexts: {[key: string]: FormContextCommon} = findAllPropertyFormContexts(type().prototype); 37 | 38 | disableOnContexts.forEach((context: DisableOnContext) => { 39 | let element: AbstractControl; 40 | let trigger$: Observable; 41 | 42 | if (!!context.propertyKey) { 43 | element = instance.get(formContexts[context.propertyKey].name); 44 | } else { 45 | element = instance; 46 | } 47 | 48 | if (context.config instanceof Observable) { 49 | trigger$ = context.config 50 | } else if (!!context.config.factory && !!context.config.providers) { 51 | trigger$ = context.config.factory(...context.config.providers.map((provider: Type) => this.injector.get(provider))); 52 | } else { 53 | throw new Error('Invalid @DisableOn argument'); 54 | } 55 | 56 | if (unsubscribeOn instanceof Observable) { 57 | trigger$ = trigger$.pipe( 58 | takeUntil(unsubscribeOn) 59 | ); 60 | } else if (unsubscribeOn instanceof DestroyRef) { 61 | trigger$ = trigger$.pipe( 62 | takeUntilDestroyed(unsubscribeOn) 63 | ); 64 | } else { 65 | throw new Error('Please provide a valid Observable or DestroyRef property name on @BuildForm decorator context to use @DisableOnHandler'); 66 | } 67 | 68 | trigger$.subscribe((disable: boolean) => { 69 | if (!!disable) { 70 | element.disable(context.options); 71 | } else { 72 | element.enable(context.options); 73 | } 74 | }); 75 | }); 76 | } 77 | 78 | } 79 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/core/handler/on-value-changes.handler.ts: -------------------------------------------------------------------------------- 1 | import { DestroyRef, Injectable } from '@angular/core'; 2 | import { Handler } from '../../common/common'; 3 | import { NgxFormGroup } from '../../model/ngx-form-group.model'; 4 | import { merge, Observable } from 'rxjs'; 5 | import { map, takeUntil } from 'rxjs/operators'; 6 | import { ON_VALUE_CHANGES_METADATA_KEY, OnValueChangesContext } from '../../decorator/on-value-changes.decorator'; 7 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 8 | import { ConstructorFunction } from '../../common/typing'; 9 | 10 | @Injectable() 11 | export class OnValueChangesHandler implements Handler { 12 | 13 | public handle(type: () => ConstructorFunction, instance: NgxFormGroup, unsubscribeOn: Observable | DestroyRef): void { 14 | if (!Reflect.hasMetadata(ON_VALUE_CHANGES_METADATA_KEY, type().prototype)) { 15 | return; 16 | } 17 | 18 | if (!unsubscribeOn) { 19 | throw new Error(`Please provide 'unsubscribeOn' property with a valid Observable property name on @BuildForm decorator context to use @OnValueChanges`); 20 | } 21 | 22 | const contexts: OnValueChangesContext[] = Reflect.getMetadata(ON_VALUE_CHANGES_METADATA_KEY, type().prototype); 23 | 24 | contexts.forEach((context: OnValueChangesContext) => { 25 | let trigger$: Observable = this.getValueChanges(instance, context.controls); 26 | if (unsubscribeOn instanceof Observable) { 27 | trigger$ = trigger$.pipe( 28 | takeUntil(unsubscribeOn) 29 | ); 30 | } else if (unsubscribeOn instanceof DestroyRef) { 31 | trigger$ = trigger$.pipe( 32 | takeUntilDestroyed(unsubscribeOn) 33 | ); 34 | } else { 35 | throw new Error('Please provide a valid Observable or DestroyRef property name on @BuildForm decorator context to use @OnValueChanges'); 36 | } 37 | trigger$.subscribe({ 38 | next: () => instance.getValue()[context.propertyKey].call(instance.getValue(), instance), 39 | complete: () => console.log('complete') 40 | }) 41 | }); 42 | } 43 | 44 | private getValueChanges(instance: NgxFormGroup, keys: string | string[]): Observable { 45 | if (!keys) { 46 | return instance.valueChanges.pipe( 47 | map(() => void 0) 48 | ); 49 | } else { 50 | if (!Array.isArray(keys)) { 51 | keys = [keys]; 52 | } 53 | 54 | return merge(...keys.map((key: string) => instance.get(key).valueChanges)).pipe( 55 | map(() => void 0) 56 | ); 57 | } 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/core/ngx-form.builder.spec.ts: -------------------------------------------------------------------------------- 1 | import { NgxFormBuilder } from './ngx-form.builder'; 2 | import { UpdateOn } from '../decorator/update-on.decorator'; 3 | import { FormControl } from '../decorator/form-control.decorator'; 4 | import { FormArray } from '../decorator/form-array.decorator'; 5 | import { FormGroup, FormGroupContext } from '../decorator/form-group.decorator'; 6 | import { NgxFormGroup } from '../model/ngx-form-group.model'; 7 | import { Validator } from '../decorator/validator.decorator'; 8 | import { Validators } from '@angular/forms'; 9 | import { TestBed } from '@angular/core/testing'; 10 | import { provideNgxForm } from '../ngx-form.module'; 11 | import { AsyncValidator } from '../decorator/async-validator.decorator'; 12 | import { AsyncValidatorFactory } from '../factory/async-validator.factory'; 13 | import { Observable, of } from 'rxjs'; 14 | import { Injectable } from '@angular/core'; 15 | import { map } from 'rxjs/operators'; 16 | 17 | @Injectable() 18 | class MyService { 19 | 20 | public httpCall(): Observable { 21 | return of('http result'); 22 | } 23 | } 24 | 25 | class AddressForm { 26 | 27 | @Validator([Validators.required, Validators.min(0)]) 28 | @FormControl({defaultValue: 0}) 29 | public streetNumber: number; 30 | 31 | @FormControl({defaultValue: 'Default route'}) 32 | public route: string; 33 | 34 | @FormControl({defaultValue: 'Default zipCode'}) 35 | public zipCode: string; 36 | 37 | @FormControl({defaultValue: 'Default city'}) 38 | public city: string; 39 | 40 | public constructor(data: Partial = {}) { 41 | Object.assign(this, data); 42 | } 43 | } 44 | 45 | class CompanyForm { 46 | 47 | @FormControl() 48 | public name: string; 49 | 50 | @FormControl() 51 | public siret: string; 52 | 53 | @FormGroup(() => AddressForm) 54 | public address: AddressForm; 55 | 56 | public constructor(data: Partial = {}) { 57 | Object.assign(this, data); 58 | } 59 | } 60 | 61 | const defaultAddress: AddressForm = new AddressForm({ 62 | route: 'User route', 63 | city: 'User city', 64 | zipCode: 'User zipCode' 65 | }) 66 | 67 | @UpdateOn('change') 68 | class UserForm { 69 | 70 | @FormControl({defaultValue: 'Thomas'}) 71 | public firstName: string; 72 | 73 | @Validator(Validators.required) 74 | @FormControl() 75 | public lastName: string; 76 | 77 | @FormArray({defaultValue: 'Default skill', defaultValues: ['Java', 'C++']}) 78 | public skills: string[]; 79 | 80 | @FormArray(() => CompanyForm) 81 | public companies: CompanyForm[]; 82 | 83 | @FormGroup({type: () => AddressForm, defaultValue: structuredClone(defaultAddress)}) 84 | @UpdateOn('submit') 85 | public personalAddress: AddressForm; 86 | 87 | @AsyncValidator([ 88 | AsyncValidatorFactory.of( 89 | (service: MyService) => () => service.httpCall().pipe(map((result: string) => ({error: result}))), 90 | [MyService] 91 | ) 92 | ]) 93 | @FormControl() 94 | public secondaryAddress: string; 95 | 96 | public constructor(data: Partial = {}) { 97 | Object.assign(this, data); 98 | } 99 | } 100 | 101 | let builder: NgxFormBuilder; 102 | 103 | const formGroupContextConfiguration: FormGroupContext = { 104 | type: () => UserForm 105 | }; 106 | 107 | describe('NgxFormBuilder', () => { 108 | 109 | beforeEach(() => { 110 | TestBed.configureTestingModule({ 111 | providers: [ 112 | provideNgxForm(), 113 | MyService 114 | ], 115 | }); 116 | 117 | builder = TestBed.inject(NgxFormBuilder); 118 | }); 119 | 120 | it('should create ngx form control instance', () => { 121 | expect(builder.build(formGroupContextConfiguration)).toBeDefined() 122 | }); 123 | 124 | it('should call build control method for each form control annotation', () => { 125 | spyOn(builder, 'buildControl').and.callThrough(); 126 | builder.build(formGroupContextConfiguration); 127 | expect(builder.buildControl).toHaveBeenCalledTimes(7); 128 | }); 129 | 130 | it('should call build group method for each form group annotation', () => { 131 | spyOn(builder, 'buildGroup').and.callThrough(); 132 | builder.build(formGroupContextConfiguration); 133 | expect(builder.buildGroup).toHaveBeenCalledTimes(1); 134 | }); 135 | 136 | it('should call build array method for each form array annotation', () => { 137 | spyOn(builder, 'buildArray').and.callThrough(); 138 | builder.build(formGroupContextConfiguration); 139 | expect(builder.buildArray).toHaveBeenCalledTimes(2); 140 | }); 141 | 142 | it('should have option properties set according to annotations', () => { 143 | const form: NgxFormGroup = builder.build(formGroupContextConfiguration); 144 | expect(form.updateOn).toEqual('change'); 145 | expect(form.controls.lastName.validator).toEqual(Validators.required); 146 | expect((form.controls.personalAddress as NgxFormGroup).controls.streetNumber.validator).toBeDefined(); 147 | }); 148 | 149 | it('should resolve async validator with dependencies', () => { 150 | const form: NgxFormGroup = builder.build(formGroupContextConfiguration); 151 | 152 | expect(form.controls.secondaryAddress.errors).toEqual({error: 'http result'}); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/core/ngx-form.builder.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable } from '@angular/core'; 2 | import { AbstractControlOptions, AsyncValidatorFn, FormBuilder, FormControlOptions, FormControlState, FormGroup, ValidatorFn } from '@angular/forms'; 3 | import { FORM_GROUP_INSTANCE_METADATA_KEY, NgxFormGroup } from '../model/ngx-form-group.model'; 4 | import { FORM_CONTROL_INSTANCE_METADATA_KEY, NgxFormControl } from '../model/ngx-form-control.model'; 5 | import { FORM_ARRAY_INSTANCE_METADATA_KEY, NgxFormArray } from '../model/ngx-form-array.model'; 6 | import { findPropertyFormContexts, FormContextCommon, FormHooks } from '../common/decorator.common'; 7 | import { FORM_GROUP_SUFFIX_METADATA_KEY, FormGroupContext } from '../decorator/form-group.decorator'; 8 | import { FORM_CONTROL_SUFFIX_METADATA_KEY } from '../decorator/form-control.decorator'; 9 | import { UPDATE_ON_METADATA_KEY } from '../decorator/update-on.decorator'; 10 | import { FORM_ARRAY_SUFFIX_METADATA_KEY, FormArrayContext } from '../decorator/form-array.decorator'; 11 | import { VALIDATORS_METADATA_KEY } from '../decorator/validator.decorator'; 12 | import { ASYNC_VALIDATORS_METADATA_KEY } from '../decorator/async-validator.decorator'; 13 | import { AsyncValidatorResolver } from './async-validator.resolver'; 14 | import { ValidatorResolver } from './validator.resolver'; 15 | import { ConstructorFunction, DataToFormType } from '../common/typing'; 16 | 17 | @Injectable() 18 | export class NgxFormBuilder extends FormBuilder { 19 | 20 | private readonly validatorResolver: ValidatorResolver = inject(ValidatorResolver); 21 | 22 | private readonly asyncValidatorResolver: AsyncValidatorResolver = inject(AsyncValidatorResolver); 23 | 24 | public group(controlsConfig: DataToFormType, options?: AbstractControlOptions | null): any { 25 | const fg: FormGroup = super.group(controlsConfig, options); 26 | 27 | return new NgxFormGroup(fg.controls as DataToFormType, {asyncValidators: fg.asyncValidator, updateOn: fg.updateOn, validators: fg.validator}); 28 | } 29 | 30 | public control(formState: V | FormControlState, validatorOrOpts?: ValidatorFn | ValidatorFn[] | FormControlOptions & { 31 | nonNullable?: boolean; 32 | } | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): NgxFormControl { 33 | return new NgxFormControl(formState, validatorOrOpts, asyncValidator); 34 | } 35 | 36 | public array(controlsConfig: any, 37 | validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, 38 | asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): any { 39 | return new NgxFormArray(this, controlsConfig, validatorOrOpts, asyncValidator); 40 | } 41 | 42 | public build(rootGroupContext: FormGroupContext, options?: AbstractControlOptions): NgxFormGroup { 43 | const rootContextOptions: AbstractControlOptions = { 44 | validators: this.validatorResolver.resolve(Reflect.getMetadata(VALIDATORS_METADATA_KEY, rootGroupContext.type())), 45 | asyncValidators: this.asyncValidatorResolver.resolve(Reflect.getMetadata(ASYNC_VALIDATORS_METADATA_KEY, rootGroupContext.type())), 46 | updateOn: Reflect.getMetadata(UPDATE_ON_METADATA_KEY, rootGroupContext.type()) 47 | }; 48 | 49 | const form: NgxFormGroup = this.group( 50 | {}, 51 | options || rootContextOptions 52 | ); 53 | Reflect.defineMetadata(FORM_GROUP_INSTANCE_METADATA_KEY, rootGroupContext, form); 54 | 55 | const controlContexts: {[key: string]: FormContextCommon} = findPropertyFormContexts(rootGroupContext.type().prototype, FORM_CONTROL_SUFFIX_METADATA_KEY) || {}; 56 | Object.keys(controlContexts).forEach((key: string) => { 57 | const controlConfiguration: FormContextCommon = controlContexts[key]; 58 | form.addControl(controlConfiguration.name, this.buildControl(key, controlConfiguration, rootGroupContext.type().prototype, rootGroupContext.defaultValue)); 59 | }); 60 | 61 | const arrayContexts: {[key: string]: FormContextCommon} = findPropertyFormContexts(rootGroupContext.type().prototype, FORM_ARRAY_SUFFIX_METADATA_KEY) || {}; 62 | Object.keys(arrayContexts).forEach((key: string) => { 63 | const arrayContext: FormArrayContext = arrayContexts[key] as FormArrayContext; 64 | const ngxFormArray: NgxFormArray = this.buildArray(key, arrayContext, rootGroupContext.type().prototype); 65 | form.addControl(arrayContext.name, ngxFormArray); 66 | }); 67 | 68 | const groupContexts: {[key: string]: FormContextCommon} = findPropertyFormContexts(rootGroupContext.type().prototype, FORM_GROUP_SUFFIX_METADATA_KEY) || {}; 69 | Object.keys(groupContexts).forEach((key: string) => { 70 | const groupContext: FormGroupContext = groupContexts[key] as FormGroupContext; 71 | 72 | form.addControl( 73 | groupContext.name, 74 | this.buildGroup(key, groupContext, rootGroupContext.type().prototype) 75 | ); 76 | }); 77 | 78 | return form; 79 | } 80 | 81 | public buildControl(key: string, formContextCommon: FormContextCommon, groupType: ConstructorFunction, groupDefaultValue: V): NgxFormControl { 82 | let controlValue: any; 83 | 84 | if (groupDefaultValue?.[key] !== undefined) { 85 | controlValue = groupDefaultValue[key]; 86 | } else if (formContextCommon.defaultValue !== undefined) { 87 | controlValue = formContextCommon.defaultValue; 88 | } else { 89 | controlValue = null; 90 | } 91 | 92 | const ngxFormControl: NgxFormControl = this.control( 93 | controlValue, 94 | { 95 | validators: this.validatorResolver.resolve(Reflect.getMetadata(`${VALIDATORS_METADATA_KEY}:${key}`, groupType)), 96 | asyncValidators: this.asyncValidatorResolver.resolve(Reflect.getMetadata(`${ASYNC_VALIDATORS_METADATA_KEY}:${key}`, groupType)), 97 | updateOn: Reflect.getMetadata(`${UPDATE_ON_METADATA_KEY}:${key}`, groupType) 98 | } 99 | ); 100 | Reflect.defineMetadata(FORM_CONTROL_INSTANCE_METADATA_KEY, formContextCommon, ngxFormControl); 101 | 102 | return ngxFormControl; 103 | } 104 | 105 | public buildArray(key: string, arrayContext: FormArrayContext, groupType: ConstructorFunction): NgxFormArray { 106 | const ngxFormArray: NgxFormArray = this.array( 107 | [], 108 | { 109 | validators: this.validatorResolver.resolve(Reflect.getMetadata(`${VALIDATORS_METADATA_KEY}:${key}`, groupType)), 110 | asyncValidators: this.asyncValidatorResolver.resolve(Reflect.getMetadata(`${ASYNC_VALIDATORS_METADATA_KEY}:${key}`, groupType)), 111 | updateOn: Reflect.getMetadata(`${UPDATE_ON_METADATA_KEY}:${key}`, groupType) 112 | } 113 | ); 114 | Reflect.defineMetadata(FORM_ARRAY_INSTANCE_METADATA_KEY, arrayContext, ngxFormArray); 115 | 116 | (arrayContext.defaultValues || []).forEach((value: VC) => ngxFormArray.add(value)); 117 | 118 | return ngxFormArray; 119 | } 120 | 121 | public buildGroup(key: string, groupContext: FormGroupContext, groupType: ConstructorFunction): NgxFormGroup { 122 | const groupValidators: ValidatorFn | ValidatorFn[] = this.validatorResolver.resolve( 123 | Reflect.getMetadata(`${VALIDATORS_METADATA_KEY}:${key}`, groupType) || Reflect.getMetadata(`${VALIDATORS_METADATA_KEY}`, groupContext.type()) 124 | ); 125 | const groupAsyncValidators: AsyncValidatorFn | AsyncValidatorFn[] = this.asyncValidatorResolver.resolve( 126 | Reflect.getMetadata(`${ASYNC_VALIDATORS_METADATA_KEY}:${key}`, groupType) || Reflect.getMetadata(`${ASYNC_VALIDATORS_METADATA_KEY}`, groupContext.type()) 127 | ); 128 | const groupUpdateOn: FormHooks = Reflect.getMetadata(`${UPDATE_ON_METADATA_KEY}:${key}`, groupType) || Reflect.getMetadata(`${UPDATE_ON_METADATA_KEY}`, groupContext.type()); 129 | 130 | const ngxFormGroup: NgxFormGroup = this.build( 131 | groupContext, 132 | { 133 | validators: groupValidators, 134 | asyncValidators: groupAsyncValidators, 135 | updateOn: groupUpdateOn 136 | } 137 | ); 138 | Reflect.defineMetadata(FORM_GROUP_INSTANCE_METADATA_KEY, groupContext, ngxFormGroup); 139 | 140 | if (groupContext.defaultValue !== undefined) { 141 | ngxFormGroup.patchValue(groupContext.defaultValue); 142 | } 143 | 144 | return ngxFormGroup; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/core/validator.resolver.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { provideNgxForm } from '../ngx-form.module'; 3 | import { ValidatorResolver } from './validator.resolver'; 4 | import { Injectable } from '@angular/core'; 5 | import { FormControl, ValidatorFn, Validators } from '@angular/forms'; 6 | import { ValidatorFactory, ValidatorFactoryWithProviders } from '../factory/validator.factory'; 7 | 8 | @Injectable() 9 | class MyProvider { 10 | 11 | public readonly length: number = 5; 12 | 13 | } 14 | 15 | describe('ValidatorResolver', () => { 16 | 17 | let resolver: ValidatorResolver; 18 | 19 | beforeEach(() => { 20 | TestBed.configureTestingModule({ 21 | providers: [ 22 | provideNgxForm(), 23 | ValidatorResolver, 24 | MyProvider 25 | ] 26 | }); 27 | 28 | resolver = TestBed.inject(ValidatorResolver); 29 | }); 30 | 31 | it('should return undefined if no config', () => { 32 | expect(resolver.resolve(undefined)).toBeUndefined(); 33 | }); 34 | 35 | it('should return validator fn as is', () => { 36 | const validator: ValidatorFn = Validators.required; 37 | 38 | expect(resolver.resolve(validator)).toEqual(validator); 39 | }); 40 | 41 | it('should return validator fn array as is', () => { 42 | const validators: ValidatorFn[] = [Validators.required, Validators.min(0)]; 43 | 44 | expect(resolver.resolve(validators)).toEqual(validators); 45 | }); 46 | 47 | it('should build validator fn', () => { 48 | const validator: ValidatorFactoryWithProviders = 49 | ValidatorFactory.of(((provider: MyProvider) => Validators.min(provider.length)), [MyProvider]); 50 | 51 | const error: any = (resolver.resolve(validator) as ValidatorFn)(new FormControl(0)); 52 | expect(error).toEqual(Validators.min(5)(new FormControl(0))); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/core/validator.resolver.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable, Injector, Type } from '@angular/core'; 2 | import { ValidatorFn } from '@angular/forms'; 3 | import { AsyncValidatorConfig } from '../decorator/async-validator.decorator'; 4 | import { ValidatorConfig } from '../decorator/validator.decorator'; 5 | 6 | @Injectable() 7 | export class ValidatorResolver { 8 | 9 | private readonly injector: Injector = inject(Injector); 10 | 11 | public resolve(config: ValidatorConfig | ValidatorConfig[]): ValidatorFn | ValidatorFn[] { 12 | if (!config) { 13 | return; 14 | } else if (Array.isArray(config)) { 15 | return config.map((c: AsyncValidatorConfig) => this.validatorConfigToValidatorFn(c)); 16 | } else { 17 | return this.validatorConfigToValidatorFn(config); 18 | } 19 | } 20 | 21 | private validatorConfigToValidatorFn(config: ValidatorConfig): ValidatorFn { 22 | if (typeof config === 'function') { 23 | return config; 24 | } else if (typeof config === 'object') { 25 | return config.factory(...config.providers.map((provider: Type) => this.injector.get(provider))); 26 | } else { 27 | throw new Error('Invalid @Validator argument'); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/async-validator.decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { AsyncValidatorFn } from '@angular/forms'; 3 | import { ASYNC_VALIDATORS_METADATA_KEY, AsyncValidator } from './async-validator.decorator'; 4 | import { of } from 'rxjs'; 5 | import { delay, map } from 'rxjs/operators'; 6 | import { AsyncValidatorFactory } from '../factory/async-validator.factory'; 7 | 8 | class MyService { 9 | } 10 | 11 | const myAsyncValidator: AsyncValidatorFn = () => 12 | of(void 0).pipe( 13 | delay(10), 14 | map(() => ({asyncError: true})) 15 | ); 16 | 17 | @AsyncValidator(myAsyncValidator) 18 | class LibraryForm { 19 | 20 | public name: string = 'My Library'; 21 | 22 | @AsyncValidator([]) 23 | public numberOfBooks: number = 10; 24 | 25 | @AsyncValidator(AsyncValidatorFactory.of((service: MyService) => () => of({error: `${service}`}), [MyService])) 26 | public address: string; 27 | 28 | @AsyncValidator([ 29 | myAsyncValidator, 30 | AsyncValidatorFactory.of((service: MyService) => () => of({error: `${service}`}), [MyService]) 31 | ]) 32 | public secondaryAddress: string; 33 | } 34 | 35 | class NotAForm { 36 | } 37 | 38 | describe('AsyncValidatorDecorator', () => { 39 | 40 | const form: LibraryForm = new LibraryForm(); 41 | 42 | it('should add async validator metadata on property with decorator', () => { 43 | expect(Reflect.getMetadata(`${ASYNC_VALIDATORS_METADATA_KEY}:numberOfBooks`, form)).toBeDefined(); 44 | }) 45 | 46 | it('should not add async validator metadata on property without decorator', () => { 47 | expect(Reflect.getMetadata(`${ASYNC_VALIDATORS_METADATA_KEY}:name`, form)).toBeUndefined(); 48 | }) 49 | 50 | it('should add async validator metadata on class with decorator', () => { 51 | expect(Reflect.getMetadata(`${ASYNC_VALIDATORS_METADATA_KEY}`, LibraryForm)).toBeDefined(); 52 | }) 53 | 54 | it('should not add async validator metadata on class without decorator', () => { 55 | expect(Reflect.getMetadata(`${ASYNC_VALIDATORS_METADATA_KEY}`, NotAForm)).toBeUndefined(); 56 | }) 57 | }) 58 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/async-validator.decorator.ts: -------------------------------------------------------------------------------- 1 | import { AsyncValidatorFn } from '@angular/forms'; 2 | import { AsyncValidatorFactoryWithProviders } from '../factory/async-validator.factory'; 3 | 4 | export const ASYNC_VALIDATORS_METADATA_KEY: string = 'ngx-form:async-validators'; 5 | 6 | export type AsyncValidatorConfig = AsyncValidatorFn | AsyncValidatorFactoryWithProviders; 7 | 8 | export function AsyncValidator(asyncValidators: AsyncValidatorConfig | AsyncValidatorConfig[]): any { 9 | return (target: any, propertyKey: string = null): void => { 10 | let key: string = ASYNC_VALIDATORS_METADATA_KEY; 11 | if (propertyKey) { 12 | key += `:${propertyKey}`; 13 | } 14 | Reflect.defineMetadata(key, asyncValidators, target); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/build-form.decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { FormControl } from './form-control.decorator'; 2 | import { NgxFormGroup } from '../model/ngx-form-group.model'; 3 | import { BuildForm } from './build-form.decorator'; 4 | import { NgxFormBuilder } from '../core/ngx-form.builder'; 5 | import { provideNgxForm } from '../ngx-form.module'; 6 | import { TestBed } from '@angular/core/testing'; 7 | import { EMPTY, Observable, of } from 'rxjs'; 8 | import { DisableOn } from './disable-on.decorator'; 9 | import { Injectable } from '@angular/core'; 10 | import { DisableOnFactory } from '../factory/disable-on.factory'; 11 | import { delay } from 'rxjs/operators'; 12 | import { FormControlStatus, Validators } from '@angular/forms'; 13 | import { RunHelpers } from 'rxjs/internal/testing/TestScheduler'; 14 | import { TestScheduler } from 'rxjs/testing'; 15 | import { FormGroup } from './form-group.decorator'; 16 | import { Validator } from './validator.decorator'; 17 | import { OnValueChanges } from './on-value-changes.decorator'; 18 | 19 | @Injectable() 20 | class MyProvider { 21 | 22 | public enable(): Observable { 23 | return of(false); 24 | } 25 | 26 | public disable(): Observable { 27 | return of(true); 28 | } 29 | 30 | public disableWithTimeout(): Observable { 31 | return of(true).pipe( 32 | delay(100) 33 | ); 34 | } 35 | 36 | } 37 | 38 | @Validator(Validators.required) 39 | class UserForm { 40 | 41 | @FormControl() 42 | public name: string; 43 | 44 | @FormControl({defaultValue: 'Oscar GUERIN'}) 45 | public displayName: string; 46 | 47 | @DisableOn(of(true)) 48 | @FormControl() 49 | public surname: string; 50 | 51 | @DisableOn(DisableOnFactory.of((provider: MyProvider) => provider.disable(), [MyProvider])) 52 | @FormControl() 53 | public disabledAddress: string; 54 | 55 | @DisableOn(DisableOnFactory.of((provider: MyProvider) => provider.enable(), [MyProvider])) 56 | @FormControl() 57 | public enabledAddress: string; 58 | 59 | @DisableOn(DisableOnFactory.of((provider: MyProvider) => provider.disableWithTimeout(), [MyProvider])) 60 | @FormControl() 61 | public disabledWithTimeoutAddress: string; 62 | 63 | @DisableOn(DisableOnFactory.of((provider: MyProvider) => provider.disableWithTimeout(), [MyProvider]), {emitEvent: false}) 64 | @FormControl() 65 | public disabledWithTimeoutAddressWithoutEvents: string; 66 | 67 | @FormGroup(() => SubForm) 68 | public subForm: SubForm; 69 | } 70 | 71 | class SubForm { 72 | 73 | @DisableOn(of(true)) 74 | @FormControl() 75 | public myControl: string; 76 | 77 | } 78 | 79 | @DisableOn(of(true)) 80 | class DisabledForm { 81 | 82 | @FormControl() 83 | public name: string; 84 | 85 | } 86 | 87 | class SumForm { 88 | 89 | @FormControl() 90 | public a: number; 91 | 92 | @FormControl() 93 | public b: number; 94 | 95 | @FormControl() 96 | public sum: number; 97 | 98 | @OnValueChanges(['a', 'b']) 99 | public computeSum(instance: NgxFormGroup): void { 100 | this.sum = this.a + this.b; 101 | instance.setValue(this, {emitEvent: false}); 102 | } 103 | } 104 | 105 | class ConsumerComponent { 106 | 107 | public readonly obs$: Observable = EMPTY; 108 | 109 | @BuildForm(() => UserForm, {unsubscribeOn: 'obs$'}) 110 | public form: NgxFormGroup; 111 | 112 | @BuildForm(() => DisabledForm, {unsubscribeOn: 'obs$'}) 113 | public disabledForm: NgxFormGroup; 114 | 115 | @BuildForm(() => SumForm, {unsubscribeOn: 'obs$'}) 116 | public sumForm: NgxFormGroup; 117 | } 118 | 119 | describe('BuildFormDecorator', () => { 120 | 121 | let builder: NgxFormBuilder; 122 | let testScheduler: TestScheduler; 123 | 124 | beforeEach(() => { 125 | TestBed.configureTestingModule({ 126 | providers: [ 127 | provideNgxForm(), 128 | MyProvider 129 | ] 130 | }); 131 | 132 | builder = TestBed.inject(NgxFormBuilder); 133 | testScheduler = new TestScheduler(((actual: any, expected: any) => { 134 | expect(actual).toEqual(expected); 135 | })); 136 | }); 137 | 138 | const component: ConsumerComponent = new ConsumerComponent(); 139 | 140 | it('should create form group', () => { 141 | expect(component.form).toBeDefined(); 142 | }); 143 | 144 | it('should create form controls', () => { 145 | expect(component.form.controls.name).toBeDefined(); 146 | }); 147 | 148 | it('should set initial value when specified', () => { 149 | expect(component.form.controls.name.value).toBeNull(); 150 | expect(component.form.controls.displayName.value).toEqual('Oscar GUERIN'); 151 | }); 152 | 153 | it('should disable controls depending on disable on observable value', () => { 154 | expect(component.form.controls.enabledAddress.enabled).toBeTrue(); 155 | expect(component.form.controls.disabledAddress.disabled).toBeTrue(); 156 | expect(component.form.controls.surname.disabled).toBeTrue(); 157 | }); 158 | 159 | it('should emit disable events when emit event option is true or undefined', (done: DoneFn) => { 160 | component.form.controls.disabledWithTimeoutAddress.statusChanges.subscribe((event: FormControlStatus) => { 161 | expect(event).toEqual('DISABLED'); 162 | 163 | done(); 164 | }); 165 | }); 166 | 167 | it('should not emit disable events when emit event option is set to false', () => { 168 | testScheduler.run(({expectObservable}: RunHelpers) => { 169 | const source$: Observable = component.form.controls.disabledWithTimeoutAddressWithoutEvents.statusChanges; 170 | expectObservable(source$).toBe(''); 171 | }); 172 | }); 173 | 174 | it('should disable entire form if disable on decorator is set on form class', () => { 175 | expect(component.form.enabled).toBeTrue(); 176 | expect(component.disabledForm.disabled).toBeTrue(); 177 | }); 178 | 179 | it('should disable controls from child form groups', () => { 180 | expect(component.form.controls.subForm.get('myControl').disabled).toBeTrue(); 181 | }); 182 | 183 | it('should compute sum on value changes', () => { 184 | component.sumForm.get('a').setValue(2); 185 | component.sumForm.get('b').setValue(3); 186 | 187 | expect(component.sumForm.getValue().sum).toEqual(5); 188 | }) 189 | }) 190 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/build-form.decorator.ts: -------------------------------------------------------------------------------- 1 | import { NgxFormModule } from '../ngx-form.module'; 2 | import { NgxFormGroup } from '../model/ngx-form-group.model'; 3 | import { FormGroupContext } from './form-group.decorator'; 4 | import { DisableOnHandler } from '../core/handler/disable-on.handler'; 5 | import { OnValueChangesHandler } from '../core/handler/on-value-changes.handler'; 6 | import { ConstructorFunction } from '../common/typing'; 7 | 8 | export const BUILD_FORM_METADATA_KEY: string = 'ngx-form:build-form'; 9 | 10 | export const BUILD_FORM_INSTANCE_METADATA_KEY: string = 'ngx-form:form-instance'; 11 | 12 | export interface BuildFormConfig { 13 | 14 | unsubscribeOn?: string; 15 | } 16 | 17 | export interface BuildFormContextConfiguration extends BuildFormConfig { 18 | 19 | type: () => ConstructorFunction; 20 | } 21 | 22 | export function BuildForm(type: () => ConstructorFunction, config: BuildFormConfig = {}): any { 23 | return (target: any, propertyKey: string): void => { 24 | const buildFormContextConfiguration: BuildFormContextConfiguration = { 25 | type, 26 | ...config 27 | }; 28 | Reflect.defineMetadata(BUILD_FORM_METADATA_KEY, buildFormContextConfiguration, target); 29 | 30 | Object.defineProperty(target.constructor.prototype, propertyKey, { 31 | get(): NgxFormGroup { 32 | if (Reflect.hasOwnMetadata(BUILD_FORM_INSTANCE_METADATA_KEY, this, propertyKey)) { 33 | return Reflect.getOwnMetadata(BUILD_FORM_INSTANCE_METADATA_KEY, this, propertyKey); 34 | } 35 | 36 | const formGroupContextConfiguration: FormGroupContext = { 37 | type, 38 | }; 39 | 40 | const form: NgxFormGroup = NgxFormModule.getNgxFormBuilder().build(formGroupContextConfiguration); 41 | Reflect.defineMetadata(BUILD_FORM_INSTANCE_METADATA_KEY, form, this, propertyKey); 42 | 43 | NgxFormModule.getInjector().get(DisableOnHandler).handle(type, form, this[config?.unsubscribeOn]); 44 | 45 | NgxFormModule.getInjector().get(OnValueChangesHandler).handle(type, form, this[config?.unsubscribeOn]); 46 | 47 | return form; 48 | }, 49 | set: () => void 0, 50 | enumerable: true, 51 | configurable: true 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/disable-on.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { Type } from '@angular/core'; 3 | 4 | export const DISABLE_ON_METADATA_KEY: string = 'ngx-form:disable-on'; 5 | 6 | export type DisableOnSimpleConfig = Observable; 7 | 8 | export type DisableOnConfigFactoryFn = (...providers: any[]) => DisableOnSimpleConfig; 9 | 10 | export interface DisableOnConfigWithProviders { 11 | 12 | factory: DisableOnConfigFactoryFn; 13 | 14 | providers: Type[]; 15 | 16 | } 17 | 18 | export type DisableOnConfig = DisableOnSimpleConfig | DisableOnConfigWithProviders; 19 | 20 | export interface DisableOnOptions { 21 | 22 | onlySelf?: boolean; 23 | 24 | emitEvent?: boolean; 25 | 26 | } 27 | 28 | export interface DisableOnContext { 29 | 30 | propertyKey?: string; 31 | 32 | config: DisableOnConfig; 33 | 34 | options?: DisableOnOptions; 35 | 36 | } 37 | 38 | export function DisableOn(config: DisableOnConfig, options?: DisableOnOptions): any { 39 | return (target: any, propertyKey = null): void => { 40 | const context: DisableOnContext = {propertyKey, config, options}; 41 | 42 | let metas: DisableOnContext[] = []; 43 | if (Reflect.hasMetadata(DISABLE_ON_METADATA_KEY, target)) { 44 | metas = Reflect.getMetadata(DISABLE_ON_METADATA_KEY, target); 45 | } 46 | Reflect.defineMetadata(DISABLE_ON_METADATA_KEY, metas.concat(context), target); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/form-array.decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { FormControl } from './form-control.decorator'; 2 | import { PROPERTY_CONFIGURATIONS_METADATA_KEY } from '../common/decorator.common'; 3 | import { FORM_ARRAY_SUFFIX_METADATA_KEY, FormArray, FormArrayContext } from './form-array.decorator'; 4 | 5 | class CompanyForm { 6 | 7 | @FormControl() 8 | public name: string; 9 | 10 | @FormControl() 11 | public siret: string; 12 | 13 | public constructor(data: Partial = {}) { 14 | Object.assign(this, data); 15 | } 16 | } 17 | 18 | class UserForm { 19 | 20 | @FormArray({defaultValue: 'Default skill', defaultValues: ['Java', 'C++']}) 21 | public skills: string[]; 22 | 23 | @FormArray('userSoftSkills') 24 | public softSkills: string[]; 25 | 26 | @FormArray(() => CompanyForm) 27 | public companies: CompanyForm[]; 28 | } 29 | 30 | const form: UserForm = new UserForm(); 31 | 32 | describe('FormArrayDecorator', () => { 33 | 34 | it('should add form array metadata on form class', () => { 35 | const formMetadata: {[key: string]: FormArrayContext} = 36 | Reflect.getMetadata(`${PROPERTY_CONFIGURATIONS_METADATA_KEY}:${FORM_ARRAY_SUFFIX_METADATA_KEY}`, form); 37 | expect(formMetadata).toBeDefined(); 38 | }) 39 | 40 | it('should add form array type metadata when defined', () => { 41 | const formMetadata: {[key: string]: FormArrayContext} = 42 | Reflect.getMetadata(`${PROPERTY_CONFIGURATIONS_METADATA_KEY}:${FORM_ARRAY_SUFFIX_METADATA_KEY}`, form); 43 | expect(formMetadata.skills.type).toBeUndefined(); 44 | expect(formMetadata.companies.type().name).toEqual(CompanyForm.name); 45 | }) 46 | 47 | it('should add form array default value metadata when defined', () => { 48 | const formMetadata: {[key: string]: FormArrayContext} = 49 | Reflect.getMetadata(`${PROPERTY_CONFIGURATIONS_METADATA_KEY}:${FORM_ARRAY_SUFFIX_METADATA_KEY}`, form); 50 | expect(formMetadata.skills.defaultValue).toEqual('Default skill'); 51 | expect(formMetadata.companies.defaultValue).toBeUndefined(); 52 | }) 53 | 54 | it('should add form array default values metadata when defined', () => { 55 | const formMetadata: {[key: string]: FormArrayContext} = 56 | Reflect.getMetadata(`${PROPERTY_CONFIGURATIONS_METADATA_KEY}:${FORM_ARRAY_SUFFIX_METADATA_KEY}`, form); 57 | expect(formMetadata.skills.defaultValues).toEqual(['Java', 'C++']); 58 | expect(formMetadata.companies.defaultValues).toBeUndefined(); 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/form-array.decorator.ts: -------------------------------------------------------------------------------- 1 | import { addFormContextCommon, FormContextCommon, FormHooks } from '../common/decorator.common'; 2 | import { ConstructorFunction } from '../common/typing'; 3 | 4 | export const FORM_ARRAY_SUFFIX_METADATA_KEY: string = 'form-array'; 5 | 6 | export interface FormArrayContext extends FormContextCommon { 7 | 8 | type?: () => ConstructorFunction; 9 | 10 | updateOn?: FormHooks; 11 | 12 | defaultValues?: T[]; 13 | } 14 | 15 | export function FormArray(formArrayContext?: FormArrayContext | string | (() => ConstructorFunction)): any { 16 | return (target: any, propertyKey: string): void => { 17 | let formArrayContextConfiguration: FormArrayContext = { 18 | name: propertyKey 19 | } 20 | 21 | if (typeof formArrayContext === 'object') { 22 | formArrayContextConfiguration = { 23 | ...formArrayContextConfiguration, 24 | ...formArrayContext 25 | }; 26 | } else if (typeof formArrayContext === 'string') { 27 | formArrayContextConfiguration.name = formArrayContext; 28 | } else if (typeof formArrayContext === 'function') { 29 | formArrayContextConfiguration.type = formArrayContext; 30 | } 31 | 32 | addFormContextCommon(target, formArrayContextConfiguration, propertyKey, FORM_ARRAY_SUFFIX_METADATA_KEY); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/form-child.decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { FormControl } from './form-control.decorator'; 2 | import { NgxFormGroup } from '../model/ngx-form-group.model'; 3 | import { BuildForm } from './build-form.decorator'; 4 | import { NgxFormBuilder } from '../core/ngx-form.builder'; 5 | import { provideNgxForm } from '../ngx-form.module'; 6 | import { FormArray } from './form-array.decorator'; 7 | import { FormChild } from './form-child.decorator'; 8 | import { NgxFormArray } from '../model/ngx-form-array.model'; 9 | import { TestBed } from '@angular/core/testing'; 10 | 11 | class UserForm { 12 | 13 | @FormControl() 14 | public name: string; 15 | 16 | @FormArray({defaultValue: 'Default skill', defaultValues: ['Java', 'C++']}) 17 | public skills: string[]; 18 | } 19 | 20 | class ConsumerComponent { 21 | 22 | @BuildForm(() => UserForm) 23 | public form: NgxFormGroup; 24 | 25 | @FormChild({attribute: 'form', path: 'skills'}) 26 | public skillForm: NgxFormArray; 27 | 28 | @FormChild({attribute: 'form', path: 'wrong'}) 29 | public wrongPathForm: NgxFormArray; 30 | } 31 | 32 | let builder: NgxFormBuilder; 33 | 34 | const component: ConsumerComponent = new ConsumerComponent(); 35 | 36 | describe('FormChildDecorator', () => { 37 | 38 | beforeEach(() => { 39 | TestBed.configureTestingModule({ 40 | providers: [ 41 | provideNgxForm(), 42 | ] 43 | }); 44 | 45 | builder = TestBed.inject(NgxFormBuilder); 46 | }); 47 | 48 | it('should create form group', () => { 49 | expect(component.form).toBeDefined(); 50 | }); 51 | 52 | it('should create form child when path is correct', () => { 53 | expect(component.skillForm).toBeDefined(); 54 | }); 55 | 56 | it('should not create form child when path is incorrect', () => { 57 | expect(component.wrongPathForm).toBeNull(); 58 | }); 59 | 60 | it('should have value defined', () => { 61 | expect(component.skillForm.value).toBeDefined(); 62 | }); 63 | }) 64 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/form-child.decorator.ts: -------------------------------------------------------------------------------- 1 | import { NgxFormGroup } from '../model/ngx-form-group.model'; 2 | import { AbstractControl } from '@angular/forms'; 3 | import { NgxFormControl } from '../model/ngx-form-control.model'; 4 | import { NgxFormArray } from '../model/ngx-form-array.model'; 5 | 6 | export const FORM_CHILD_METADATA_KEY: string = 'ngx-form:form-children'; 7 | 8 | export interface FormChildContext { 9 | 10 | attribute: string; 11 | 12 | path: string; 13 | } 14 | 15 | export function FormChild(context: FormChildContext): any { 16 | return (target: any, propertyKey: any): void => { 17 | Reflect.defineMetadata(FORM_CHILD_METADATA_KEY, context, target, propertyKey); 18 | 19 | Object.defineProperty(target.constructor.prototype, propertyKey, { 20 | get(): NgxFormGroup | NgxFormControl | NgxFormArray { 21 | return (this[context.attribute] as AbstractControl).get(context.path) as NgxFormGroup | NgxFormControl | NgxFormArray; 22 | }, 23 | set: () => void 0, 24 | enumerable: true, 25 | configurable: true 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/form-control.decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { FORM_CONTROL_SUFFIX_METADATA_KEY, FormControl } from './form-control.decorator'; 2 | import { FormContextCommon, PROPERTY_CONFIGURATIONS_METADATA_KEY } from '../common/decorator.common'; 3 | 4 | class UserForm { 5 | 6 | @FormControl('userName') 7 | public name: string; 8 | 9 | @FormControl({defaultValue: 'Oscar GUERIN'}) 10 | public displayName: string; 11 | 12 | public notAFormControl: number; 13 | } 14 | 15 | describe('FormControlDecorator', () => { 16 | 17 | const form: UserForm = new UserForm(); 18 | 19 | it('should add form controls metadata on form class', () => { 20 | const formMetadata: {[key: string]: FormContextCommon} = 21 | Reflect.getMetadata(`${PROPERTY_CONFIGURATIONS_METADATA_KEY}:${FORM_CONTROL_SUFFIX_METADATA_KEY}`, form); 22 | expect(formMetadata).toBeDefined(); 23 | }) 24 | 25 | it('should add control name on metadata', () => { 26 | const formMetadata: {[key: string]: FormContextCommon} = 27 | Reflect.getMetadata(`${PROPERTY_CONFIGURATIONS_METADATA_KEY}:${FORM_CONTROL_SUFFIX_METADATA_KEY}`, form); 28 | expect(formMetadata.name.name).toEqual('userName'); 29 | expect(formMetadata.displayName.name).toEqual('displayName'); 30 | }) 31 | 32 | it('should add control default value on metadata when defined', () => { 33 | const formMetadata: {[key: string]: FormContextCommon} = 34 | Reflect.getMetadata(`${PROPERTY_CONFIGURATIONS_METADATA_KEY}:${FORM_CONTROL_SUFFIX_METADATA_KEY}`, form); 35 | expect(formMetadata.name.defaultValue).toBeUndefined(); 36 | expect(formMetadata.displayName.defaultValue).toEqual('Oscar GUERIN'); 37 | }) 38 | 39 | it('should not add metadata for fields without annotation', () => { 40 | const formMetadata: {[key: string]: FormContextCommon} = 41 | Reflect.getMetadata(`${PROPERTY_CONFIGURATIONS_METADATA_KEY}:${FORM_CONTROL_SUFFIX_METADATA_KEY}`, form); 42 | expect(formMetadata.notAFormControl).toBeUndefined(); 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/form-control.decorator.ts: -------------------------------------------------------------------------------- 1 | import { addFormContextCommon, FormContextCommon } from '../common/decorator.common'; 2 | 3 | export const FORM_CONTROL_SUFFIX_METADATA_KEY: string = 'form-control'; 4 | 5 | export function FormControl(formControlContext?: FormContextCommon | string): any { 6 | return (target: any, propertyKey: string): void => { 7 | let formControlContextConfiguration: FormContextCommon = { 8 | name: propertyKey 9 | } 10 | 11 | if (typeof formControlContext === 'object') { 12 | formControlContextConfiguration = { 13 | ...formControlContextConfiguration, 14 | ...formControlContext 15 | }; 16 | } else if (typeof formControlContext === 'string') { 17 | formControlContextConfiguration.name = formControlContext; 18 | } 19 | 20 | addFormContextCommon(target, formControlContextConfiguration, propertyKey, FORM_CONTROL_SUFFIX_METADATA_KEY); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/form-group.decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import { FormControl } from './form-control.decorator'; 2 | import { PROPERTY_CONFIGURATIONS_METADATA_KEY } from '../common/decorator.common'; 3 | import { FORM_GROUP_SUFFIX_METADATA_KEY, FormGroup, FormGroupContext } from './form-group.decorator'; 4 | 5 | class AddressForm { 6 | 7 | @FormControl({defaultValue: 0}) 8 | public streetNumber: number; 9 | 10 | @FormControl({defaultValue: 'Default route'}) 11 | public route: string; 12 | 13 | public constructor(data: Partial = {}) { 14 | Object.assign(this, data); 15 | } 16 | } 17 | 18 | const defaultAddress: AddressForm = new AddressForm({ 19 | route: 'User route', 20 | streetNumber: 1 21 | }); 22 | 23 | class UserForm { 24 | 25 | @FormGroup(() => AddressForm) 26 | public homeAddress: AddressForm; 27 | 28 | @FormGroup({type: () => AddressForm, defaultValue: structuredClone(defaultAddress)}) 29 | public workAddress: AddressForm; 30 | } 31 | 32 | describe('FormGroupDecorator', () => { 33 | 34 | const form: UserForm = new UserForm(); 35 | 36 | it('should add form group metadata on form class', () => { 37 | const formMetadata: {[key: string]: FormGroupContext} = 38 | Reflect.getMetadata(`${PROPERTY_CONFIGURATIONS_METADATA_KEY}:${FORM_GROUP_SUFFIX_METADATA_KEY}`, form); 39 | expect(formMetadata).toBeDefined(); 40 | }) 41 | 42 | it('should add form group type metadata for each form group annotation', () => { 43 | const formMetadata: {[key: string]: FormGroupContext} = 44 | Reflect.getMetadata(`${PROPERTY_CONFIGURATIONS_METADATA_KEY}:${FORM_GROUP_SUFFIX_METADATA_KEY}`, form); 45 | expect(formMetadata.homeAddress.type().name).toEqual(AddressForm.name); 46 | expect(formMetadata.workAddress.type().name).toEqual(AddressForm.name); 47 | }) 48 | 49 | it('should add form group default value metadata when defined', () => { 50 | const formMetadata: {[key: string]: FormGroupContext} = 51 | Reflect.getMetadata(`${PROPERTY_CONFIGURATIONS_METADATA_KEY}:${FORM_GROUP_SUFFIX_METADATA_KEY}`, form); 52 | expect(formMetadata.homeAddress.defaultValue).toBeUndefined(); 53 | expect(formMetadata.workAddress.defaultValue).toEqual(structuredClone(defaultAddress)); 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/form-group.decorator.ts: -------------------------------------------------------------------------------- 1 | import { addFormContextCommon, FormContextCommon } from '../common/decorator.common'; 2 | import { ConstructorFunction } from '../common/typing'; 3 | 4 | export const FORM_GROUP_SUFFIX_METADATA_KEY: string = 'form-group'; 5 | 6 | export interface FormGroupContext extends FormContextCommon { 7 | 8 | type: () => ConstructorFunction; 9 | } 10 | 11 | export function FormGroup(formGroupContext: FormGroupContext | (() => ConstructorFunction)): any { 12 | return (target: any, propertyKey: string): void => { 13 | let formGroupContextConfiguration: FormGroupContext = { 14 | name: propertyKey, 15 | } as FormGroupContext; 16 | 17 | if (typeof formGroupContext === 'object') { 18 | formGroupContextConfiguration = { 19 | ...formGroupContextConfiguration, 20 | ...formGroupContext 21 | }; 22 | } else if (typeof formGroupContext === 'function') { 23 | formGroupContextConfiguration.type = formGroupContext; 24 | } 25 | 26 | addFormContextCommon(target, formGroupContextConfiguration, propertyKey, FORM_GROUP_SUFFIX_METADATA_KEY); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/on-value-changes.decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { ON_VALUE_CHANGES_METADATA_KEY, OnValueChanges } from './on-value-changes.decorator'; 3 | 4 | class LibraryForm { 5 | 6 | public name: string = 'My Library'; 7 | 8 | @OnValueChanges() 9 | public myMethod(): void { 10 | this.name = 'Another library' 11 | } 12 | } 13 | 14 | describe('OnValueChangesDecorator', () => { 15 | 16 | const form: LibraryForm = new LibraryForm(); 17 | 18 | it('should add value changes metadata on method with decorator', () => { 19 | expect(Reflect.getMetadata(`${ON_VALUE_CHANGES_METADATA_KEY}`, form)).toBeDefined(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/on-value-changes.decorator.ts: -------------------------------------------------------------------------------- 1 | export const ON_VALUE_CHANGES_METADATA_KEY: string = 'ngx-form:on-value-changes'; 2 | 3 | export interface OnValueChangesContext { 4 | 5 | propertyKey?: string; 6 | 7 | controls?: string | string[]; 8 | 9 | } 10 | 11 | export function OnValueChanges(controls?: string | string[]): any { 12 | return (target: any, propertyKey = null): void => { 13 | const context: OnValueChangesContext = {propertyKey, controls}; 14 | 15 | let metas: OnValueChangesContext[] = []; 16 | if (Reflect.hasMetadata(ON_VALUE_CHANGES_METADATA_KEY, target)) { 17 | metas = Reflect.getMetadata(ON_VALUE_CHANGES_METADATA_KEY, target); 18 | } 19 | Reflect.defineMetadata(ON_VALUE_CHANGES_METADATA_KEY, metas.concat(context), target); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/update-on.decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { UPDATE_ON_METADATA_KEY, UpdateOn } from './update-on.decorator'; 3 | 4 | @UpdateOn('blur') 5 | class LibraryForm { 6 | 7 | public name: string = 'My Library'; 8 | 9 | @UpdateOn('change') 10 | public numberOfBooks: number = 10; 11 | } 12 | 13 | class NotAForm { 14 | } 15 | 16 | describe('UpdateOnDecorator', () => { 17 | 18 | const form: LibraryForm = new LibraryForm(); 19 | 20 | it('should add update on metadata on property with decorator', () => { 21 | expect(Reflect.getMetadata(`${UPDATE_ON_METADATA_KEY}:numberOfBooks`, form)).toBeDefined(); 22 | }) 23 | 24 | it('should not add update on metadata on property without decorator', () => { 25 | expect(Reflect.getMetadata(`${UPDATE_ON_METADATA_KEY}:name`, form)).toBeUndefined(); 26 | }) 27 | 28 | it('should add update on metadata on class with decorator', () => { 29 | expect(Reflect.getMetadata(`${UPDATE_ON_METADATA_KEY}`, LibraryForm)).toBeDefined(); 30 | }) 31 | 32 | it('should not add update on metadata on class without decorator', () => { 33 | expect(Reflect.getMetadata(`${UPDATE_ON_METADATA_KEY}`, NotAForm)).toBeUndefined(); 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/update-on.decorator.ts: -------------------------------------------------------------------------------- 1 | import { FormHooks } from '../common/decorator.common'; 2 | 3 | export const UPDATE_ON_METADATA_KEY: string = 'ngx-form:update-on'; 4 | 5 | export function UpdateOn(updateOn: FormHooks): any { 6 | return (target: any, propertyKey: string = null): void => { 7 | let key: string = UPDATE_ON_METADATA_KEY; 8 | if (propertyKey) { 9 | key += `:${propertyKey}`; 10 | } 11 | Reflect.defineMetadata(key, updateOn, target); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/validator.decorator.spec.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import { Validator, VALIDATORS_METADATA_KEY } from './validator.decorator'; 3 | import { Validators } from '@angular/forms'; 4 | 5 | @Validator(Validators.required) 6 | class LibraryForm { 7 | 8 | public name: string = 'My Library'; 9 | 10 | @Validator([Validators.required, Validators.min(0)]) 11 | public numberOfBooks: number = 10; 12 | } 13 | 14 | class NotAForm { 15 | } 16 | 17 | describe('ValidatorDecorator', () => { 18 | 19 | const form: LibraryForm = new LibraryForm(); 20 | 21 | it('should add validator metadata on property with decorator', () => { 22 | expect(Reflect.getMetadata(`${VALIDATORS_METADATA_KEY}:numberOfBooks`, form)).toBeDefined(); 23 | }) 24 | 25 | it('should not add validator metadata on property without decorator', () => { 26 | expect(Reflect.getMetadata(`${VALIDATORS_METADATA_KEY}:name`, form)).toBeUndefined(); 27 | }) 28 | 29 | it('should add validator metadata on class with decorator', () => { 30 | expect(Reflect.getMetadata(`${VALIDATORS_METADATA_KEY}`, LibraryForm)).toBeDefined(); 31 | }) 32 | 33 | it('should not add validator metadata on class without decorator', () => { 34 | expect(Reflect.getMetadata(`${VALIDATORS_METADATA_KEY}`, NotAForm)).toBeUndefined(); 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/decorator/validator.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ValidatorFn } from '@angular/forms'; 2 | import { ValidatorFactoryWithProviders } from '../factory/validator.factory'; 3 | 4 | export const VALIDATORS_METADATA_KEY: string = 'ngx-form:validators'; 5 | 6 | export type ValidatorConfig = ValidatorFn | ValidatorFactoryWithProviders; 7 | 8 | export function Validator(validators: ValidatorConfig | ValidatorConfig[]): any { 9 | return (target: any, propertyKey: string = null): void => { 10 | let key: string = VALIDATORS_METADATA_KEY; 11 | if (propertyKey) { 12 | key += `:${propertyKey}`; 13 | } 14 | Reflect.defineMetadata(key, validators, target); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/factory/async-validator.factory.ts: -------------------------------------------------------------------------------- 1 | import { AsyncValidatorFn } from '@angular/forms'; 2 | import { Type } from '@angular/core'; 3 | 4 | export type AsyncValidatorFactoryFn = (...providers: any[]) => AsyncValidatorFn; 5 | 6 | export interface AsyncValidatorFactoryWithProviders { 7 | 8 | factory: AsyncValidatorFactoryFn; 9 | 10 | providers: Type[]; 11 | 12 | } 13 | 14 | export class AsyncValidatorFactory { 15 | 16 | public static of(factory: AsyncValidatorFactoryFn, providers: Type[]): AsyncValidatorFactoryWithProviders { 17 | return {factory, providers}; 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/factory/disable-on.factory.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@angular/core'; 2 | import { DisableOnConfigFactoryFn, DisableOnConfigWithProviders } from '../decorator/disable-on.decorator'; 3 | 4 | export class DisableOnFactory { 5 | 6 | public static of(factory: DisableOnConfigFactoryFn, providers: Type[]): DisableOnConfigWithProviders { 7 | return {factory, providers}; 8 | } 9 | 10 | } 11 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/factory/validator.factory.ts: -------------------------------------------------------------------------------- 1 | import { ValidatorFn } from '@angular/forms'; 2 | import { Type } from '@angular/core'; 3 | 4 | export type ValidatorFactoryFn = (...providers: any[]) => ValidatorFn; 5 | 6 | export interface ValidatorFactoryWithProviders { 7 | 8 | factory: ValidatorFactoryFn; 9 | 10 | providers: Type[]; 11 | 12 | } 13 | 14 | export class ValidatorFactory { 15 | 16 | public static of(factory: ValidatorFactoryFn, providers: Type[]): ValidatorFactoryWithProviders { 17 | return {factory, providers}; 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/model/interface/ngx-form-collection.ts: -------------------------------------------------------------------------------- 1 | import { NgxForm } from './ngx-form'; 2 | 3 | export interface NgxFormCollection extends NgxForm { 4 | 5 | markAllAsDirty(): void; 6 | 7 | markAllAsPending(): void; 8 | 9 | markAllAsPristine(): void; 10 | 11 | markAllAsUntouched(): void; 12 | } 13 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/model/interface/ngx-form.ts: -------------------------------------------------------------------------------- 1 | export interface NgxForm { 2 | 3 | restore(): void; 4 | 5 | empty(): void; 6 | 7 | cancel(): void; 8 | } 9 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/model/ngx-form-array.model.spec.ts: -------------------------------------------------------------------------------- 1 | import { FormControl } from '../decorator/form-control.decorator'; 2 | import { FormGroupContext } from '../decorator/form-group.decorator'; 3 | import { FormArray } from '../decorator/form-array.decorator'; 4 | import { NgxFormBuilder } from '../core/ngx-form.builder'; 5 | import { NgxFormGroup } from './ngx-form-group.model'; 6 | import { NgxFormArray } from './ngx-form-array.model'; 7 | import { TestBed } from '@angular/core/testing'; 8 | import { provideNgxForm } from '../ngx-form.module'; 9 | 10 | class CompanyForm { 11 | 12 | @FormControl({defaultValue: 'Witty Services'}) 13 | public name: string; 14 | 15 | @FormControl({defaultValue: '00000000000000'}) 16 | public siret: string; 17 | 18 | public constructor(data: Partial = {}) { 19 | Object.assign(this, data); 20 | } 21 | } 22 | 23 | class UserForm { 24 | 25 | @FormArray({defaultValue: 'Default skill', defaultValues: ['Java', 'C++']}) 26 | public skills: string[]; 27 | 28 | @FormArray({type: () => CompanyForm, defaultValues: [new CompanyForm()]}) 29 | public companies: CompanyForm[]; 30 | 31 | public constructor(data: Partial = {}) { 32 | Object.assign(this, data); 33 | } 34 | } 35 | 36 | const formGroupContextConfiguration: FormGroupContext = { 37 | type: () => UserForm 38 | }; 39 | 40 | let builder: NgxFormBuilder; 41 | let form: NgxFormGroup; 42 | 43 | describe('NgxFormArray', () => { 44 | 45 | beforeEach(() => { 46 | TestBed.configureTestingModule({ 47 | providers: [ 48 | provideNgxForm(), 49 | ] 50 | }) 51 | 52 | builder = TestBed.inject(NgxFormBuilder); 53 | form = builder.build(formGroupContextConfiguration); 54 | }); 55 | 56 | it('should set value on method call', () => { 57 | form.controls.skills.setValue(['Angular, TypeScript']); 58 | expect(form.getValue().skills).toEqual(['Angular, TypeScript']); 59 | }); 60 | 61 | it('should patch value on method call', () => { 62 | form.controls.skills.patchValue(['Java', 'Angular', 'TypeScript']); 63 | expect(form.getValue().skills).toEqual(['Java', 'Angular']); 64 | }); 65 | 66 | it('should patch value on method call', () => { 67 | form.controls.skills.patchValue(['Angular']); 68 | expect(form.getValue().skills).toEqual(['Angular', 'C++']); 69 | }); 70 | 71 | it('should add value on method call', () => { 72 | (form.controls.skills as NgxFormArray).add('C#'); 73 | expect(form.getValue().skills).toEqual(['Java', 'C++', 'C#']); 74 | }); 75 | 76 | it('should insert value on add method call with index', () => { 77 | (form.controls.skills as NgxFormArray).add('C#', 0); 78 | expect(form.getValue().skills).toEqual(['C#', 'Java', 'C++']); 79 | }); 80 | 81 | it('should add form value on method call', () => { 82 | const companyFormDefault: CompanyForm = new CompanyForm({ 83 | name: 'Witty Services', 84 | siret: '00000000000000' 85 | }); 86 | 87 | const addedCompanyForm: CompanyForm = new CompanyForm({ 88 | name: 'Witty', 89 | siret: '11111111111111' 90 | }); 91 | 92 | (form.controls.companies as NgxFormArray).add(addedCompanyForm); 93 | expect(form.getValue().companies).toEqual([companyFormDefault, addedCompanyForm]); 94 | }); 95 | 96 | it('should restore value on method call', () => { 97 | form.controls.skills.setValue(['Angular, TypeScript']); 98 | form.restore(); 99 | expect(form.getValue().skills).toEqual(['Java', 'C++']); 100 | }); 101 | 102 | it('should empty value on method call', () => { 103 | form.controls.skills.setValue(['Angular, TypeScript']); 104 | form.empty(); 105 | expect(form.getValue().skills).toEqual([]); 106 | }); 107 | 108 | it('should mark all as dirty', () => { 109 | const companies: NgxFormArray = form.controls.companies as NgxFormArray; 110 | expect(companies.dirty).toBeFalse(); 111 | expect(companies.controls[0].dirty).toBeFalse(); 112 | expect(companies.controls[0].get('name').dirty).toBeFalse(); 113 | form.markAllAsDirty(); 114 | expect(companies.dirty).toBeTrue(); 115 | expect(companies.controls[0].dirty).toBeTrue(); 116 | expect(companies.controls[0].get('name').dirty).toBeTrue(); 117 | }); 118 | 119 | it('should mark all as untouched', () => { 120 | const companies: NgxFormArray = form.controls.companies as NgxFormArray; 121 | companies.controls[0].get('name').markAsTouched(); 122 | expect(companies.untouched).toBeFalse(); 123 | expect(companies.controls[0].untouched).toBeFalse(); 124 | expect(companies.controls[0].get('name').untouched).toBeFalse(); 125 | form.markAllAsUntouched(); 126 | expect(companies.untouched).toBeTrue(); 127 | expect(companies.controls[0].untouched).toBeTrue(); 128 | expect(companies.controls[0].get('name').untouched).toBeTrue(); 129 | }); 130 | 131 | it('should mark all as pending', () => { 132 | const companies: NgxFormArray = form.controls.companies as NgxFormArray; 133 | expect(companies.pending).toBeFalse(); 134 | expect(companies.controls[0].pending).toBeFalse(); 135 | expect(companies.controls[0].get('name').pending).toBeFalse(); 136 | form.markAllAsPending(); 137 | expect(companies.pending).toBeTrue(); 138 | expect(companies.controls[0].pending).toBeTrue(); 139 | expect(companies.controls[0].get('name').pending).toBeTrue(); 140 | }); 141 | 142 | it('should mark all as pristine', () => { 143 | const companies: NgxFormArray = form.controls.companies as NgxFormArray; 144 | companies.controls[0].get('name').markAsDirty(); 145 | expect(companies.pristine).toBeFalse(); 146 | expect(companies.controls[0].pristine).toBeFalse(); 147 | expect(companies.controls[0].get('name').pristine).toBeFalse(); 148 | form.markAllAsPristine(); 149 | expect(companies.pristine).toBeTrue(); 150 | expect(companies.controls[0].pristine).toBeTrue(); 151 | expect(companies.controls[0].get('name').pristine).toBeTrue(); 152 | }); 153 | 154 | it('should set the value with the latest setValue call', () => { 155 | const initialValue: CompanyForm[] = [new CompanyForm({ 156 | name: 'Thomas SA', 157 | siret: 'mon siret' 158 | })]; 159 | form.get('companies').setValue(initialValue); 160 | 161 | form.get('companies').patchValue([new CompanyForm({ 162 | name: 'Oscarrr SA', 163 | siret: 'mon siret' 164 | })]); 165 | 166 | (form.get('companies') as NgxFormArray).cancel(); 167 | expect((form.get('companies') as NgxFormArray).getValue()).toEqual(initialValue); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/model/ngx-form-array.model.ts: -------------------------------------------------------------------------------- 1 | import { AbstractControl, AbstractControlOptions, AsyncValidatorFn, FormArray, ValidatorFn } from '@angular/forms'; 2 | import { NgxFormBuilder } from '../core/ngx-form.builder'; 3 | import { FormArrayContext } from '../decorator/form-array.decorator'; 4 | import { NgxFormCollection } from './interface/ngx-form-collection'; 5 | import { NgxFormControl } from './ngx-form-control.model'; 6 | import { transformValueToSmartValue } from '../common/common'; 7 | import { NgxFormGroup } from './ngx-form-group.model'; 8 | 9 | export const FORM_ARRAY_INSTANCE_METADATA_KEY: string = 'ngx-form:form-array-instance'; 10 | 11 | export class NgxFormArray extends FormArray> implements NgxFormCollection { 12 | 13 | private lastValuesSet: V[]; 14 | 15 | public constructor(private readonly builder: NgxFormBuilder, 16 | controls: AbstractControl[], 17 | validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, 18 | asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null) { 19 | super(controls, validatorOrOpts, asyncValidator); 20 | } 21 | 22 | public getValue(parent: string = ''): V[] { 23 | if (parent !== '') { 24 | parent += parent + '.'; 25 | } 26 | 27 | return this.controls.map((control: AbstractControl) => { 28 | if (control instanceof NgxFormGroup) { 29 | return transformValueToSmartValue(control, parent); 30 | } else { 31 | return control.value; 32 | } 33 | }); 34 | } 35 | 36 | public add(value: V = null, index: number = -1): AbstractControl { 37 | const formArrayContext: FormArrayContext = Reflect.getMetadata(FORM_ARRAY_INSTANCE_METADATA_KEY, this); 38 | let form: AbstractControl; 39 | if (!formArrayContext.type) { 40 | form = this.builder.control( 41 | value || formArrayContext.defaultValue, 42 | { 43 | validators: [], 44 | asyncValidators: [], 45 | updateOn: formArrayContext.updateOn 46 | } 47 | ); 48 | } else { 49 | form = this.builder.build({type: formArrayContext.type, defaultValue: value || formArrayContext.defaultValue}) 50 | } 51 | 52 | if (index === -1) { 53 | this.push(form); 54 | } else { 55 | this.insert(index, form); 56 | } 57 | 58 | return form; 59 | } 60 | 61 | public setValue(values: V[], options?: {onlySelf?: boolean; emitEvent?: boolean}): void { 62 | this.clear(); 63 | values.forEach(() => this.add()); 64 | super.setValue(values.map((value: V) => value), options); 65 | this.lastValuesSet = values; 66 | } 67 | 68 | public patchValue(values: V[], options?: {onlySelf?: boolean; emitEvent?: boolean}): void { 69 | super.patchValue(values.map((value: V) => value), options); 70 | } 71 | 72 | public cancel(): void { 73 | this.setValue(this.lastValuesSet); 74 | } 75 | 76 | public empty(): void { 77 | this.clear(); 78 | } 79 | 80 | public restore(): void { 81 | this.clear(); 82 | 83 | const formArrayContext: FormArrayContext = Reflect.getMetadata(FORM_ARRAY_INSTANCE_METADATA_KEY, this); 84 | (formArrayContext.defaultValues || []).forEach((value: V) => this.add(value)); 85 | } 86 | 87 | public markAllAsDirty(): void { 88 | this.markAsDirty({onlySelf: true}); 89 | 90 | this.controls 91 | .filter((value: AbstractControl) => value instanceof NgxFormControl) 92 | .forEach((control: any) => control.markAsDirty()); 93 | 94 | this.controls 95 | .filter((value: AbstractControl) => !(value instanceof NgxFormControl)) 96 | .forEach((control: any) => control.markAllAsDirty()); 97 | } 98 | 99 | public markAllAsPending(): void { 100 | this.markAsPending({onlySelf: true}); 101 | 102 | this.controls 103 | .filter((value: AbstractControl) => value instanceof NgxFormControl) 104 | .forEach((control: any) => control.markAsPending()); 105 | 106 | this.controls 107 | .filter((value: AbstractControl) => !(value instanceof NgxFormControl)) 108 | .forEach((control: any) => control.markAllAsPending()); 109 | } 110 | 111 | public markAllAsPristine(): void { 112 | this.markAsPristine({onlySelf: true}); 113 | 114 | this.controls 115 | .filter((value: AbstractControl) => value instanceof NgxFormControl) 116 | .forEach((control: any) => control.markAsPristine()); 117 | 118 | this.controls 119 | .filter((value: AbstractControl) => !(value instanceof NgxFormControl)) 120 | .forEach((control: any) => control.markAllAsPristine()); 121 | } 122 | 123 | public markAllAsUntouched(): void { 124 | this.markAsUntouched({onlySelf: true}); 125 | 126 | this.controls 127 | .filter((value: AbstractControl) => value instanceof NgxFormControl) 128 | .forEach((control: any) => control.markAsUntouched()); 129 | 130 | this.controls 131 | .filter((value: AbstractControl) => !(value instanceof NgxFormControl)) 132 | .forEach((control: any) => control.markAllAsUntouched()); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/model/ngx-form-control.model.spec.ts: -------------------------------------------------------------------------------- 1 | import { Validators } from '@angular/forms'; 2 | import { Validator } from '../decorator/validator.decorator'; 3 | import { FormControl } from '../decorator/form-control.decorator'; 4 | import { FormGroupContext } from '../decorator/form-group.decorator'; 5 | import { UpdateOn } from '../decorator/update-on.decorator'; 6 | import { NgxFormBuilder } from '../core/ngx-form.builder'; 7 | import { NgxFormGroup } from './ngx-form-group.model'; 8 | import { NgxFormControl } from './ngx-form-control.model'; 9 | import { TestBed } from '@angular/core/testing'; 10 | import { provideNgxForm } from '../ngx-form.module'; 11 | 12 | @UpdateOn('change') 13 | class UserForm { 14 | 15 | @FormControl({defaultValue: 'Thomas'}) 16 | public firstName: string; 17 | 18 | @Validator(Validators.required) 19 | @FormControl('userLastName') 20 | public lastName: string; 21 | 22 | public constructor(data: Partial = {}) { 23 | Object.assign(this, data); 24 | } 25 | } 26 | 27 | const formGroupContextConfiguration: FormGroupContext = { 28 | type: () => UserForm 29 | }; 30 | 31 | let builder: NgxFormBuilder; 32 | let form: NgxFormGroup; 33 | 34 | describe('NgxFormControl', () => { 35 | 36 | beforeEach(() => { 37 | TestBed.configureTestingModule({ 38 | providers: [ 39 | provideNgxForm(), 40 | ] 41 | }) 42 | 43 | builder = TestBed.inject(NgxFormBuilder); 44 | form = builder.build(formGroupContextConfiguration); 45 | }); 46 | 47 | it('should set value on method call', () => { 48 | form.controls.firstName.setValue('Oscar'); 49 | expect(form.getValue().firstName).toEqual('Oscar'); 50 | }); 51 | 52 | it('should restore value on method call', () => { 53 | form.controls.firstName.setValue('Oscar'); 54 | form.restore(); 55 | expect(form.getValue().firstName).toEqual('Thomas'); 56 | }); 57 | 58 | it('should set the value with the latest setValue call', () => { 59 | form.get('firstName').setValue('Thomas'); 60 | form.get('firstName').patchValue('Oscarrr'); 61 | 62 | (form.get('firstName') as NgxFormControl).cancel(); 63 | expect(form.get('firstName').value).toEqual('Thomas'); 64 | }); 65 | }) 66 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/model/ngx-form-control.model.ts: -------------------------------------------------------------------------------- 1 | import { AbstractControlOptions, AsyncValidatorFn, FormControl, ValidatorFn } from '@angular/forms'; 2 | import { NgxForm } from './interface/ngx-form'; 3 | import { FormContextCommon } from '../common/decorator.common'; 4 | 5 | export const FORM_CONTROL_INSTANCE_METADATA_KEY: string = 'ngx-form:form-control-instance'; 6 | 7 | export class NgxFormControl extends FormControl implements NgxForm { 8 | 9 | private lastValueSet: V; 10 | private makeRestoration: boolean = false; 11 | private makePatch: boolean = false; 12 | 13 | public constructor(formState?: any, validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null) { 14 | super(formState, validatorOrOpts, asyncValidator); 15 | } 16 | 17 | public readonly value: V; 18 | 19 | public setValue(value: V, options?: {onlySelf?: boolean; emitEvent?: boolean; emitModelToViewChange?: boolean; emitViewToModelChange?: boolean}): void { 20 | super.setValue(value, options); 21 | 22 | if (this.makeRestoration) { 23 | this.makeRestoration = false; 24 | } 25 | 26 | if (!this.makePatch) { 27 | this.lastValueSet = value; 28 | } 29 | } 30 | 31 | public patchValue(value: V, options?: {onlySelf?: boolean; emitEvent?: boolean; emitModelToViewChange?: boolean; emitViewToModelChange?: boolean}): void { 32 | this.makePatch = true; 33 | super.patchValue(value, options); 34 | this.makePatch = false; 35 | } 36 | 37 | public cancel(): void { 38 | this.setValue(this.lastValueSet); 39 | } 40 | 41 | public empty(): void { 42 | } 43 | 44 | public restore(): void { 45 | this.makeRestoration = true; 46 | const formContextCommon: FormContextCommon = Reflect.getMetadata(FORM_CONTROL_INSTANCE_METADATA_KEY, this); 47 | this.setValue(formContextCommon.defaultValue !== undefined ? formContextCommon.defaultValue : null); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/model/ngx-form-group.model.spec.ts: -------------------------------------------------------------------------------- 1 | import { FormControl } from '../decorator/form-control.decorator'; 2 | import { FormGroup, FormGroupContext } from '../decorator/form-group.decorator'; 3 | import { NgxFormBuilder } from '../core/ngx-form.builder'; 4 | import { NgxFormGroup } from './ngx-form-group.model'; 5 | import { FormArray } from '../decorator/form-array.decorator'; 6 | import { TestBed } from '@angular/core/testing'; 7 | import { provideNgxForm } from '../ngx-form.module'; 8 | 9 | class AddressForm { 10 | 11 | @FormControl({defaultValue: 0}) 12 | public streetNumber: number; 13 | 14 | @FormControl({defaultValue: 'Default route'}) 15 | public route: string; 16 | 17 | public constructor(data: Partial = {}) { 18 | Object.assign(this, data); 19 | } 20 | } 21 | 22 | const defaultAddress: AddressForm = new AddressForm({ 23 | streetNumber: 7, 24 | route: 'User route' 25 | }) 26 | 27 | class UserForm { 28 | 29 | @FormGroup({type: () => AddressForm, defaultValue: defaultAddress}) 30 | public address: AddressForm; 31 | 32 | @FormArray({defaultValue: 'Default skill'}) 33 | public skills: string[]; 34 | 35 | public constructor(data: Partial = {}) { 36 | Object.assign(this, data); 37 | } 38 | } 39 | 40 | const formGroupContextConfiguration: FormGroupContext = { 41 | type: () => UserForm 42 | }; 43 | 44 | let builder: NgxFormBuilder; 45 | let form: NgxFormGroup; 46 | 47 | describe('NgxFormGroup', () => { 48 | 49 | beforeEach(() => { 50 | TestBed.configureTestingModule({ 51 | providers: [ 52 | provideNgxForm(), 53 | ] 54 | }) 55 | 56 | builder = TestBed.inject(NgxFormBuilder); 57 | form = builder.build(formGroupContextConfiguration); 58 | }); 59 | 60 | it('should get smart value', () => { 61 | expect(form.getValue()).toBeInstanceOf(UserForm); 62 | }); 63 | 64 | it('should set value on method call', () => { 65 | form.setValue(new UserForm({ 66 | address: new AddressForm({ 67 | route: 'Hey', 68 | streetNumber: 8 69 | }), 70 | skills: ['Java'] 71 | })); 72 | expect(form.getValue().address.streetNumber).toEqual(8); 73 | }); 74 | 75 | it('should restore value on method call', () => { 76 | form.setValue(new UserForm({ 77 | address: new AddressForm({ 78 | route: 'Hey', 79 | streetNumber: 8 80 | }), 81 | skills: [] 82 | })); 83 | form.restore(); 84 | expect(form.getValue().address.streetNumber).toEqual(7); 85 | form.setValue(new UserForm({ 86 | address: new AddressForm({ 87 | route: 'Hey', 88 | streetNumber: 8 89 | }), 90 | skills: [] 91 | })); 92 | expect(form.getValue().address.streetNumber).toEqual(8); 93 | }); 94 | 95 | it('should reset value on method call', () => { 96 | form.empty(); 97 | expect(form.getValue().address.streetNumber).toEqual(null); 98 | }); 99 | 100 | it('should patch value on method call', () => { 101 | form.patchValue(new UserForm({ 102 | address: new AddressForm({ 103 | route: 'Hey', 104 | streetNumber: 8 105 | }) 106 | })); 107 | expect(form.getValue().address.streetNumber).toEqual(8); 108 | }); 109 | 110 | it('should mark all as dirty', () => { 111 | const address: NgxFormGroup = form.controls.address as NgxFormGroup; 112 | expect(address.dirty).toBeFalse(); 113 | expect(address.get('route').dirty).toBeFalse(); 114 | form.markAllAsDirty(); 115 | expect(address.dirty).toBeTrue(); 116 | expect(address.get('route').dirty).toBeTrue(); 117 | }); 118 | 119 | it('should mark all as untouched', () => { 120 | const address: NgxFormGroup = form.controls.address as NgxFormGroup; 121 | address.get('route').markAsTouched(); 122 | expect(address.untouched).toBeFalse(); 123 | expect(address.get('route').untouched).toBeFalse(); 124 | form.markAllAsUntouched(); 125 | expect(address.untouched).toBeTrue(); 126 | expect(address.get('route').untouched).toBeTrue(); 127 | }); 128 | 129 | it('should mark all as pending', () => { 130 | const address: NgxFormGroup = form.controls.address as NgxFormGroup; 131 | expect(address.pending).toBeFalse(); 132 | expect(address.get('route').pending).toBeFalse(); 133 | form.markAllAsPending(); 134 | expect(address.pending).toBeTrue(); 135 | expect(address.get('route').pending).toBeTrue(); 136 | }); 137 | 138 | it('should mark all as pristine', () => { 139 | const address: NgxFormGroup = form.controls.address as NgxFormGroup; 140 | address.get('route').markAsDirty(); 141 | expect(address.pristine).toBeFalse(); 142 | expect(address.get('route').pristine).toBeFalse(); 143 | form.markAllAsPristine(); 144 | expect(address.pristine).toBeTrue(); 145 | expect(address.get('route').pristine).toBeTrue(); 146 | }); 147 | 148 | it('should set the value with the latest setValue call', () => { 149 | const initialValue: UserForm = new UserForm({ 150 | address: new AddressForm({ 151 | route: 'route', 152 | streetNumber: 1 153 | }), 154 | skills: ['Typescript'] 155 | }); 156 | form.setValue(initialValue); 157 | 158 | form.patchValue(new UserForm({ 159 | address: new AddressForm({ 160 | route: 'routeeee' 161 | }), 162 | skills: ['C++'] 163 | })); 164 | 165 | form.cancel(); 166 | expect(form.getValue()).toEqual(initialValue); 167 | }); 168 | }) 169 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/model/ngx-form-group.model.ts: -------------------------------------------------------------------------------- 1 | import { AbstractControl, AbstractControlOptions, AsyncValidatorFn, FormGroup, ValidatorFn } from '@angular/forms'; 2 | import { NgxForm } from './interface/ngx-form'; 3 | import { FormGroupContext } from '../decorator/form-group.decorator'; 4 | import { NgxFormCollection } from './interface/ngx-form-collection'; 5 | import { NgxFormControl } from './ngx-form-control.model'; 6 | import { transformSmartValueToValue, transformValueToSmartValue } from '../common/common'; 7 | import { DataFormType, DataToFormType } from '../common/typing'; 8 | 9 | export const FORM_GROUP_INSTANCE_METADATA_KEY: string = 'ngx-form:form-group-instance'; 10 | 11 | export class NgxFormGroup extends FormGroup> implements NgxFormCollection { 12 | 13 | private lastValueSet: Partial; 14 | private makeRestoration: boolean = false; 15 | 16 | public constructor(controls: DataToFormType, 17 | validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, 18 | asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null) { 19 | super(controls, validatorOrOpts, asyncValidator); 20 | } 21 | 22 | public getValue(parent: string = ''): V { 23 | if (parent !== '') { 24 | parent += parent + '.'; 25 | } 26 | 27 | return transformValueToSmartValue(this, parent); 28 | } 29 | 30 | public setValue(value: DataFormType, options?: {onlySelf?: boolean; emitEvent?: boolean}): void { 31 | super.setValue(transformSmartValueToValue(value), options); 32 | 33 | if (this.makeRestoration) { 34 | this.makeRestoration = false; 35 | } 36 | this.lastValueSet = value; 37 | } 38 | 39 | public patchValue(value: DataFormType, options?: {onlySelf?: boolean; emitEvent?: boolean}): void { 40 | super.patchValue(transformSmartValueToValue(value), options); 41 | } 42 | 43 | public cancel(): void { 44 | this.setValue(this.lastValueSet); 45 | } 46 | 47 | public empty(): void { 48 | this.reset(); 49 | 50 | (Object.values(this.controls) as any).forEach((control: NgxForm) => control.empty()); 51 | } 52 | 53 | public restore(): void { 54 | (Object.values(this.controls) as any).forEach((control: NgxForm) => control.restore()); 55 | const groupContext: FormGroupContext = Reflect.getMetadata(FORM_GROUP_INSTANCE_METADATA_KEY, this); 56 | 57 | if (groupContext.defaultValue !== undefined) { 58 | this.makeRestoration = true; 59 | this.patchValue(groupContext.defaultValue); 60 | } 61 | } 62 | 63 | public markAllAsDirty(): void { 64 | this.markAsDirty({onlySelf: true}); 65 | 66 | Object.values(this.controls) 67 | .filter((value: AbstractControl) => value instanceof NgxFormControl) 68 | .forEach((control: NgxFormControl) => control.markAsDirty()); 69 | 70 | Object.values(this.controls) 71 | .filter((value: AbstractControl) => !(value instanceof NgxFormControl)) 72 | .forEach((control: any) => control.markAllAsDirty()); 73 | } 74 | 75 | public markAllAsPending(): void { 76 | this.markAsPending({onlySelf: true}); 77 | 78 | Object.values(this.controls) 79 | .filter((value: AbstractControl) => value instanceof NgxFormControl) 80 | .forEach((control: NgxFormControl) => control.markAsPending()); 81 | 82 | Object.values(this.controls) 83 | .filter((value: AbstractControl) => !(value instanceof NgxFormControl)) 84 | .forEach((control: any) => control.markAllAsPending()); 85 | } 86 | 87 | public markAllAsPristine(): void { 88 | this.markAsPristine({onlySelf: true}); 89 | 90 | Object.values(this.controls) 91 | .filter((value: AbstractControl) => value instanceof NgxFormControl) 92 | .forEach((control: NgxFormControl) => control.markAsPristine()); 93 | 94 | Object.values(this.controls) 95 | .filter((value: AbstractControl) => !(value instanceof NgxFormControl)) 96 | .forEach((control: any) => control.markAllAsPristine()); 97 | } 98 | 99 | public markAllAsUntouched(): void { 100 | this.markAsUntouched({onlySelf: true}); 101 | 102 | Object.values(this.controls) 103 | .filter((value: AbstractControl) => value instanceof NgxFormControl) 104 | .forEach((control: NgxFormControl) => control.markAsUntouched()); 105 | 106 | Object.values(this.controls) 107 | .filter((value: AbstractControl) => !(value instanceof NgxFormControl)) 108 | .forEach((control: any) => control.markAllAsUntouched()); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/ngx-form.module.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, waitForAsync } from '@angular/core/testing'; 2 | import { NgxFormModule } from './ngx-form.module'; 3 | import { Injector } from '@angular/core'; 4 | 5 | describe('NgxFormModule', () => { 6 | 7 | beforeEach(waitForAsync(() => { 8 | TestBed.configureTestingModule({ 9 | imports: [ 10 | NgxFormModule, 11 | Injector 12 | ] 13 | }).compileComponents(); 14 | })); 15 | 16 | 17 | it('should be instantiated', () => { 18 | expect(NgxFormModule).toBeDefined(); 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /projects/ngx-form/src/lib/ngx-form.module.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | 3 | import { EnvironmentProviders, inject, Injector, makeEnvironmentProviders, ModuleWithProviders, NgModule, provideAppInitializer } from '@angular/core'; 4 | import { NgxFormBuilder } from './core/ngx-form.builder'; 5 | import { AsyncValidatorResolver } from './core/async-validator.resolver'; 6 | import { DisableOnHandler } from './core/handler/disable-on.handler'; 7 | import { ValidatorResolver } from './core/validator.resolver'; 8 | import { OnValueChangesHandler } from './core/handler/on-value-changes.handler'; 9 | 10 | export function provideNgxForm(): EnvironmentProviders { 11 | return makeEnvironmentProviders([ 12 | provideAppInitializer(() => ((injector: Injector) => (): void => { 13 | NgxFormModule.injector = injector; 14 | })(inject(Injector))()), 15 | NgxFormBuilder, 16 | ValidatorResolver, 17 | AsyncValidatorResolver, 18 | DisableOnHandler, 19 | OnValueChangesHandler 20 | ]); 21 | } 22 | 23 | @NgModule() 24 | export class NgxFormModule { 25 | 26 | public static injector: Injector = null; 27 | 28 | public static getNgxFormBuilder(): NgxFormBuilder { 29 | return NgxFormModule.injector.get(NgxFormBuilder); 30 | } 31 | 32 | public static getInjector(): Injector { 33 | return NgxFormModule.injector; 34 | } 35 | 36 | /** 37 | * @deprecated use provideNgxForm() instead 38 | */ 39 | public static forRoot(): ModuleWithProviders { 40 | return { 41 | ngModule: NgxFormModule, 42 | providers: [ 43 | provideNgxForm() 44 | ] 45 | }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /projects/ngx-form/src/public-api.ts: -------------------------------------------------------------------------------- 1 | export { AsyncValidator, AsyncValidatorConfig } from './lib/decorator/async-validator.decorator'; 2 | export { BuildForm } from './lib/decorator/build-form.decorator'; 3 | export { DisableOnSimpleConfig, DisableOnConfigFactoryFn, DisableOnConfigWithProviders, DisableOnConfig, DisableOnOptions, DisableOnContext, DisableOn } from './lib/decorator/disable-on.decorator'; 4 | export { OnValueChangesContext, OnValueChanges } from './lib/decorator/on-value-changes.decorator'; 5 | export { FormArray, FormArrayContext } from './lib/decorator/form-array.decorator'; 6 | export { FormControl } from './lib/decorator/form-control.decorator'; 7 | export { FormGroup, FormGroupContext } from './lib/decorator/form-group.decorator'; 8 | export { FormChild, FormChildContext } from './lib/decorator/form-child.decorator'; 9 | export { UpdateOn } from './lib/decorator/update-on.decorator'; 10 | export { Validator, ValidatorConfig } from './lib/decorator/validator.decorator'; 11 | export { AsyncValidatorFactory, AsyncValidatorFactoryFn, AsyncValidatorFactoryWithProviders } from './lib/factory/async-validator.factory'; 12 | export { ValidatorFactory, ValidatorFactoryFn, ValidatorFactoryWithProviders } from './lib/factory/validator.factory'; 13 | export { DisableOnFactory } from './lib/factory/disable-on.factory'; 14 | 15 | export { NgxFormArray } from './lib/model/ngx-form-array.model'; 16 | export { NgxFormControl } from './lib/model/ngx-form-control.model'; 17 | export { NgxFormGroup } from './lib/model/ngx-form-group.model'; 18 | 19 | export { NgxFormBuilder } from './lib/core/ngx-form.builder'; 20 | 21 | export { NgxFormModule, provideNgxForm } from './lib/ngx-form.module'; 22 | 23 | export * from './lib/common/typing'; 24 | -------------------------------------------------------------------------------- /projects/ngx-form/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js'; 4 | import 'zone.js/testing'; 5 | import { getTestBed } from '@angular/core/testing'; 6 | import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing'; 7 | 8 | // First, initialize the Angular testing environment. 9 | getTestBed().initTestEnvironment( 10 | BrowserDynamicTestingModule, 11 | platformBrowserDynamicTesting() 12 | ); 13 | -------------------------------------------------------------------------------- /projects/ngx-form/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../out-tsc/lib", 5 | "declaration": true, 6 | "inlineSources": true, 7 | "types": [], 8 | "lib": [ 9 | "dom", 10 | "es2018" 11 | ] 12 | }, 13 | "angularCompilerOptions": { 14 | "skipTemplateCodegen": true, 15 | "strictMetadataEmit": true, 16 | "enableResourceInlining": true 17 | }, 18 | "exclude": [ 19 | "src/test.ts", 20 | "**/*.spec.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /projects/ngx-form/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.lib.json", 3 | "compilerOptions": { 4 | "declarationMap": false 5 | }, 6 | "angularCompilerOptions": { 7 | "compilationMode": "partial" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /projects/ngx-form/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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "outDir": "./dist/out-tsc", 6 | "sourceMap": true, 7 | "esModuleInterop": true, 8 | "declaration": false, 9 | "experimentalDecorators": true, 10 | "module": "esnext", 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "ES2022", 14 | "lib": [ 15 | "es2018", 16 | "dom" 17 | ], 18 | "paths": { 19 | "@paddls/ngx-form": [ 20 | "dist/ngx-form/ngx-form", 21 | "dist/ngx-form" 22 | ] 23 | }, 24 | "useDefineForClassFields": false 25 | }, 26 | "angularCompilerOptions": { 27 | "fullTemplateTypeCheck": true, 28 | "strictInjectionParameters": true 29 | } 30 | } 31 | --------------------------------------------------------------------------------