├── .commitlintrc.js ├── .editorconfig ├── .github └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .husky └── commit-msg ├── .prettierignore ├── .prettierrc ├── .releaserc ├── .vscode ├── extensions.json ├── launch.json └── tasks.json ├── CHANGELOG.md ├── README.md ├── angular.json ├── course.jpeg ├── package-lock.json ├── package.json ├── projects ├── examples │ ├── karma.conf.js │ ├── src │ │ ├── app │ │ │ ├── app.component.html │ │ │ ├── app.component.scss │ │ │ ├── app.component.ts │ │ │ ├── components │ │ │ │ ├── smart │ │ │ │ │ ├── business-hours-form │ │ │ │ │ │ ├── business-hours-form.component.html │ │ │ │ │ │ ├── business-hours-form.component.scss │ │ │ │ │ │ └── business-hours-form.component.ts │ │ │ │ │ └── purchase-form │ │ │ │ │ │ ├── purchase-form.component.html │ │ │ │ │ │ ├── purchase-form.component.scss │ │ │ │ │ │ └── purchase-form.component.ts │ │ │ │ └── ui │ │ │ │ │ ├── address │ │ │ │ │ ├── address.component.html │ │ │ │ │ ├── address.component.scss │ │ │ │ │ └── address.component.ts │ │ │ │ │ ├── business-hour │ │ │ │ │ ├── business-hour.component.html │ │ │ │ │ ├── business-hour.component.scss │ │ │ │ │ └── business-hour.component.ts │ │ │ │ │ ├── business-hours │ │ │ │ │ ├── business-hours.component.html │ │ │ │ │ ├── business-hours.component.scss │ │ │ │ │ └── business-hours.component.ts │ │ │ │ │ └── phonenumbers │ │ │ │ │ ├── phonenumbers.component.html │ │ │ │ │ ├── phonenumbers.component.scss │ │ │ │ │ └── phonenumbers.component.ts │ │ │ ├── luke.service.ts │ │ │ ├── models │ │ │ │ ├── address.model.ts │ │ │ │ ├── business-hours-form.model.ts │ │ │ │ ├── phonenumber.model.ts │ │ │ │ └── purchase-form.model.ts │ │ │ ├── product.service.ts │ │ │ ├── product.type.ts │ │ │ ├── swapi.service.ts │ │ │ └── validations │ │ │ │ ├── address.validations.ts │ │ │ │ ├── business-hours.validations.ts │ │ │ │ ├── phonenumber.validations.ts │ │ │ │ └── purchase.validations.ts │ │ ├── assets │ │ │ ├── .gitkeep │ │ │ └── course.jpeg │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── main.ts │ │ ├── styles.scss │ │ └── variables.scss │ ├── tsconfig.app.json │ └── tsconfig.spec.json └── ngx-vest-forms │ ├── .storybook │ ├── main.ts │ ├── preview.ts │ ├── styles.scss │ ├── tsconfig.json │ └── typings.d.ts │ ├── documentation.json │ ├── ng-package.json │ ├── package.json │ ├── src │ ├── lib │ │ ├── components │ │ │ └── control-wrapper │ │ │ │ ├── control-wrapper.component.html │ │ │ │ ├── control-wrapper.component.scss │ │ │ │ └── control-wrapper.component.ts │ │ ├── constants.ts │ │ ├── directives │ │ │ ├── form-model-group.directive.ts │ │ │ ├── form-model.directive.ts │ │ │ ├── form.directive.ts │ │ │ ├── validate-root-form.directive.ts │ │ │ └── validation-options.ts │ │ ├── exports.ts │ │ ├── testing │ │ │ ├── simple-form-with-validation-config.stories.ts │ │ │ ├── simple-form-with-validation-options.stories.ts │ │ │ ├── simple-form.stories.ts │ │ │ └── simple-form.ts │ │ └── utils │ │ │ ├── array-to-object.spec.ts │ │ │ ├── array-to-object.ts │ │ │ ├── deep-partial.ts │ │ │ ├── deep-required.ts │ │ │ ├── form-utils.spec.ts │ │ │ ├── form-utils.ts │ │ │ ├── shape-validation.spec.ts │ │ │ └── shape-validation.ts │ ├── public-api.ts │ └── setup-jest.ts │ ├── tsconfig.lib.json │ ├── tsconfig.lib.prod.json │ └── tsconfig.spec.json ├── tailwind.config.js ├── tsconfig.json └── tsconfig.spec.json /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | '@commitlint/config-conventional' 4 | ], 5 | rules: { 6 | 'body-max-line-length': [0, 'always'], 7 | 'footer-max-line-length': [0, 'always'] 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | cd: 8 | concurrency: ci-${{ github.ref }} 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout ✅ 12 | uses: actions/checkout@v2 13 | with: 14 | persist-credentials: false 15 | - name: Setup 🏗 16 | uses: actions/setup-node@v2 17 | with: 18 | node-version: '20' 19 | cache: 'npm' 20 | - name: Install ⚙️ 21 | run: npm ci 22 | - name: Playwright install 23 | run: npx playwright install 24 | - name: Build 🛠 25 | run: npm run build:ci 26 | - name: Test 📋 27 | run: npm run test:ci 28 | - name: Storybook 29 | run: npm run storybook:build 30 | - name: Integration test 📋 31 | run: npm run test:storybook:ci 32 | - name: Publish 📢 33 | env: 34 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 36 | run: npx semantic-release 37 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | jobs: 7 | ci: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout ✅ 11 | uses: actions/checkout@v2 12 | - name: Setup 🏗 13 | uses: actions/setup-node@v2 14 | with: 15 | node-version: '20' 16 | cache: 'npm' 17 | - name: Install ⚙️ 18 | run: npm ci 19 | - name: Playwright install 20 | run: npx playwright install 21 | - name: Build 🛠 22 | run: npm run build:ci 23 | - name: Test 📋 24 | run: npm run test:ci 25 | - name: Storybook 26 | run: npm run storybook:build 27 | - name: Integration test 📋 28 | run: npm run test:storybook:ci 29 | -------------------------------------------------------------------------------- /.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 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/settings.json 26 | !.vscode/tasks.json 27 | !.vscode/launch.json 28 | !.vscode/extensions.json 29 | .history/* 30 | 31 | # Miscellaneous 32 | /.angular/cache 33 | .sass-cache/ 34 | /connect.lock 35 | /coverage 36 | /libpeerconnection.log 37 | testem.log 38 | /typings 39 | 40 | # System files 41 | .DS_Store 42 | Thumbs.db 43 | 44 | *storybook.log -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .angular 4 | .github 5 | .husky 6 | .vscode 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "printWidth": 80, 5 | "tabWidth": 2, 6 | "semi": true 7 | } 8 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "master" 4 | ], 5 | "plugins": [ 6 | "@semantic-release/release-notes-generator", 7 | "@semantic-release/changelog", 8 | [ 9 | "@semantic-release/npm", 10 | { 11 | "pkgRoot": "dist/ngx-vest-forms" 12 | } 13 | ], 14 | "@semantic-release/git", 15 | "@semantic-release/github" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846 3 | "recommendations": ["angular.ng-template"] 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 3 | "version": "0.2.0", 4 | "configurations": [ 5 | { 6 | "name": "ng serve", 7 | "type": "chrome", 8 | "request": "launch", 9 | "preLaunchTask": "npm: start", 10 | "url": "http://localhost:4200/" 11 | }, 12 | { 13 | "name": "ng test", 14 | "type": "chrome", 15 | "request": "launch", 16 | "preLaunchTask": "npm: test", 17 | "url": "http://localhost:9876/debug.html" 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 3 | "version": "2.0.0", 4 | "tasks": [ 5 | { 6 | "type": "npm", 7 | "script": "start", 8 | "isBackground": true, 9 | "problemMatcher": { 10 | "owner": "typescript", 11 | "pattern": "$tsc", 12 | "background": { 13 | "activeOnStart": true, 14 | "beginsPattern": { 15 | "regexp": "(.*?)" 16 | }, 17 | "endsPattern": { 18 | "regexp": "bundle generation complete" 19 | } 20 | } 21 | } 22 | }, 23 | { 24 | "type": "npm", 25 | "script": "test", 26 | "isBackground": true, 27 | "problemMatcher": { 28 | "owner": "typescript", 29 | "pattern": "$tsc", 30 | "background": { 31 | "activeOnStart": true, 32 | "beginsPattern": { 33 | "regexp": "(.*?)" 34 | }, 35 | "endsPattern": { 36 | "regexp": "bundle generation complete" 37 | } 38 | } 39 | } 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [1.1.0](https://github.com/simplifiedcourses/ngx-vest-forms/compare/v1.0.3...v1.1.0) (2024-07-17) 2 | 3 | 4 | ### Features 5 | 6 | * **validationOptions:** adds storybook test cases ([e535fa0](https://github.com/simplifiedcourses/ngx-vest-forms/commit/e535fa0da18764bdeba6e12911eeaaac90885a02)) 7 | * **validationOptions:** adds support for dynamic validation options ([e9ab6a0](https://github.com/simplifiedcourses/ngx-vest-forms/commit/e9ab6a0bd6ec1dbc182f40292863ebc87b63b913)) 8 | * **validationOptions:** adds support for dynamic validation options ([ceaa103](https://github.com/simplifiedcourses/ngx-vest-forms/commit/ceaa10300f3a6392655542369ef9d7d1faa6b856)) 9 | 10 | ## [1.0.3](https://github.com/simplifiedcourses/ngx-vest-forms/compare/v1.0.2...v1.0.3) (2024-06-19) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * **ngx-vest-forms:** fixed some bugs, cleaned up code, ci stuff ([ee9860d](https://github.com/simplifiedcourses/ngx-vest-forms/commit/ee9860de39d6d63b32523661164d2bc5cf807ffb)) 16 | 17 | ## [1.0.2](https://github.com/simplifiedcourses/ngx-vest-forms/compare/v1.0.1...v1.0.2) (2024-06-12) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * **form directive:** idle did not replayed the last value resulting in racing conditions ([430db01](https://github.com/simplifiedcourses/ngx-vest-forms/commit/430db017bf37efcb38458492359f6e69c6900202)) 23 | 24 | ## [1.0.1](https://github.com/simplifiedcourses/ngx-vest-forms/compare/v1.0.0...v1.0.1) (2024-06-12) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * **docs:** fixed the docs ([581ae63](https://github.com/simplifiedcourses/ngx-vest-forms/commit/581ae6314774f88775447fa9206cca87fa08a44e)) 30 | * **docs:** trigger deploy ([f9c8319](https://github.com/simplifiedcourses/ngx-vest-forms/commit/f9c83193c00d71ec74dd001eac5f07a6822bb973)) 31 | * renamed package ([afccdc2](https://github.com/simplifiedcourses/ngx-vest-forms/commit/afccdc239bd184d66591686ce0f01e1ad20b2b94)) 32 | 33 | ## [1.0.1](https://github.com/simplifiedcourses/ngx-vest-forms/compare/v1.0.0...v1.0.1) (2024-06-12) 34 | 35 | 36 | ### Bug Fixes 37 | 38 | * **docs:** fixed the docs ([581ae63](https://github.com/simplifiedcourses/ngx-vest-forms/commit/581ae6314774f88775447fa9206cca87fa08a44e)) 39 | * renamed package ([afccdc2](https://github.com/simplifiedcourses/ngx-vest-forms/commit/afccdc239bd184d66591686ce0f01e1ad20b2b94)) 40 | 41 | ## [1.0.1](https://github.com/simplifiedcourses/ngx-vest-forms/compare/v1.0.0...v1.0.1) (2024-06-12) 42 | 43 | 44 | ### Bug Fixes 45 | 46 | * renamed package ([afccdc2](https://github.com/simplifiedcourses/ngx-vest-forms/commit/afccdc239bd184d66591686ce0f01e1ad20b2b94)) 47 | 48 | # 1.0.0 (2024-06-11) 49 | 50 | 51 | ### Features 52 | 53 | * **node version:** update to node 20 ([8d7a20d](https://github.com/simplifiedcourses/ngx-vest-forms/commit/8d7a20d644f30ee016cbc4276b8dc78e890298d7)) 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ngx-vest-forms 2 | 3 | ### Introduction 4 | 5 | This is a very lightweight adapter for Angular template-driven forms and [vestjs](https://vestjs.dev). 6 | This package gives us the ability to create unidirectional forms without any boilerplate. 7 | It is meant for complex forms with a high focus on complex validations and conditionals. 8 | 9 | All the validations are asynchronous and use [vestjs](https://vestjs.dev) suites that can be re-used 10 | across different frameworks and technologies. 11 | 12 | ### Installation 13 | 14 | You can install the package by running: 15 | 16 | ```shell 17 | npm i ngx-vest-forms 18 | ``` 19 | 20 | ### Creating a simple form 21 | 22 | Let's start by explaining how to create a simple form. 23 | I want a form with a form group called `general` info that has 2 properties: 24 | - `firstName` 25 | - `lastName` 26 | 27 | We need to import the `vestForms` const in the imports section of the `@Component` decorator. 28 | Now we can apply the `scVestForm` directive to the `form` tag and listen to the `formValueChange` output to feed our signal. 29 | In the form we create a form group for `generalInfo` with the `ngModelGroup` directive. 30 | And we crate 2 inputs with the `name` attribute and the `[ngModel]` input. 31 | **Do note that we are not using the banana in the box syntax but only tha square brackets, resulting in a unidirectional dataflow** 32 | 33 | ```typescript 34 | import { vestForms, DeepPartial } from 'ngx-vest-forms'; 35 | 36 | // A form model is always deep partial because angular will create it over time organically 37 | type MyFormModel = DeepPartial<{ 38 | generalInfo: { 39 | firstName: string; 40 | lastName: string; 41 | } 42 | }> 43 | 44 | @Component({ 45 | imports: [vestForms], 46 | template: ` 47 |
50 |
51 | 52 | 53 | 54 | 55 | 56 |
57 |
58 | ` 59 | }) 60 | export class MyComponent { 61 | // This signal will hold the state of our form 62 | protected readonly formValue = signal({}); 63 | } 64 | ``` 65 | 66 | **Note: Template-driven forms are deep partial, so always use the `?` operator in your templates.** 67 | 68 | That's it! This will feed the `formValue` signal and angular will create a form group and 2 form controls for us automatically. 69 | The object that will be fed in the `formValue` signal will look like this: 70 | 71 | ```typescript 72 | formValue = { 73 | generalInfo: { 74 | firstName: '', 75 | lastName: '' 76 | } 77 | } 78 | ``` 79 | 80 | The ngForm will contain automatically created FormGroups and FormControls. 81 | This does not have anything to do with this package. It's just Angular: 82 | ```typescript 83 | form = { 84 | controls: { 85 | generalInformation: { // FormGroup 86 | controls: { 87 | firstName: {...}, // FormControl 88 | lastName: {...} //FormControl 89 | } 90 | } 91 | } 92 | } 93 | ``` 94 | 95 | The `scVestForm` directive offers some basic outputs for us though: 96 | 97 | | Output | Description | 98 | |----------------------|-----------------------------------------------------------------------------------------------------------------------------------------| 99 | | formValueChange | Emits when the form value changes. But debounces
the events since template-driven forms are created by the
framework over time | 100 | | dirtyChange | Emits when the dirty state of the form changes | 101 | | validChange | Emits when the form becomes dirty or pristine | 102 | | errorsChange | Emits an entire list of the form and all its form groups and controls | 103 | 104 | ### Avoiding typo's 105 | 106 | Template-driven forms are type-safe, but not in the `name` attributes or `ngModelGroup` attributes. 107 | Making a typo in those can result in a time-consuming endeavor. For this we have introduced shapes. 108 | A shape is an object where the `scVestForm` can validate to. It is a deep required of the form model: 109 | 110 | ```typescript 111 | import { DeepPartial, DeepRequired, vestForms } from 'ngx-vest-forms'; 112 | 113 | type MyFormModel = DeepPartial<{ 114 | generalInfo: { 115 | firstName: string; 116 | lastName: string; 117 | } 118 | }> 119 | 120 | export const myFormModelShape: DeepRequired = { 121 | generalInfo: { 122 | firstName: '', 123 | lastName: '' 124 | } 125 | }; 126 | 127 | @Component({ 128 | imports: [vestForms], 129 | template: ` 130 |
134 | 135 |
136 | 137 | 138 | 139 | 140 | 141 |
142 |
143 | ` 144 | }) 145 | export class MyComponent { 146 | protected readonly formValue = signal({}); 147 | protected readonly shape = myFormModelShape; 148 | } 149 | ``` 150 | 151 | By passing the shape to the `formShape` input the `scVestForm` will validate the actual form value 152 | against the form shape every time the form changes, but only when Angular is in devMode. 153 | 154 | Making a typo in the name attribute or an ngModelGroup attribute would result in runtime errors. 155 | The console would look like this: 156 | 157 | ```chatinput 158 | Error: Shape mismatch: 159 | 160 | [ngModel] Mismatch 'firstame' 161 | [ngModelGroup] Mismatch: 'addresses.billingddress' 162 | [ngModel] Mismatch 'addresses.billingddress.steet' 163 | [ngModel] Mismatch 'addresses.billingddress.number' 164 | [ngModel] Mismatch 'addresses.billingddress.city' 165 | [ngModel] Mismatch 'addresses.billingddress.zipcode' 166 | [ngModel] Mismatch 'addresses.billingddress.country' 167 | 168 | 169 | at validateShape (shape-validation.ts:28:19) 170 | at Object.next (form.directive.ts:178:17) 171 | at ConsumerObserver.next (Subscriber.js:91:33) 172 | at SafeSubscriber._next (Subscriber.js:60:26) 173 | at SafeSubscriber.next (Subscriber.js:31:18) 174 | at subscribe.innerSubscriber (switchMap.js:14:144) 175 | at OperatorSubscriber._next (OperatorSubscriber.js:13:21) 176 | at OperatorSubscriber.next (Subscriber.js:31:18) 177 | at map.js:7:24 178 | ``` 179 | 180 | ### Conditional fields 181 | 182 | What if we want to remove a form control or form group? With reactive forms that would require a lot of work 183 | but since Template driven forms do all the hard work for us, we can simply create a computed signal for that and 184 | bind that in the template. Having logic in the template is considered a bad practice, so we can do all 185 | the calculations in our class. 186 | 187 | Let's hide `lastName` if `firstName` is not filled in: 188 | 189 | ```html 190 |
191 | 192 | 193 | 194 | @if(lastNameAvailable()){ 195 | 196 | 197 | } 198 |
199 | ``` 200 | 201 | ```typescript 202 | class MyComponent { 203 | ... 204 | protected readonly lastNameAvailable = 205 | computed(() => !!this.formValue().generalInformation?.firstName); 206 | } 207 | ``` 208 | 209 | This will automatically add and remove the form control from our form model. 210 | This also works for a form group: 211 | 212 | ```html 213 | @if(showGeneralInfo()){ 214 |
215 | 216 | 217 | 218 | 219 | 220 |
221 | } 222 | ``` 223 | 224 | ### Reactive disabling 225 | 226 | To achieve reactive disabling, we just have to take advantage of computed signals as well: 227 | 228 | ```typescript 229 | class MyComponent { 230 | protected readonly lastNameDisabled = 231 | computed(() => !this.formValue().generalInformation?.firstName); 232 | } 233 | ``` 234 | 235 | We can bind the computed signal to the `disabled` directive of Angular. 236 | ```html 237 | 240 | ``` 241 | 242 | ### Validations 243 | 244 | The absolute gem in ngx-vest-forms is the flexibility in validations without writing any boilerplate. 245 | The only dependency this lib has is [vest.js](https://vestjs.dev). An awesome lightweight validation framework. 246 | You can use it on the backend/frontend/Angular/react etc... 247 | 248 | We use vest because it introduces the concept of vest suites. These are suites that kind of look like unit-tests 249 | but that are highly flexible: 250 | * [X] Write validations on forms 251 | * [X] Write validations on form groups 252 | * [X] Write validations on form controls 253 | * [X] Composable/reuse-able different validation suites 254 | * [X] Write conditional validations 255 | 256 | This is how you write a simple Vest suite: 257 | ```typescript 258 | import { enforce, only, staticSuite, test } from 'vest'; 259 | import { MyFormModel } from '../models/my-form.model' 260 | 261 | export const myFormModelSuite = staticSuite( 262 | (model: MyformModel, field?: string) => { 263 | if (field) { 264 | // Needed to not run every validation every time 265 | only(field); 266 | } 267 | test('firstName', 'First name is required', () => { 268 | enforce(model.firstName).isNotBlank(); 269 | }); 270 | test('lastName', 'Last name is required', () => { 271 | enforce(model.lastName).isNotBlank(); 272 | }); 273 | } 274 | ); 275 | }; 276 | ``` 277 | 278 | In the `test` function the first parameter is the field, the second is the validation error. 279 | The field is separated with the `.` syntax. So if we would have an `addresses` form group with an `billingAddress` form group inside 280 | and a form control `street` the field would be: `addresses.billingAddress.street`. 281 | 282 | This syntax should be self-explanatory and the entire enforcements guidelines can be found on [vest.js](https://vestjs.dev). 283 | 284 | Now let's connect this to our form. This is the biggest pain that ngx-vest-forms will fix for you: **Connecting Vest suites to Angular** 285 | 286 | ```typescript 287 | class MyComponent { 288 | protected readonly formValue = signal({}); 289 | protected readonly suite = myFormModelSuite; 290 | } 291 | ``` 292 | 293 | ```html 294 | 295 |
301 | ... 302 |
303 | ``` 304 | 305 | That's it. Validations are completely wired now. Because ngx-vest-forms will hook into the 306 | `[ngModel]` and `ngModelGroup` attributes, and create ngValidators automatically. 307 | 308 | It goes like this: 309 | - Control gets created, Angular recognizes the `ngModel` and `ngModelGroup` directives 310 | - These directives implement `AsyncValidator` and will connect to a vest suite 311 | - User types into control 312 | - The validate function gets called 313 | - Vest gets called for one field 314 | - Vest returns the errors 315 | - @simpilfied/forms puts those errors on the angular form control 316 | 317 | This means that `valid`, `invalid`, `errors`, `statusChanges` etc will keep on working 318 | just like it would with a regular angular form. 319 | 320 | #### Showing validation errors 321 | 322 | Now we want to show the validation errors in a consistent way. 323 | For that we have provided the `sc-control-wrapper` attribute component. 324 | 325 | You can use it on: 326 | - elements that hold `ngModelGroup` 327 | - elements that have an `ngModel` (or form control) inside of them. 328 | 329 | This will show errors automatically on: 330 | - form submit 331 | - blur 332 | 333 | **Note:** If those requirements don't fill your need, you can write a custom control-wrapper by copy-pasting the 334 | `control-wrapper` and adjusting the code. 335 | 336 | Let's update our form: 337 | 338 | ```html 339 | 340 |
341 |
342 | 344 |
345 | 346 |
347 | 348 | 349 |
350 |
351 | ``` 352 | 353 | This is the only thing we need to do to create a form that is completely wired with vest. 354 | * [x] Automatic creation of form controls and form groups 355 | * [x] Automatic connection to vest suites 356 | * [x] Automatic typo validation 357 | * [x] Automatic adding of css error classes and showing validation messages 358 | * [x] On blur 359 | * [x] On submit 360 | 361 | ### Conditional validations 362 | 363 | Vest makes it extremely easy to create conditional validations. 364 | Assume we have a form model that has `age` and `emergencyContact`. 365 | The `emergencyContact` is required, but only when the person is not of legal age. 366 | 367 | We can use the `omitWhen` so that when the person is below 18, the assertion 368 | will not be done. 369 | 370 | ```typescript 371 | import { enforce, omitWhen, only, staticSuite, test } from 'vest'; 372 | 373 | ... 374 | omitWhen((model.age || 0) >= 18, () => { 375 | test('emergencyContact', 'Emergency contact is required', () => { 376 | enforce(model.emergencyContact).isNotBlank(); 377 | }); 378 | }); 379 | ``` 380 | 381 | You can put those validations on every field that you want. On form group fields and on form control fields. 382 | Check this interesting example below: 383 | 384 | * [x] Password is always required 385 | * [x] Confirm password is only required when there is a password 386 | * [x] The passwords should match, but only when they are both filled in 387 | 388 | ```typescript 389 | test('passwords.password', 'Password is not filled in', () => { 390 | enforce(model.passwords?.password).isNotBlank(); 391 | }); 392 | omitWhen(!model.passwords?.password, () => { 393 | test('passwords.confirmPassword', 'Confirm password is not filled in', () => { 394 | enforce(model.passwords?.confirmPassword).isNotBlank(); 395 | }); 396 | }); 397 | omitWhen(!model.passwords?.password || !model.passwords?.confirmPassword, () => { 398 | test('passwords', 'Passwords do not match', () => { 399 | enforce(model.passwords?.confirmPassword).equals(model.passwords?.password); 400 | }); 401 | }); 402 | ``` 403 | 404 | Forget about manually adding, removing validators on reactive forms and not being able to 405 | re-use them. This code is easy to test, easy to re-use on frontend, backend, angular, react, etc... 406 | **Oh, it's also pretty readable** 407 | 408 | ### Composable validations 409 | 410 | We can compose validations suites with sub suites. After all, we want to re-use certain pieces of our 411 | validation logic and we don't want one huge unreadable suite. 412 | This is quite straightforward with Vest. 413 | 414 | Let's take this simple function that validates an address: 415 | 416 | ```typescript 417 | export function addressValidations(model: AddressModel | undefined, field: string): void { 418 | test(`${field}.street`, 'Street is required', () => { 419 | enforce(model?.street).isNotBlank(); 420 | }); 421 | test(`${field}.city`, 'City is required', () => { 422 | enforce(model?.city).isNotBlank(); 423 | }); 424 | test(`${field}.zipcode`, 'Zipcode is required', () => { 425 | enforce(model?.zipcode).isNotBlank(); 426 | }); 427 | test(`${field}.number`, 'Number is required', () => { 428 | enforce(model?.number).isNotBlank(); 429 | }); 430 | test(`${field}.country`, 'Country is required', () => { 431 | enforce(model?.country).isNotBlank(); 432 | }); 433 | } 434 | ``` 435 | 436 | Our suite would consume it like this: 437 | 438 | ```typescript 439 | import { enforce, omitWhen, only, staticSuite, test } from 'vest'; 440 | import { PurchaseFormModel } from '../models/purchaseFormModel'; 441 | 442 | export const mySuite = staticSuite( 443 | (model: PurchaseFormModel, field?: string) => { 444 | if (field) { 445 | only(field); 446 | } 447 | addressValidations(model.addresses?.billingAddress, 'addresses.billingAddress'); 448 | addressValidations(model.addresses?.shippingAddress, 'addresses.shippingAddress'); 449 | } 450 | ); 451 | ``` 452 | 453 | We achieved decoupling, readability and reuse of our addressValidations. 454 | 455 | #### A more complex example 456 | 457 | Let's combine the conditional part with the reusable part. 458 | We have 2 addresses, but the shippingAddress is only required when the `shippingAddressIsDifferentFromBillingAddress` 459 | Checkbox is checked. But if it is checked, all fields are required. 460 | And if both addresses are filled in, they should be different. 461 | 462 | This gives us validation on: 463 | * [x] The addresses form field (they can't be equal) 464 | * [x] The shipping Address field (only required when checkbox is checked) 465 | * [x] validation on all the address fields (street, number, etc) on both addresses 466 | 467 | ```typescript 468 | addressValidations( 469 | model.addresses?.billingAddress, 470 | 'addresses.billingAddress' 471 | ); 472 | omitWhen( 473 | !model.addresses?.shippingAddressDifferentFromBillingAddress, 474 | () => { 475 | addressValidations( 476 | model.addresses?.shippingAddress, 477 | 'addresses.shippingAddress' 478 | ); 479 | test('addresses', 'The addresses appear to be the same', () => { 480 | enforce(JSON.stringify(model.addresses?.billingAddress)).notEquals( 481 | JSON.stringify(model.addresses?.shippingAddress) 482 | ); 483 | }); 484 | } 485 | ); 486 | ``` 487 | 488 | ### Validation options 489 | 490 | The validation is triggered immediately when the input on the formModel changes. 491 | In some cases you want to debounce the input (e.g. if you make an api call in the validation suite). 492 | 493 | You can configure additional `validationOptions` at various levels like `form`, `ngModelGroup` or `ngModel`. 494 | 495 | ```html 496 | 497 |
500 | ... 501 |
502 | 503 | 505 |
506 | ... 507 |
508 | ``` 509 | 510 | 511 | ### Validations on the root form 512 | 513 | When we want to validate multiple fields that are depending on each other, 514 | it is a best practice to wrap them in a parent form group. 515 | If `password` and `confirmPassword` have to be equal the validation should not happen on 516 | `password` nor on `confirmPassword`, it should happen on `passwords`: 517 | 518 | ```typescript 519 | const form = { 520 | // validation happens here 521 | passwords: { 522 | password: '', 523 | confirmPassword: '' 524 | } 525 | }; 526 | ``` 527 | 528 | Sometimes we don't have the ability to create a form group for 2 depending fields, or sometimes we just 529 | want to create validation rules on portions of the form. For that we can use `validateRootForm`. 530 | Use the `errorsChange` output to keep the errors as state in a signal that we can use in the template 531 | wherever we want. 532 | 533 | ```html 534 | {{ errors()?.['rootForm'] }} 535 | {{ errors() }} 536 |
543 |
544 | ``` 545 | 546 | ```typescript 547 | export class MyformComponent { 548 | protected readonly formValue = signal({}); 549 | protected readonly suite = myFormModelSuite; 550 | // Keep the errors in state 551 | protected readonly errors = signal>({ }); 552 | } 553 | ``` 554 | 555 | When setting the `[validateRootForm]` directive to true, the form will 556 | also create an ngValidator on root level, that listens to the ROOT_FORM field. 557 | 558 | To make this work we need to use the field in the vest suite like this: 559 | 560 | ```typescript 561 | import { ROOT_FORM } from 'ngx-vest-forms'; 562 | 563 | test(ROOT_FORM, 'Brecht is not 30 anymore', () => { 564 | enforce( 565 | model.firstName === 'Brecht' && 566 | model.lastName === 'Billiet' && 567 | model.age === 30).isFalsy(); 568 | }); 569 | ``` 570 | 571 | 572 | 573 | ### Validation of dependant controls and or groups 574 | 575 | Sometimes, form validations are dependent on the values of other form controls or groups. 576 | This scenario is common when a field's validity relies on the input of another field. 577 | A typical example is the `confirmPassword` field, which should only be validated if the `password` field is filled in. 578 | When the `password` field value changes, it necessitates re-validating the `confirmPassword` field to ensure 579 | consistency. 580 | 581 | Here's how you can handle validation dependencies with ngx-vest-forms and vest.js: 582 | 583 | 584 | Use Vest to create a suite where you define the conditional validations. 585 | For example, the `confirmPassword` field should only be validated when the `password` field is not empty. 586 | Additionally, you need to ensure that both fields match. 587 | 588 | ```typescript 589 | import { enforce, omitWhen, staticSuite, test } from 'vest'; 590 | import { MyFormModel } from '../models/my-form.model'; 591 | 592 | export const myFormModelSuite = staticSuite((model: MyFormModel, field?: string) => { 593 | if (field) { 594 | only(field); 595 | } 596 | 597 | test('password', 'Password is required', () => { 598 | enforce(model.password).isNotBlank(); 599 | }); 600 | 601 | omitWhen(!model.password, () => { 602 | test('confirmPassword', 'Confirm password is required', () => { 603 | enforce(model.confirmPassword).isNotBlank(); 604 | }); 605 | }); 606 | 607 | omitWhen(!model.password || !model.confirmPassword, () => { 608 | test('passwords', 'Passwords do not match', () => { 609 | enforce(model.confirmPassword).equals(model.password); 610 | }); 611 | }); 612 | }); 613 | ``` 614 | 615 | Creating a validation config. 616 | The `scVestForm` has an input called `validationConfig`, that we can use to let the system know when to retrigger validations. 617 | 618 | ```typescript 619 | protected validationConfig = { 620 | password: ['passwords.confirmPassword'] 621 | } 622 | ``` 623 | Here we see that when password changes, it needs to update the field `passwords.confirmPassword`. 624 | This validationConfig is completely dynamic, and can also be used for form arrays. 625 | 626 | ```html 627 | 628 |
631 |
632 | 633 | 634 | 635 | 636 | 637 |
638 |
639 | ``` 640 | 641 | 642 | #### Form array validations 643 | 644 | An example can be found [in this simplified courses article](https://blog.simplified.courses/template-driven-forms-with-form-arrays/) 645 | There is also a complex example of form arrays with complex validations in the examples. 646 | 647 | 648 | ### Child form components 649 | 650 | Big forms result in big files. It makes sense to split them up. 651 | For instance an address form can be reused, so we want to create a child component for that. 652 | We have to make sure that this child component can access the ngForm. 653 | For that we have to use the `vestFormViewProviders` from `ngx-vest-forms` 654 | 655 | ```typescript 656 | ... 657 | import { vestForms, vestFormsViewProviders } from 'ngx-vest-forms'; 658 | 659 | @Component({ 660 | ... 661 | viewProviders: [vestFormsViewProviders] 662 | }) 663 | export class AddressComponent { 664 | @Input() address?: AddressModel; 665 | } 666 | ``` 667 | 668 | # Examples 669 | to check the examples, clone this repo and run: 670 | ```shell 671 | npm i 672 | npm start 673 | ``` 674 | 675 | There is an example of a complex form with a lot of conditionals and specifics, 676 | and there is an example of a form array with complex validations that is used to 677 | create a form to add business hours. A free tutorial will follow soon. 678 | 679 | 680 | You can check the examples in the github repo [here](https://github.com/simplifiedcourses/ngx-vest-forms/blob/master/projects/examples). 681 | [Here](https://stackblitz.com/~/github.com/simplifiedcourses/ngx-vest-forms-stackblitz){:target="_blank"} is a stackblitz example for you. 682 | It's filled with form complexities and also contains form array logic. 683 | 684 | ## Want to learn more? 685 | [![course.jpeg](course.jpeg)](https://www.simplified.courses/complex-angular-template-driven-forms) 686 | 687 | [This course](https://www.simplified.courses/complex-angular-template-driven-forms) teaches you to become a form expert in no time. 688 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "ngx-vest-forms": { 7 | "projectType": "library", 8 | "root": "projects/ngx-vest-forms", 9 | "sourceRoot": "projects/ngx-vest-forms/src", 10 | "prefix": "lib", 11 | "architect": { 12 | "build": { 13 | "builder": "@angular-devkit/build-angular:ng-packagr", 14 | "options": { 15 | "project": "projects/ngx-vest-forms/ng-package.json" 16 | }, 17 | "configurations": { 18 | "production": { 19 | "tsConfig": "projects/ngx-vest-forms/tsconfig.lib.prod.json" 20 | }, 21 | "development": { 22 | "tsConfig": "projects/ngx-vest-forms/tsconfig.lib.json" 23 | } 24 | }, 25 | "defaultConfiguration": "production" 26 | }, 27 | "test": { 28 | "builder": "@angular-devkit/build-angular:karma", 29 | "options": { 30 | "codeCoverage": true, 31 | "tsConfig": "projects/ngx-vest-forms/tsconfig.spec.json", 32 | "polyfills": [ 33 | "zone.js", 34 | "zone.js/testing" 35 | ], 36 | "karmaConfig": "projects/ngx-vest-forms/karma.conf.js" 37 | } 38 | }, 39 | "storybook": { 40 | "builder": "@storybook/angular:start-storybook", 41 | "options": { 42 | "styles": ["projects/ngx-vest-forms/.storybook/styles.scss"], 43 | "configDir": "projects/ngx-vest-forms/.storybook", 44 | "browserTarget": "ngx-vest-forms:build", 45 | "compodoc": true, 46 | "compodocArgs": [ 47 | "-e", 48 | "json", 49 | "-d", 50 | "projects/ngx-vest-forms" 51 | ], 52 | "port": 6006 53 | } 54 | }, 55 | "build-storybook": { 56 | "builder": "@storybook/angular:build-storybook", 57 | "options": { 58 | "configDir": "projects/ngx-vest-forms/.storybook", 59 | "browserTarget": "ngx-vest-forms:build", 60 | "compodoc": true, 61 | "compodocArgs": [ 62 | "-e", 63 | "json", 64 | "-d", 65 | "projects/ngx-vest-forms" 66 | ], 67 | "outputDir": "dist/storybook/ngx-vest-forms" 68 | } 69 | } 70 | } 71 | }, 72 | "examples": { 73 | "projectType": "application", 74 | "schematics": { 75 | "@schematics/angular:component": { 76 | "style": "scss" 77 | } 78 | }, 79 | "root": "projects/examples", 80 | "sourceRoot": "projects/examples/src", 81 | "prefix": "app", 82 | "architect": { 83 | "build": { 84 | "builder": "@angular-devkit/build-angular:browser", 85 | "options": { 86 | "outputPath": "dist/examples", 87 | "index": "projects/examples/src/index.html", 88 | "main": "projects/examples/src/main.ts", 89 | "polyfills": [ 90 | "zone.js" 91 | ], 92 | "tsConfig": "projects/examples/tsconfig.app.json", 93 | "inlineStyleLanguage": "scss", 94 | "assets": [ 95 | "projects/examples/src/favicon.ico", 96 | "projects/examples/src/assets" 97 | ], 98 | "styles": [ 99 | "projects/examples/src/styles.scss" 100 | ], 101 | "scripts": [] 102 | }, 103 | "configurations": { 104 | "production": { 105 | "budgets": [ 106 | { 107 | "type": "initial", 108 | "maximumWarning": "500kb", 109 | "maximumError": "1mb" 110 | }, 111 | { 112 | "type": "anyComponentStyle", 113 | "maximumWarning": "2kb", 114 | "maximumError": "4kb" 115 | } 116 | ], 117 | "outputHashing": "all" 118 | }, 119 | "development": { 120 | "buildOptimizer": false, 121 | "optimization": false, 122 | "vendorChunk": true, 123 | "extractLicenses": false, 124 | "sourceMap": true, 125 | "namedChunks": true 126 | } 127 | }, 128 | "defaultConfiguration": "production" 129 | }, 130 | "serve": { 131 | "builder": "@angular-devkit/build-angular:dev-server", 132 | "configurations": { 133 | "production": { 134 | "buildTarget": "examples:build:production" 135 | }, 136 | "development": { 137 | "buildTarget": "examples:build:development" 138 | } 139 | }, 140 | "defaultConfiguration": "development" 141 | }, 142 | "extract-i18n": { 143 | "builder": "@angular-devkit/build-angular:extract-i18n", 144 | "options": { 145 | "buildTarget": "examples:build" 146 | } 147 | }, 148 | "test": { 149 | "builder": "@angular-devkit/build-angular:karma", 150 | "options": { 151 | "polyfills": [ 152 | "zone.js", 153 | "zone.js/testing" 154 | ], 155 | "tsConfig": "projects/examples/tsconfig.spec.json", 156 | "inlineStyleLanguage": "scss", 157 | "assets": [ 158 | "projects/examples/src/favicon.ico", 159 | "projects/examples/src/assets" 160 | ], 161 | "styles": [ 162 | "projects/examples/src/styles.scss" 163 | ], 164 | "scripts": [], 165 | "karmaConfig": "projects/examples/karma.conf.js" 166 | } 167 | } 168 | } 169 | } 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /course.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplifiedcourses/ngx-vest-forms/e4ee6ee5ef2a5edd427f73da682d229f03d47725/course.jpeg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simplified", 3 | "version": "0.0.0-development", 4 | "license": "MIT", 5 | "scripts": { 6 | "ng": "ng", 7 | "start": "ng serve", 8 | "api": "json-server projects/examples/src/backend/db.json", 9 | "build": "ng build", 10 | "watch": "ng build --watch --configuration development", 11 | "test": "jest", 12 | "prepare": "husky install", 13 | "build:app": "ng build examples --configuration=production --base-href=/ngx-vest-forms/", 14 | "build:lib": "ng build ngx-vest-forms --configuration=production", 15 | "build:ci": "npm run build:lib && npm run build:app", 16 | "postbuild:lib": "copyfiles README.md dist/ngx-vest-forms", 17 | "test:lib": "jest", 18 | "storybook:build": "npx ng run ngx-vest-forms:build-storybook", 19 | "test:storybook:ci": "npx run-p --race test:storybook preview-storybook", 20 | "test:storybook": "wait-on http://127.0.0.1:6006 && test-storybook -c projects/ngx-vest-forms/.storybook --url http://127.0.0.1:6006 --", 21 | "preview-storybook": "http-server dist/storybook/ngx-vest-forms --port 6006 --silent", 22 | "test:ci": "npm run test:lib", 23 | "semantic-release": "semantic-release", 24 | "prettier:check": "prettier --check \"projects/**/*.{ts,js,json,css,scss,html}\"", 25 | "prettier:format": "prettier --write \"projects/**/*.{ts,js,json,css,scss,html}\"" 26 | }, 27 | "private": false, 28 | "dependencies": { 29 | "@angular/animations": "^18.0.1", 30 | "@angular/common": "^18.0.1", 31 | "@angular/compiler": "^18.0.1", 32 | "@angular/core": "^18.0.1", 33 | "@angular/forms": "^18.0.1", 34 | "@angular/platform-browser": "^18.0.1", 35 | "@angular/platform-browser-dynamic": "^18.0.1", 36 | "@angular/router": "^18.0.1", 37 | "@storybook/jest": "^0.2.3", 38 | "rxjs": "~7.8.0", 39 | "tslib": "^2.3.0", 40 | "vest": "^5.2.8", 41 | "zone.js": "~0.14.6" 42 | }, 43 | "devDependencies": { 44 | "@angular-devkit/build-angular": "^18.0.2", 45 | "@angular-eslint/builder": "^18.0.1", 46 | "@angular-eslint/schematics": "^18.0.1", 47 | "@angular/cli": "~18.0.2", 48 | "@angular/compiler-cli": "^18.0.1", 49 | "@chromatic-com/storybook": "^1.5.0", 50 | "@commitlint/cli": "^18.2.0", 51 | "@commitlint/config-conventional": "^18.1.0", 52 | "@compodoc/compodoc": "^1.1.25", 53 | "@semantic-release/changelog": "^6.0.3", 54 | "@semantic-release/git": "^10.0.1", 55 | "@storybook/addon-coverage": "^1.0.4", 56 | "@storybook/addon-docs": "^8.1.6", 57 | "@storybook/addon-essentials": "^8.1.6", 58 | "@storybook/addon-interactions": "^8.1.10", 59 | "@storybook/addon-links": "^8.1.6", 60 | "@storybook/angular": "^8.1.6", 61 | "@storybook/blocks": "^8.1.6", 62 | "@storybook/test": "^8.1.10", 63 | "@storybook/test-runner": "^0.18.2", 64 | "@tailwindcss/forms": "^0.5.7", 65 | "@types/jasmine": "~4.3.0", 66 | "@types/jest": "^29.5.12", 67 | "autoprefixer": "^10.4.19", 68 | "concurrently": "^7.5.0", 69 | "copyfiles": "^2.4.1", 70 | "eslint-config-prettier": "^9.1.0", 71 | "eslint-plugin-prettier": "^5.1.3", 72 | "http-server": "^14.1.1", 73 | "husky": "^8.0.3", 74 | "jasmine-core": "~4.6.0", 75 | "jest": "^29.7.0", 76 | "jest-preset-angular": "^14.1.0", 77 | "json-server": "^0.17.0", 78 | "karma": "~6.4.0", 79 | "karma-chrome-launcher": "~3.2.0", 80 | "karma-coverage": "~2.2.0", 81 | "karma-jasmine": "~5.1.0", 82 | "karma-jasmine-html-reporter": "~2.0.0", 83 | "ng-packagr": "^18.0.0", 84 | "ngx-mask": "^17.0.8", 85 | "npm-run-all": "^4.1.5", 86 | "playwright": "^1.44.1", 87 | "postcss": "^8.4.38", 88 | "semantic-release": "^22.0.5", 89 | "semantic-release-cli": "^5.4.4", 90 | "storybook": "^8.1.6", 91 | "tailwindcss": "^3.4.3", 92 | "typescript": "~5.4.5", 93 | "wait-on": "^6.0.1" 94 | }, 95 | "repository": { 96 | "type": "git", 97 | "url": "https://github.com/simplifiedcourses/ngx-vest-forms.git" 98 | }, 99 | "jest": { 100 | "preset": "jest-preset-angular", 101 | "setupFilesAfterEnv": [ 102 | "./projects/ngx-vest-forms/src/setup-jest.ts" 103 | ], 104 | "globalSetup": "jest-preset-angular/global-setup" 105 | }, 106 | "publishConfig": { 107 | "access": "public" 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /projects/examples/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 | jasmine: { 17 | // you can add configuration options for Jasmine here 18 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 19 | // for example, you can disable the random execution with `random: false` 20 | // or set a specific seed with `seed: 4321` 21 | }, 22 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 23 | }, 24 | jasmineHtmlReporter: { 25 | suppressAll: true, // removes the duplicated traces 26 | }, 27 | coverageReporter: { 28 | dir: require('path').join(__dirname, '../../coverage/examples'), 29 | subdir: '.', 30 | reporters: [{ type: 'html' }, { type: 'text-summary' }], 31 | }, 32 | reporters: ['progress', 'kjhtml'], 33 | browsers: ['Chrome', 'ChromeHeadlessCI'], 34 | customLaunchers: { 35 | ChromeHeadlessCI: { 36 | base: 'ChromeHeadless', 37 | flags: ['--no-sandbox'], 38 | }, 39 | }, 40 | restartOnFileChange: true, 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /projects/examples/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | -------------------------------------------------------------------------------- /projects/examples/src/app/app.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplifiedcourses/ngx-vest-forms/e4ee6ee5ef2a5edd427f73da682d229f03d47725/projects/examples/src/app/app.component.scss -------------------------------------------------------------------------------- /projects/examples/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { RouterLink, RouterOutlet } from '@angular/router'; 3 | import { PurchaseFormComponent } from './components/smart/purchase-form/purchase-form.component'; 4 | 5 | @Component({ 6 | selector: 'app-root', 7 | imports: [PurchaseFormComponent, RouterLink, RouterOutlet], 8 | standalone: true, 9 | templateUrl: './app.component.html', 10 | styleUrls: ['./app.component.scss'], 11 | }) 12 | export class AppComponent { 13 | title = 'purchase'; 14 | } 15 | -------------------------------------------------------------------------------- /projects/examples/src/app/components/smart/business-hours-form/business-hours-form.component.html: -------------------------------------------------------------------------------- 1 |
4 |

5 | Example: form array with complex validations 6 |

7 |
    8 |
  • Always show an error if there is no business hour added
  • 9 |
  • From field is required
  • 10 |
  • To field is required
  • 11 |
  • From field should be valid time
  • 12 |
  • To field should be valid time
  • 13 |
  • 14 | If both from and to fields are valid: 15 |
      16 |
    • To field should be later than from field
    • 17 |
    18 |
  • 19 |
  • The same should apply for edit
  • 20 |
  • If more than 1 business hour is added, there should be no overlap
  • 21 |
22 |
23 | 24 |
35 | {{ errors()?.[ROOT_FORM] }} 39 |
40 | 43 |
44 |
45 |
46 |

Valid: {{ formValid() }}

47 | 48 |

The value of the form

49 |
50 |   {{ formValue() | json }}
51 | 
52 | 53 |
54 |   {{ errors() | json }}
55 | 
56 | -------------------------------------------------------------------------------- /projects/examples/src/app/components/smart/business-hours-form/business-hours-form.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | @apply flex flex-col max-w-screen-lg mx-auto py-8 px-4 lg:py-16; 3 | } 4 | -------------------------------------------------------------------------------- /projects/examples/src/app/components/smart/business-hours-form/business-hours-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, signal } from '@angular/core'; 2 | import { JsonPipe } from '@angular/common'; 3 | import { 4 | ValidateRootFormDirective, 5 | ROOT_FORM, 6 | vestForms, 7 | } from 'ngx-vest-forms'; 8 | import { 9 | BusinessHoursFormModel, 10 | businessHoursFormShape, 11 | } from '../../../models/business-hours-form.model'; 12 | import { businessHoursSuite } from '../../../validations/business-hours.validations'; 13 | import { BusinessHourComponent } from '../../ui/business-hour/business-hour.component'; 14 | import { BusinessHoursComponent } from '../../ui/business-hours/business-hours.component'; 15 | 16 | @Component({ 17 | selector: 'sc-business-hours-form', 18 | standalone: true, 19 | imports: [ 20 | JsonPipe, 21 | vestForms, 22 | ValidateRootFormDirective, 23 | BusinessHourComponent, 24 | BusinessHoursComponent, 25 | ], 26 | templateUrl: './business-hours-form.component.html', 27 | styleUrls: ['./business-hours-form.component.scss'], 28 | }) 29 | export class BusinessHoursFormComponent { 30 | protected readonly formValue = signal({}); 31 | protected readonly formValid = signal(false); 32 | protected readonly errors = signal>({}); 33 | protected readonly suite = businessHoursSuite; 34 | protected readonly shape = businessHoursFormShape; 35 | protected readonly ROOT_FORM = ROOT_FORM; 36 | } 37 | -------------------------------------------------------------------------------- /projects/examples/src/app/components/smart/purchase-form/purchase-form.component.html: -------------------------------------------------------------------------------- 1 |
4 |

5 | Example: complex form with comples validations and conditionals 6 |

7 |
    8 |
  • Show validation errors on blur
  • 9 |
  • Show validation errors on submit
  • 10 |
  • When first name is Brecht: Set gender to male
  • 11 |
  • 12 | When first name is Brecht and last name is 13 | Billiet: Set age and passwords 14 |
  • 15 |
  • 16 | When first name is Luke: Fetch 17 | Luke Skywalker from the swapi api 18 |
  • 19 |
  • When age is below 18, make Emergency contact required
  • 20 |
  • When age is of legal age, disable Emergency contact
  • 21 |
  • There should be at least one phone number
  • 22 |
  • Phone numbers should not be empty
  • 23 |
  • When gender is other, show Specify gender
  • 24 |
  • When gender is other, make Specify gender required
  • 25 |
  • Password is required
  • 26 |
  • Confirm password is only required when password is filled in
  • 27 |
  • Passwords should match, but only check if both are filled in
  • 28 |
  • Billing address is required
  • 29 |
  • Show shipping address only when needed (otherwise remove from DOM)
  • 30 |
  • 31 | If shipping address is different from billing address, make it required 32 |
  • 33 |
  • 34 | If shipping address is different from billing address, make sure they are 35 | not the same 36 |
  • 37 |
  • 38 | When providing shipping address and toggling the checkbox back and forth, 39 | make sure the state is kept 40 |
  • 41 |
  • 42 | When clicking the Fetch data button, load data, disable the form, and 43 | patch and re-enable the form 44 |
  • 45 |
  • When the user id is taken, perform async validation
  • 46 |
  • 47 | Try to set the firstName to Brecht lastName to Billiet, Now change the age 48 | to 30. It shows how we can do validations on the root 49 |
  • 50 |
51 | 52 |
53 |
54 | 55 |
{{ vm.errors?.['rootForm'] }}
56 | 57 | @if (vm.errors?.['rootForm']) { 58 | 64 | } 65 | 66 |
80 |
81 |

Purchase form

82 |
83 | 93 |
94 |
95 | 104 |
105 |
106 | 115 |
116 |
117 | 126 |
127 |
128 | 138 |
139 |
140 | 141 | 142 |
143 |
146 |
147 | 148 |
149 |
150 | 157 | 158 |
159 |
160 | 167 | 168 |
169 |
170 | 177 | 178 |
179 |
180 |
181 | @if (vm.showGenderOther) { 182 |
183 | 191 |
192 | } 193 |
194 | 195 |
196 |
197 |
198 | 207 |
208 |
209 | 218 |
219 |
220 |
221 |
222 | 230 |
231 |
232 |
233 |

Billing address

234 | 237 |
238 | 248 | @if (vm.showShippingAddress) { 249 |
250 |

Shipping Address

251 | 252 |
253 | } 254 |
255 |
256 |   257 | 258 |
259 |
260 |
261 |
262 |

Valid: {{ vm.formValid }}

263 | 264 |

The value of the form

265 |
266 |   {{ vm.formValue | json }}
267 | 
268 | -------------------------------------------------------------------------------- /projects/examples/src/app/components/smart/purchase-form/purchase-form.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | @apply flex flex-col max-w-screen-lg mx-auto py-8 px-4 lg:py-16; 3 | } 4 | -------------------------------------------------------------------------------- /projects/examples/src/app/components/smart/purchase-form/purchase-form.component.ts: -------------------------------------------------------------------------------- 1 | import { ValidateRootFormDirective, vestForms } from 'ngx-vest-forms'; 2 | import { Component, computed, effect, inject, signal } from '@angular/core'; 3 | import { JsonPipe } from '@angular/common'; 4 | import { AddressComponent } from '../../ui/address/address.component'; 5 | import { PhonenumbersComponent } from '../../ui/phonenumbers/phonenumbers.component'; 6 | import { LukeService } from '../../../luke.service'; 7 | import { SwapiService } from '../../../swapi.service'; 8 | import { ProductService } from '../../../product.service'; 9 | import { toObservable, toSignal } from '@angular/core/rxjs-interop'; 10 | import { 11 | PurchaseFormModel, 12 | purchaseFormShape, 13 | } from '../../../models/purchase-form.model'; 14 | import { createPurchaseValidationSuite } from '../../../validations/purchase.validations'; 15 | import { AddressModel } from '../../../models/address.model'; 16 | import { debounceTime, filter, switchMap } from 'rxjs'; 17 | 18 | @Component({ 19 | selector: 'sc-purchase-form', 20 | standalone: true, 21 | imports: [ 22 | JsonPipe, 23 | vestForms, 24 | AddressComponent, 25 | PhonenumbersComponent, 26 | ValidateRootFormDirective, 27 | ], 28 | templateUrl: './purchase-form.component.html', 29 | styleUrls: ['./purchase-form.component.scss'], 30 | }) 31 | export class PurchaseFormComponent { 32 | private readonly lukeService = inject(LukeService); 33 | private readonly swapiService = inject(SwapiService); 34 | private readonly productService = inject(ProductService); 35 | public readonly products = toSignal(this.productService.getAll()); 36 | protected readonly formValue = signal({}); 37 | protected readonly formValid = signal(false); 38 | protected readonly loading = signal(false); 39 | protected readonly errors = signal>({}); 40 | protected readonly suite = createPurchaseValidationSuite(this.swapiService); 41 | private readonly shippingAddress = signal({}); 42 | protected readonly shape = purchaseFormShape; 43 | private readonly viewModel = computed(() => { 44 | return { 45 | formValue: this.formValue(), 46 | errors: this.errors(), 47 | formValid: this.formValid(), 48 | emergencyContactDisabled: (this.formValue().age || 0) >= 18, 49 | showShippingAddress: 50 | this.formValue().addresses?.shippingAddressDifferentFromBillingAddress, 51 | showGenderOther: this.formValue().gender === 'other', 52 | // Take shipping address from the state 53 | shippingAddress: 54 | this.formValue().addresses?.shippingAddress || this.shippingAddress(), 55 | loading: this.loading(), 56 | }; 57 | }); 58 | 59 | protected readonly validationConfig: { 60 | [key: string]: string[]; 61 | } = { 62 | age: ['emergencyContact'], 63 | 'passwords.password': ['passwords.confirmPassword'], 64 | gender: ['genderOther'], 65 | }; 66 | 67 | constructor() { 68 | const firstName = computed(() => this.formValue().firstName); 69 | const lastName = computed(() => this.formValue().lastName); 70 | effect( 71 | () => { 72 | // If the first name is Brecht, update the gender to male 73 | if (firstName() === 'Brecht') { 74 | this.formValue.update((val) => ({ 75 | ...val, 76 | gender: 'male', 77 | })); 78 | } 79 | 80 | // If the first name is Brecht and the last name is Billiet, set the age and passwords 81 | if (firstName() === 'Brecht' && lastName() === 'Billiet') { 82 | this.formValue.update((val) => ({ 83 | ...val, 84 | age: 35, 85 | passwords: { 86 | password: 'Test1234', 87 | confirmPassword: 'Test12345', 88 | }, 89 | })); 90 | } 91 | }, 92 | { allowSignalWrites: true } 93 | ); 94 | 95 | // When firstName is Luke, fetch luke skywalker and update the form value 96 | toObservable(firstName) 97 | .pipe( 98 | debounceTime(1000), 99 | filter((v) => v === 'Luke'), 100 | switchMap(() => this.lukeService.getLuke()) 101 | ) 102 | .subscribe((luke) => { 103 | this.formValue.update((v) => ({ ...v, ...luke })); 104 | }); 105 | } 106 | 107 | protected setFormValue(v: PurchaseFormModel): void { 108 | this.formValue.set(v); 109 | 110 | // Keep shipping address in the state 111 | if (v.addresses?.shippingAddress) { 112 | this.shippingAddress.set(v.addresses.shippingAddress); 113 | } 114 | } 115 | 116 | protected get vm() { 117 | return this.viewModel(); 118 | } 119 | 120 | protected onSubmit(): void { 121 | if (this.formValid()) { 122 | console.log(this.formValue()); 123 | } 124 | } 125 | 126 | protected fetchData() { 127 | this.loading.set(true); 128 | this.lukeService.getLuke().subscribe((luke) => { 129 | this.formValue.update((v) => ({ ...v, ...luke })); 130 | this.loading.set(false); 131 | }); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /projects/examples/src/app/components/ui/address/address.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 12 |
13 |
14 | 23 |
24 |
25 | 34 |
35 |
36 | 45 |
46 |
47 | 56 |
57 |
58 | -------------------------------------------------------------------------------- /projects/examples/src/app/components/ui/address/address.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplifiedcourses/ngx-vest-forms/e4ee6ee5ef2a5edd427f73da682d229f03d47725/projects/examples/src/app/components/ui/address/address.component.scss -------------------------------------------------------------------------------- /projects/examples/src/app/components/ui/address/address.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | 3 | import { vestForms, vestFormsViewProviders } from 'ngx-vest-forms'; 4 | import { AddressModel } from '../../../models/address.model'; 5 | 6 | @Component({ 7 | selector: 'sc-address', 8 | standalone: true, 9 | imports: [vestForms], 10 | viewProviders: [vestFormsViewProviders], 11 | templateUrl: './address.component.html', 12 | styleUrls: ['./address.component.scss'], 13 | }) 14 | export class AddressComponent { 15 | @Input() address?: AddressModel; 16 | } 17 | -------------------------------------------------------------------------------- /projects/examples/src/app/components/ui/business-hour/business-hour.component.html: -------------------------------------------------------------------------------- 1 |
2 | 12 |
13 |
14 | 24 |
25 | -------------------------------------------------------------------------------- /projects/examples/src/app/components/ui/business-hour/business-hour.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | @apply flex; 3 | label { 4 | @apply flex items-center; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /projects/examples/src/app/components/ui/business-hour/business-hour.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { CommonModule, KeyValuePipe } from '@angular/common'; 3 | import { vestForms, vestFormsViewProviders } from 'ngx-vest-forms'; 4 | import { BusinessHourFormModel } from '../../../models/business-hours-form.model'; 5 | import { NgxMaskDirective } from 'ngx-mask'; 6 | 7 | @Component({ 8 | selector: 'sc-business-hour', 9 | standalone: true, 10 | imports: [CommonModule, vestForms, KeyValuePipe, NgxMaskDirective], 11 | templateUrl: './business-hour.component.html', 12 | styleUrls: ['./business-hour.component.scss'], 13 | viewProviders: [vestFormsViewProviders], 14 | }) 15 | export class BusinessHourComponent { 16 | @Input() public businessHour?: BusinessHourFormModel = {}; 17 | } 18 | -------------------------------------------------------------------------------- /projects/examples/src/app/components/ui/business-hours/business-hours.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | @for (item of businessHoursModel?.values | keyvalue; track item.key) { 4 |
5 |
6 | 9 | 16 |
17 |
18 | } 19 |
20 |
21 |
22 |
23 | 26 | 34 |
35 |
36 |
37 | -------------------------------------------------------------------------------- /projects/examples/src/app/components/ui/business-hours/business-hours.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplifiedcourses/ngx-vest-forms/e4ee6ee5ef2a5edd427f73da682d229f03d47725/projects/examples/src/app/components/ui/business-hours/business-hours.component.scss -------------------------------------------------------------------------------- /projects/examples/src/app/components/ui/business-hours/business-hours.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { CommonModule, KeyValuePipe } from '@angular/common'; 3 | import { 4 | arrayToObject, 5 | DeepPartial, 6 | vestForms, 7 | vestFormsViewProviders, 8 | } from 'ngx-vest-forms'; 9 | import { BusinessHourFormModel } from '../../../models/business-hours-form.model'; 10 | import { BusinessHourComponent } from '../business-hour/business-hour.component'; 11 | import { NgModelGroup } from '@angular/forms'; 12 | 13 | @Component({ 14 | selector: 'sc-business-hours', 15 | standalone: true, 16 | imports: [CommonModule, vestForms, KeyValuePipe, BusinessHourComponent], 17 | templateUrl: './business-hours.component.html', 18 | styleUrls: ['./business-hours.component.scss'], 19 | viewProviders: [vestFormsViewProviders], 20 | }) 21 | export class BusinessHoursComponent { 22 | @Input() public businessHoursModel?: DeepPartial<{ 23 | addValue: BusinessHourFormModel; 24 | values: { 25 | [key: string]: BusinessHourFormModel; 26 | }; 27 | }> = {}; 28 | 29 | public addBusinessHour(group: NgModelGroup): void { 30 | if (!this.businessHoursModel?.values) { 31 | return; 32 | } 33 | group.control.markAsUntouched(); 34 | this.businessHoursModel.values = arrayToObject([ 35 | ...Object.values(this.businessHoursModel.values), 36 | this.businessHoursModel.addValue, 37 | ]); 38 | this.businessHoursModel.addValue = undefined; 39 | } 40 | 41 | public removeBusinessHour(key: string): void { 42 | if (!this.businessHoursModel?.values) { 43 | return; 44 | } 45 | const businessHours = Object.values(this.businessHoursModel.values).filter( 46 | (v, index) => index !== Number(key) 47 | ); 48 | this.businessHoursModel.values = arrayToObject(businessHours); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /projects/examples/src/app/components/ui/phonenumbers/phonenumbers.component.html: -------------------------------------------------------------------------------- 1 | 42 | -------------------------------------------------------------------------------- /projects/examples/src/app/components/ui/phonenumbers/phonenumbers.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplifiedcourses/ngx-vest-forms/e4ee6ee5ef2a5edd427f73da682d229f03d47725/projects/examples/src/app/components/ui/phonenumbers/phonenumbers.component.scss -------------------------------------------------------------------------------- /projects/examples/src/app/components/ui/phonenumbers/phonenumbers.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { CommonModule, KeyValuePipe } from '@angular/common'; 3 | import { 4 | arrayToObject, 5 | vestForms, 6 | vestFormsViewProviders, 7 | } from 'ngx-vest-forms'; 8 | 9 | @Component({ 10 | selector: 'sc-phonenumbers', 11 | standalone: true, 12 | imports: [CommonModule, vestForms, KeyValuePipe], 13 | templateUrl: './phonenumbers.component.html', 14 | styleUrls: ['./phonenumbers.component.scss'], 15 | viewProviders: [vestFormsViewProviders], 16 | }) 17 | export class PhonenumbersComponent { 18 | @Input() public values: { [key: string]: string } = {}; 19 | public addValue = ''; 20 | 21 | public addPhonenumber(): void { 22 | const phoneNumbers = [...Object.values(this.values), this.addValue]; 23 | this.values = arrayToObject(phoneNumbers); 24 | this.addValue = ''; 25 | } 26 | 27 | public removePhonenumber(key: string): void { 28 | const phonenumbers = Object.values(this.values).filter( 29 | (v, index) => index !== Number(key) 30 | ); 31 | this.values = arrayToObject(phonenumbers); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /projects/examples/src/app/luke.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { map, Observable } from 'rxjs'; 4 | 5 | @Injectable({ providedIn: 'root' }) 6 | export class LukeService { 7 | private readonly httpClient = inject(HttpClient); 8 | 9 | public getLuke(): Observable<{ 10 | firstName: string; 11 | lastName: string; 12 | gender: 'male' | 'female' | 'other'; 13 | }> { 14 | return this.httpClient.get('https://swapi.dev/api/people/1').pipe( 15 | map((resp: any) => { 16 | const name = resp.name.split(' '); 17 | return { 18 | firstName: name[0], 19 | lastName: name[1], 20 | gender: resp.gender, 21 | }; 22 | }) 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /projects/examples/src/app/models/address.model.ts: -------------------------------------------------------------------------------- 1 | import { DeepRequired, DeepPartial } from 'ngx-vest-forms'; 2 | 3 | export type AddressModel = DeepPartial<{ 4 | street: string; 5 | number: string; 6 | city: string; 7 | zipcode: string; 8 | country: string; 9 | }>; 10 | export const addressShape: DeepRequired = { 11 | street: '', 12 | number: '', 13 | city: '', 14 | zipcode: '', 15 | country: '', 16 | }; 17 | -------------------------------------------------------------------------------- /projects/examples/src/app/models/business-hours-form.model.ts: -------------------------------------------------------------------------------- 1 | import { DeepPartial, DeepRequired } from 'ngx-vest-forms'; 2 | 3 | export type BusinessHoursFormModel = DeepPartial<{ 4 | businessHours: { 5 | addValue: BusinessHourFormModel; 6 | values: { [key: string]: BusinessHourFormModel }; 7 | }; 8 | }>; 9 | 10 | export type BusinessHourFormModel = DeepPartial<{ 11 | from: string; 12 | to: string; 13 | }>; 14 | 15 | export const businesssHourFormShape: DeepRequired = { 16 | from: '00:00', 17 | to: '00:00', 18 | }; 19 | 20 | export const businessHoursFormShape: DeepRequired = { 21 | businessHours: { 22 | addValue: { ...businesssHourFormShape }, 23 | values: { 24 | '0': { ...businesssHourFormShape }, 25 | }, 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /projects/examples/src/app/models/phonenumber.model.ts: -------------------------------------------------------------------------------- 1 | import { DeepRequired } from 'ngx-vest-forms'; 2 | 3 | export type PhonenumberModel = Partial<{ 4 | addValue: string; 5 | values: { [key: string]: string }; 6 | }>; 7 | export const phonenumberShape: DeepRequired = { 8 | addValue: '', 9 | values: { 10 | '0': '', 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /projects/examples/src/app/models/purchase-form.model.ts: -------------------------------------------------------------------------------- 1 | import { AddressModel, addressShape } from './address.model'; 2 | import { PhonenumberModel, phonenumberShape } from './phonenumber.model'; 3 | import { DeepPartial, DeepRequired } from 'ngx-vest-forms'; 4 | 5 | export type PurchaseFormModel = DeepPartial<{ 6 | userId: string; 7 | firstName: string; 8 | lastName: string; 9 | age: number; 10 | emergencyContact: string; 11 | passwords: { 12 | password: string; 13 | confirmPassword?: string; 14 | }; 15 | phonenumbers: PhonenumberModel; 16 | gender: 'male' | 'female' | 'other'; 17 | genderOther: string; 18 | productId: string; 19 | addresses: { 20 | shippingAddress: AddressModel; 21 | billingAddress: AddressModel; 22 | shippingAddressDifferentFromBillingAddress: boolean; 23 | }; 24 | }>; 25 | 26 | export const purchaseFormShape: DeepRequired = { 27 | userId: '', 28 | firstName: '', 29 | lastName: '', 30 | age: 0, 31 | emergencyContact: '', 32 | addresses: { 33 | shippingAddress: addressShape, 34 | billingAddress: addressShape, 35 | shippingAddressDifferentFromBillingAddress: true, 36 | }, 37 | passwords: { 38 | password: '', 39 | confirmPassword: '', 40 | }, 41 | phonenumbers: phonenumberShape, 42 | gender: 'other', 43 | genderOther: '', 44 | productId: '', 45 | }; 46 | -------------------------------------------------------------------------------- /projects/examples/src/app/product.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Product } from './product.type'; 3 | import { delay, Observable, of } from 'rxjs'; 4 | 5 | @Injectable({ 6 | providedIn: 'root', 7 | }) 8 | export class ProductService { 9 | public getAll(): Observable { 10 | return of([ 11 | { id: '0', name: 'Iphone x' }, 12 | { id: '1', name: 'Iphone 11' }, 13 | { id: '2', name: 'Iphone 12' }, 14 | { id: '3', name: 'Iphone 13' }, 15 | ]).pipe(delay(1000)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /projects/examples/src/app/product.type.ts: -------------------------------------------------------------------------------- 1 | export type Product = { 2 | name: string; 3 | id: string; 4 | }; 5 | -------------------------------------------------------------------------------- /projects/examples/src/app/swapi.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { catchError, map, Observable, of } from 'rxjs'; 4 | 5 | @Injectable({ providedIn: 'root' }) 6 | export class SwapiService { 7 | private readonly httpClient = inject(HttpClient); 8 | public userIdExists(id: string): Observable { 9 | return this.httpClient.get(`https://swapi.dev/api/people/${id}`).pipe( 10 | map(() => true), 11 | catchError(() => of(false)) 12 | ); 13 | } 14 | public searchUserById(id: string): Observable { 15 | return this.httpClient.get(`https://swapi.dev/api/people/${id}`); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /projects/examples/src/app/validations/address.validations.ts: -------------------------------------------------------------------------------- 1 | import { AddressModel } from '../models/address.model'; 2 | import { enforce, test } from 'vest'; 3 | 4 | export function addressValidations( 5 | model: AddressModel | undefined, 6 | field: string 7 | ): void { 8 | test(`${field}.street`, 'Street is required', () => { 9 | enforce(model?.street).isNotBlank(); 10 | }); 11 | test(`${field}.city`, 'City is required', () => { 12 | enforce(model?.city).isNotBlank(); 13 | }); 14 | test(`${field}.zipcode`, 'Zipcode is required', () => { 15 | enforce(model?.zipcode).isNotBlank(); 16 | }); 17 | test(`${field}.number`, 'Number is required', () => { 18 | enforce(model?.number).isNotBlank(); 19 | }); 20 | test(`${field}.country`, 'Country is required', () => { 21 | enforce(model?.country).isNotBlank(); 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /projects/examples/src/app/validations/business-hours.validations.ts: -------------------------------------------------------------------------------- 1 | import { each, enforce, omitWhen, only, staticSuite, test } from 'vest'; 2 | import { 3 | BusinessHourFormModel, 4 | BusinessHoursFormModel, 5 | } from '../models/business-hours-form.model'; 6 | import { ROOT_FORM } from 'ngx-vest-forms'; 7 | 8 | export const businessHoursSuite = staticSuite( 9 | (model: BusinessHoursFormModel, field?: string) => { 10 | if (field) { 11 | only(field); 12 | } 13 | const values = model.businessHours?.values 14 | ? Object.values(model.businessHours.values) 15 | : []; 16 | 17 | test(ROOT_FORM, 'You should have at least one business hour', () => { 18 | enforce((values?.length || 0) > 0).isTruthy(); 19 | }); 20 | omitWhen(values?.length < 2, () => { 21 | test( 22 | `businessHours.values`, 23 | 'There should be no overlap between business hours', 24 | () => { 25 | enforce( 26 | areBusinessHoursValid(values as BusinessHourFormModel[]) 27 | ).isTruthy(); 28 | } 29 | ); 30 | }); 31 | each(values, (businessHour, index) => { 32 | validateBusinessHourModel( 33 | `businessHours.values.${index}`, 34 | model.businessHours?.values?.[index] 35 | ); 36 | }); 37 | validateBusinessHourModel( 38 | 'businessHours.addValue', 39 | model.businessHours?.addValue 40 | ); 41 | } 42 | ); 43 | 44 | function validateBusinessHourModel( 45 | field: string, 46 | model?: BusinessHourFormModel 47 | ) { 48 | test(`${field}.to`, 'Required', () => { 49 | enforce(model?.to).isNotBlank(); 50 | }); 51 | test(`${field}.from`, 'Required', () => { 52 | enforce(model?.from).isNotBlank(); 53 | }); 54 | test(`${field}.from`, 'Should be a valid time', () => { 55 | enforce(isValidTime(model?.from)).isTruthy(); 56 | }); 57 | test(`${field}.to`, 'Should be a valid time', () => { 58 | enforce(isValidTime(model?.to)).isTruthy(); 59 | }); 60 | omitWhen( 61 | () => !isValidTime(model?.from) || !isValidTime(model?.to), 62 | () => { 63 | test(field, 'The from should be earlier than the to', () => { 64 | const fromFirst = Number(model?.from?.slice(0, 2)); 65 | const fromSecond = Number(model?.from?.slice(2, 4)); 66 | const toFirst = Number(model?.to?.slice(0, 2)); 67 | const toSecond = Number(model?.to?.slice(2, 4)); 68 | const from = `${fromFirst}:${fromSecond}`; 69 | const to = `${toFirst}:${toSecond}`; 70 | enforce(isFromEarlierThanTo(from, to)).isTruthy(); 71 | }); 72 | } 73 | ); 74 | } 75 | 76 | function areBusinessHoursValid( 77 | businessHours?: BusinessHourFormModel[] 78 | ): boolean { 79 | if (!businessHours) { 80 | return false; 81 | } 82 | for (let i = 0; i < businessHours.length - 1; i++) { 83 | const currentHour = businessHours[i]; 84 | const nextHour = businessHours[i + 1]; 85 | 86 | if ( 87 | !isValidTime(currentHour.from) || 88 | !isValidTime(currentHour.to) || 89 | !isValidTime(nextHour.from) || 90 | !isValidTime(nextHour.to) 91 | ) { 92 | return false; 93 | } 94 | 95 | if (!isFromEarlierThanTo(currentHour?.from, currentHour?.to)) { 96 | return false; 97 | } 98 | 99 | if (!isFromEarlierThanTo(currentHour.to, nextHour.from)) { 100 | return false; 101 | } 102 | } 103 | 104 | const lastHour = businessHours[businessHours.length - 1]; 105 | return ( 106 | isValidTime(lastHour.from) && 107 | isValidTime(lastHour.to) && 108 | isFromEarlierThanTo(lastHour.from, lastHour.to) 109 | ); 110 | } 111 | 112 | function timeStrToMinutes(time?: string): number { 113 | if (!time) { 114 | return 0; 115 | } 116 | const hours = Number(time?.slice(0, 2)); 117 | const minutes = Number(time?.slice(2, 4)); 118 | return hours * 60 + minutes; 119 | } 120 | 121 | function isValidTime(time?: string): boolean { 122 | let valid = false; 123 | if (time?.length === 4) { 124 | const first = Number(time?.slice(0, 2)); 125 | const second = Number(time?.slice(2, 4)); 126 | if ( 127 | typeof first === 'number' && 128 | typeof second === 'number' && 129 | first < 24 && 130 | second < 60 131 | ) { 132 | valid = true; 133 | } 134 | } 135 | return valid; 136 | } 137 | 138 | function isFromEarlierThanTo(from?: string, to?: string) { 139 | if (!from || !to) { 140 | return false; 141 | } 142 | // Split the "from" and "to" strings into hours and minutes 143 | let [fromHours, fromMinutes] = from.split(':').map(Number); 144 | let [toHours, toMinutes] = to.split(':').map(Number); 145 | 146 | // Check if the "from" time is earlier than the "to" time 147 | if (fromHours < toHours) { 148 | return true; 149 | } else if (fromHours === toHours) { 150 | return fromMinutes < toMinutes; 151 | } else { 152 | return false; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /projects/examples/src/app/validations/phonenumber.validations.ts: -------------------------------------------------------------------------------- 1 | import { PhonenumberModel } from '../models/phonenumber.model'; 2 | import { each, enforce, test } from 'vest'; 3 | 4 | export function phonenumberValidations( 5 | model: PhonenumberModel | undefined, 6 | field: string 7 | ): void { 8 | const phonenumbers = model?.values ? Object.values(model.values) : []; 9 | 10 | test(`${field}`, 'You should have at least one phonenumber', () => { 11 | enforce(phonenumbers.length).greaterThan(0); 12 | }); 13 | each(phonenumbers, (phonenumber, index) => { 14 | test(`${field}.values.${index}`, 'Should be a valid phonenumber', () => { 15 | enforce(phonenumber).isNotBlank(); 16 | }); 17 | }); 18 | } 19 | -------------------------------------------------------------------------------- /projects/examples/src/app/validations/purchase.validations.ts: -------------------------------------------------------------------------------- 1 | import { enforce, omitWhen, only, staticSuite, test } from 'vest'; 2 | import { PurchaseFormModel } from '../models/purchase-form.model'; 3 | import { addressValidations } from './address.validations'; 4 | import { phonenumberValidations } from './phonenumber.validations'; 5 | import { SwapiService } from '../swapi.service'; 6 | import { fromEvent, lastValueFrom, takeUntil } from 'rxjs'; 7 | import { ROOT_FORM } from 'ngx-vest-forms'; 8 | 9 | export const createPurchaseValidationSuite = (swapiService: SwapiService) => { 10 | return staticSuite((model: PurchaseFormModel, field?: string) => { 11 | if (field) { 12 | only(field); 13 | } 14 | test(ROOT_FORM, 'Brecht is not 30 anymore', () => { 15 | enforce( 16 | model.firstName === 'Brecht' && 17 | model.lastName === 'Billiet' && 18 | model.age === 30 19 | ).isFalsy(); 20 | }); 21 | 22 | omitWhen(!model.userId, () => { 23 | test('userId', 'userId is already taken', async ({ signal }) => { 24 | await lastValueFrom( 25 | swapiService 26 | .searchUserById(model.userId as string) 27 | .pipe(takeUntil(fromEvent(signal, 'abort'))) 28 | ).then( 29 | () => Promise.reject(), 30 | () => Promise.resolve() 31 | ); 32 | }); 33 | }); 34 | 35 | test('firstName', 'First name is required', () => { 36 | enforce(model.firstName).isNotBlank(); 37 | }); 38 | test('lastName', 'Last name is required', () => { 39 | enforce(model.lastName).isNotBlank(); 40 | }); 41 | test('age', 'Age is required', () => { 42 | enforce(model.age).isNotBlank(); 43 | }); 44 | omitWhen((model.age || 0) >= 18, () => { 45 | test('emergencyContact', 'Emergency contact is required', () => { 46 | enforce(model.emergencyContact).isNotBlank(); 47 | }); 48 | }); 49 | test('gender', 'Gender is required', () => { 50 | enforce(model.gender).isNotBlank(); 51 | }); 52 | omitWhen(model.gender !== 'other', () => { 53 | test( 54 | 'genderOther', 55 | 'If gender is other, you have to specify the gender', 56 | () => { 57 | enforce(model.genderOther).isNotBlank(); 58 | } 59 | ); 60 | }); 61 | test('productId', 'Product is required', () => { 62 | enforce(model.productId).isNotBlank(); 63 | }); 64 | addressValidations( 65 | model.addresses?.billingAddress, 66 | 'addresses.billingAddress' 67 | ); 68 | omitWhen( 69 | !model.addresses?.shippingAddressDifferentFromBillingAddress, 70 | () => { 71 | addressValidations( 72 | model.addresses?.shippingAddress, 73 | 'addresses.shippingAddress' 74 | ); 75 | test('addresses', 'The addresses appear to be the same', () => { 76 | enforce(JSON.stringify(model.addresses?.billingAddress)).notEquals( 77 | JSON.stringify(model.addresses?.shippingAddress) 78 | ); 79 | }); 80 | } 81 | ); 82 | test('passwords.password', 'Password is not filled in', () => { 83 | enforce(model.passwords?.password).isNotBlank(); 84 | }); 85 | omitWhen(!model.passwords?.password, () => { 86 | test( 87 | 'passwords.confirmPassword', 88 | 'Confirm password is not filled in', 89 | () => { 90 | enforce(model.passwords?.confirmPassword).isNotBlank(); 91 | } 92 | ); 93 | }); 94 | omitWhen( 95 | !model.passwords?.password || !model.passwords?.confirmPassword, 96 | () => { 97 | test('passwords', 'Passwords do not match', () => { 98 | enforce(model.passwords?.confirmPassword).equals( 99 | model.passwords?.password 100 | ); 101 | }); 102 | } 103 | ); 104 | phonenumberValidations(model?.phonenumbers, 'phonenumbers'); 105 | }); 106 | }; 107 | -------------------------------------------------------------------------------- /projects/examples/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplifiedcourses/ngx-vest-forms/e4ee6ee5ef2a5edd427f73da682d229f03d47725/projects/examples/src/assets/.gitkeep -------------------------------------------------------------------------------- /projects/examples/src/assets/course.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplifiedcourses/ngx-vest-forms/e4ee6ee5ef2a5edd427f73da682d229f03d47725/projects/examples/src/assets/course.jpeg -------------------------------------------------------------------------------- /projects/examples/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplifiedcourses/ngx-vest-forms/e4ee6ee5ef2a5edd427f73da682d229f03d47725/projects/examples/src/favicon.ico -------------------------------------------------------------------------------- /projects/examples/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Examples 6 | 7 | 8 | 9 | 16 | 17 | 18 | 44 | 45 | Become extremely productive at angular forms course 51 | 52 | 53 | 109 | 110 | -------------------------------------------------------------------------------- /projects/examples/src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | import { AppComponent } from './app/app.component'; 3 | import { 4 | provideRouter, 5 | Routes, 6 | withEnabledBlockingInitialNavigation, 7 | } from '@angular/router'; 8 | import { provideHttpClient } from '@angular/common/http'; 9 | import { PurchaseFormComponent } from './app/components/smart/purchase-form/purchase-form.component'; 10 | import { BusinessHoursFormComponent } from './app/components/smart/business-hours-form/business-hours-form.component'; 11 | import { provideEnvironmentNgxMask } from 'ngx-mask'; 12 | 13 | const appRoutes: Routes = [ 14 | { 15 | path: '', 16 | redirectTo: 'purchase', 17 | pathMatch: 'full', 18 | }, 19 | { 20 | path: 'purchase', 21 | component: PurchaseFormComponent, 22 | }, 23 | { 24 | path: 'business-hours', 25 | component: BusinessHoursFormComponent, 26 | }, 27 | ]; 28 | bootstrapApplication(AppComponent, { 29 | providers: [ 30 | provideHttpClient(), 31 | provideEnvironmentNgxMask({ validation: false }), 32 | provideRouter(appRoutes, withEnabledBlockingInitialNavigation()), 33 | ], 34 | }); 35 | -------------------------------------------------------------------------------- /projects/examples/src/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | /* Add application styles & imports to this file! */ 3 | /* Add application styles & imports to this file! */ 4 | @tailwind base; 5 | @tailwind components; 6 | @tailwind utilities; 7 | 8 | body { 9 | @apply bg-white dark:bg-gray-900 text-gray-900 dark:text-white; 10 | } 11 | button { 12 | @apply text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800; 13 | } 14 | button:disabled { 15 | @apply bg-gray-400 text-gray-700 cursor-not-allowed dark:bg-gray-600 dark:text-gray-400; 16 | } 17 | fieldset { 18 | @apply grid gap-4 sm:grid-cols-2 sm:gap-6; 19 | 20 | label span { 21 | @apply block mb-2 text-sm font-medium text-gray-900 dark:text-white; 22 | } 23 | 24 | .sc-control-wrapper { 25 | select, 26 | input[type='text'], 27 | input[type='number'], 28 | input[type='password'] { 29 | @apply bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-4 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500; 30 | } 31 | 32 | &--invalid { 33 | label span { 34 | @apply block mb-2 text-sm font-medium text-red-700 dark:text-red-500; 35 | } 36 | 37 | select, 38 | input[type='text'], 39 | input[type='number'], 40 | input[type='password'] { 41 | @apply bg-red-50 border border-red-500 text-red-900 placeholder-red-700 text-sm rounded-lg focus:ring-red-500 dark:bg-gray-700 focus:border-red-500 block w-full dark:text-red-500 dark:placeholder-red-500 dark:border-red-500; 42 | } 43 | } 44 | 45 | &__errors { 46 | @apply mt-2 text-sm text-red-600 dark:text-red-500; 47 | } 48 | 49 | .radio-button { 50 | @apply flex items-center mb-4; 51 | label { 52 | @apply block ms-2 text-sm font-medium text-gray-900 dark:text-gray-300; 53 | } 54 | 55 | input[type='radio'] { 56 | @apply w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600; 57 | } 58 | } 59 | input[type='checkbox'] { 60 | @apply w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /projects/examples/src/variables.scss: -------------------------------------------------------------------------------- 1 | $shoppie__gridunit: 8px; 2 | $shoppie-color__light-grey: #f1f1f1; 3 | $shoppie-color__grey: #ccc; 4 | $shoppie-color__accent: #267373; 5 | $shoppie-color__white: #fff; 6 | -------------------------------------------------------------------------------- /projects/examples/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/app", 6 | "types": [] 7 | }, 8 | "files": ["src/main.ts"], 9 | "include": ["src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /projects/examples/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/spec", 6 | "types": ["jasmine"] 7 | }, 8 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/angular'; 2 | 3 | const config: StorybookConfig = { 4 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 5 | addons: [ 6 | '@storybook/addon-links', 7 | '@storybook/addon-essentials', 8 | '@chromatic-com/storybook', 9 | '@storybook/addon-interactions', 10 | '@storybook/addon-coverage', 11 | ], 12 | framework: { 13 | name: '@storybook/angular', 14 | options: {}, 15 | }, 16 | }; 17 | export default config; 18 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/angular'; 2 | import { setCompodocJson } from '@storybook/addon-docs/angular'; 3 | import docJson from '../documentation.json'; 4 | setCompodocJson(docJson); 5 | 6 | const preview: Preview = { 7 | parameters: { 8 | controls: { 9 | matchers: { 10 | color: /(background|color)$/i, 11 | date: /Date$/i, 12 | }, 13 | }, 14 | }, 15 | }; 16 | 17 | export default preview; 18 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/.storybook/styles.scss: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | /* Add application styles & imports to this file! */ 3 | /* Add application styles & imports to this file! */ 4 | @tailwind base; 5 | @tailwind components; 6 | @tailwind utilities; 7 | 8 | body { 9 | @apply bg-white dark:bg-gray-900 text-gray-900 dark:text-white; 10 | } 11 | button { 12 | @apply text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 me-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800; 13 | } 14 | fieldset { 15 | @apply grid gap-4 sm:grid-cols-2 sm:gap-6; 16 | 17 | label span { 18 | @apply block mb-2 text-sm font-medium text-gray-900 dark:text-white; 19 | } 20 | 21 | .sc-control-wrapper { 22 | select, 23 | input[type='text'], 24 | input[type='number'], 25 | input[type='password'] { 26 | @apply bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-4 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-primary-500 dark:focus:border-primary-500; 27 | } 28 | 29 | &--invalid { 30 | label span { 31 | @apply block mb-2 text-sm font-medium text-red-700 dark:text-red-500; 32 | } 33 | 34 | select, 35 | input[type='text'], 36 | input[type='number'], 37 | input[type='password'] { 38 | @apply bg-red-50 border border-red-500 text-red-900 placeholder-red-700 text-sm rounded-lg focus:ring-red-500 dark:bg-gray-700 focus:border-red-500 block w-full dark:text-red-500 dark:placeholder-red-500 dark:border-red-500; 39 | } 40 | } 41 | 42 | &__errors { 43 | @apply mt-2 text-sm text-red-600 dark:text-red-500; 44 | } 45 | 46 | .radio-button { 47 | @apply flex items-center mb-4; 48 | label { 49 | @apply block ms-2 text-sm font-medium text-gray-900 dark:text-gray-300; 50 | } 51 | 52 | input[type='radio'] { 53 | @apply w-4 h-4 border-gray-300 focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600 dark:focus:bg-blue-600 dark:bg-gray-700 dark:border-gray-600; 54 | } 55 | } 56 | input[type='checkbox'] { 57 | @apply w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 dark:focus:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/.storybook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.lib.json", 3 | "compilerOptions": { 4 | "types": ["node"], 5 | "allowSyntheticDefaultImports": true, 6 | "resolveJsonModule": true 7 | }, 8 | "exclude": ["../src/test.ts", "../src/**/*.spec.ts"], 9 | "include": ["../src/**/*.stories.*", "./preview.ts"], 10 | "files": ["./typings.d.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/.storybook/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.md' { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/ng-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", 3 | "dest": "../../dist/ngx-vest-forms", 4 | "lib": { 5 | "entryFile": "src/public-api.ts" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ngx-vest-forms", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "author": "Brecht Billiet", 6 | "description": "Opinionated template-driven forms library for Angular", 7 | "peerDependencies": { 8 | "@angular/core": ">=18.0.0", 9 | "@angular/common": ">=18.0.0", 10 | "rxjs": ">=7.8.0", 11 | "vest": ">=5.2.8" 12 | }, 13 | "dependencies": { 14 | "tslib": "^2.3.0" 15 | }, 16 | "keywords": [ 17 | "Angular", 18 | "Forms", 19 | "Signals" 20 | ], 21 | "repository": "https://github.com/simplifiedcourses/ngx-vest-forms", 22 | "sideEffects": false 23 | } 24 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/components/control-wrapper/control-wrapper.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 |
    7 | @for (error of errors; track error) { 8 |
  • {{ error }}
  • 9 | } 10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/components/control-wrapper/control-wrapper.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplifiedcourses/ngx-vest-forms/e4ee6ee5ef2a5edd427f73da682d229f03d47725/projects/ngx-vest-forms/src/lib/components/control-wrapper/control-wrapper.component.scss -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/components/control-wrapper/control-wrapper.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AfterViewInit, 3 | ChangeDetectionStrategy, 4 | ChangeDetectorRef, 5 | Component, 6 | ContentChild, 7 | HostBinding, 8 | inject, 9 | OnDestroy, 10 | } from '@angular/core'; 11 | 12 | import { AbstractControl, NgModel, NgModelGroup } from '@angular/forms'; 13 | import { mergeWith, of, Subject, switchMap, takeUntil } from 'rxjs'; 14 | import { FormDirective } from '../../directives/form.directive'; 15 | 16 | @Component({ 17 | selector: '[sc-control-wrapper]', 18 | standalone: true, 19 | templateUrl: './control-wrapper.component.html', 20 | styleUrls: ['./control-wrapper.component.scss'], 21 | changeDetection: ChangeDetectionStrategy.OnPush, 22 | }) 23 | export class ControlWrapperComponent implements AfterViewInit, OnDestroy { 24 | @ContentChild(NgModel) public ngModel?: NgModel; // Optional ngModel 25 | public readonly ngModelGroup: NgModelGroup | null = inject(NgModelGroup, { 26 | optional: true, 27 | self: true, 28 | }); 29 | private readonly destroy$$ = new Subject(); 30 | private readonly cdRef = inject(ChangeDetectorRef); 31 | private readonly formDirective = inject(FormDirective); 32 | // Cache the previous error to avoid 'flickering' 33 | private previousError?: string[]; 34 | 35 | @HostBinding('class.sc-control-wrapper--invalid') 36 | public get invalid() { 37 | return this.control?.touched && this.errors; 38 | } 39 | 40 | public get errors(): string[] | undefined { 41 | if (this.control?.pending) { 42 | return this.previousError; 43 | } else { 44 | this.previousError = this.control?.errors?.['errors']; 45 | } 46 | return this.control?.errors?.['errors']; 47 | } 48 | 49 | private get control(): AbstractControl | undefined { 50 | return this.ngModelGroup 51 | ? this.ngModelGroup.control 52 | : this.ngModel?.control; 53 | } 54 | 55 | public ngOnDestroy(): void { 56 | this.destroy$$.next(); 57 | } 58 | 59 | public ngAfterViewInit(): void { 60 | // Wait until the form is idle 61 | // Then, listen to all events of the ngModelGroup or ngModel 62 | // and mark the component and its ancestors as dirty 63 | // This allows us to use the OnPush ChangeDetection Strategy 64 | this.formDirective.idle$ 65 | .pipe( 66 | switchMap(() => this.ngModelGroup?.control?.events || of(null)), 67 | mergeWith(this.control?.events || of(null)), 68 | takeUntil(this.destroy$$) 69 | ) 70 | .subscribe(() => { 71 | this.cdRef.markForCheck(); 72 | }); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const ROOT_FORM = 'rootForm'; 2 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/directives/form-model-group.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, inject, input } from '@angular/core'; 2 | import { 3 | AbstractControl, 4 | AsyncValidator, 5 | NG_ASYNC_VALIDATORS, 6 | ValidationErrors, 7 | } from '@angular/forms'; 8 | import { FormDirective } from './form.directive'; 9 | import { Observable } from 'rxjs'; 10 | import { getFormGroupField } from '../utils/form-utils'; 11 | import { ValidationOptions } from './validation-options'; 12 | 13 | /** 14 | * Hooks into the ngModelGroup selector and triggers an asynchronous validation for a form group 15 | * It will use a vest suite behind the scenes 16 | */ 17 | @Directive({ 18 | selector: '[ngModelGroup]', 19 | standalone: true, 20 | providers: [ 21 | { 22 | provide: NG_ASYNC_VALIDATORS, 23 | useExisting: FormModelGroupDirective, 24 | multi: true, 25 | }, 26 | ], 27 | }) 28 | export class FormModelGroupDirective implements AsyncValidator { 29 | public validationOptions = input({ debounceTime: 0 }); 30 | private readonly formDirective = inject(FormDirective); 31 | 32 | public validate( 33 | control: AbstractControl 34 | ): Observable { 35 | const { ngForm } = this.formDirective; 36 | const field = getFormGroupField(ngForm.control, control); 37 | return this.formDirective.createAsyncValidator(field, this.validationOptions())( 38 | control.value 39 | ) as Observable; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/directives/form-model.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, inject, input } from '@angular/core'; 2 | import { 3 | AbstractControl, 4 | AsyncValidator, 5 | NG_ASYNC_VALIDATORS, 6 | ValidationErrors, 7 | } from '@angular/forms'; 8 | import { FormDirective } from './form.directive'; 9 | import { Observable } from 'rxjs'; 10 | import { getFormControlField } from '../utils/form-utils'; 11 | import { ValidationOptions } from './validation-options'; 12 | 13 | /** 14 | * Hooks into the ngModel selector and triggers an asynchronous validation for a form model 15 | * It will use a vest suite behind the scenes 16 | */ 17 | @Directive({ 18 | selector: '[ngModel]', 19 | standalone: true, 20 | providers: [ 21 | { 22 | provide: NG_ASYNC_VALIDATORS, 23 | useExisting: FormModelDirective, 24 | multi: true, 25 | }, 26 | ], 27 | }) 28 | export class FormModelDirective implements AsyncValidator { 29 | public validationOptions = input({ debounceTime: 0 }); 30 | private readonly formDirective = inject(FormDirective); 31 | 32 | public validate( 33 | control: AbstractControl 34 | ): Observable { 35 | const { ngForm, suite, formValue } = this.formDirective; 36 | const field = getFormControlField(ngForm.control, control); 37 | return this.formDirective.createAsyncValidator(field, this.validationOptions())( 38 | control.getRawValue() 39 | ) as Observable; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/directives/form.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, inject, input, OnDestroy, Output } from '@angular/core'; 2 | import { 3 | AsyncValidatorFn, 4 | NgForm, 5 | PristineChangeEvent, 6 | StatusChangeEvent, 7 | ValidationErrors, 8 | ValueChangeEvent, 9 | } from '@angular/forms'; 10 | import { 11 | debounceTime, 12 | distinctUntilChanged, 13 | filter, 14 | map, 15 | Observable, 16 | of, 17 | ReplaySubject, 18 | startWith, 19 | Subject, 20 | switchMap, 21 | take, 22 | takeUntil, 23 | tap, 24 | zip, 25 | } from 'rxjs'; 26 | import { StaticSuite } from 'vest'; 27 | import { toObservable } from '@angular/core/rxjs-interop'; 28 | import { DeepRequired } from '../utils/deep-required'; 29 | import { 30 | cloneDeep, 31 | getAllFormErrors, 32 | mergeValuesAndRawValues, 33 | set, 34 | } from '../utils/form-utils'; 35 | import { validateShape } from '../utils/shape-validation'; 36 | import { ValidationOptions } from './validation-options'; 37 | 38 | @Directive({ 39 | selector: 'form[scVestForm]', 40 | standalone: true, 41 | }) 42 | export class FormDirective> implements OnDestroy { 43 | public readonly ngForm = inject(NgForm, { self: true, optional: false }); 44 | 45 | /** 46 | * The value of the form, this is needed for the validation part 47 | */ 48 | public readonly formValue = input(null); 49 | 50 | /** 51 | * Static vest suite that will be used to feed our angular validators 52 | */ 53 | public readonly suite = input void 57 | > | null>(null); 58 | 59 | /** 60 | * The shape of our form model. This is a deep required version of the form model 61 | * The goal is to add default values to the shape so when the template-driven form 62 | * contains values that shouldn't be there (typo's) that the developer gets run-time 63 | * errors in dev mode 64 | */ 65 | public readonly formShape = input | null>(null); 66 | 67 | /** 68 | * Updates the validation config which is a dynamic object that will be used to 69 | * trigger validations on the dependant fields 70 | * Eg: ```typescript 71 | * validationConfig = { 72 | * 'passwords.password': ['passwords.confirmPassword'] 73 | * } 74 | * ``` 75 | * 76 | * This will trigger the updateValueAndValidity on passwords.confirmPassword every time the passwords.password gets a new value 77 | * 78 | * @param v 79 | */ 80 | public readonly validationConfig = input<{ [key: string]: string[] } | null>( 81 | null 82 | ); 83 | 84 | private readonly pending$ = this.ngForm.form.events.pipe( 85 | filter((v) => v instanceof StatusChangeEvent), 86 | map((v) => (v as StatusChangeEvent).status), 87 | filter((v) => v === 'PENDING'), 88 | distinctUntilChanged() 89 | ); 90 | 91 | /** 92 | * Emits every time the form status changes in a state 93 | * that is not PENDING 94 | * We need this to assure that the form is in 'idle' state 95 | */ 96 | public readonly idle$ = this.ngForm.form.events.pipe( 97 | filter((v) => v instanceof StatusChangeEvent), 98 | map((v) => (v as StatusChangeEvent).status), 99 | filter((v) => v !== 'PENDING'), 100 | distinctUntilChanged() 101 | ); 102 | 103 | /** 104 | * Triggered as soon as the form value changes 105 | * Also every time Angular creates a new control or group 106 | * It also contains the disabled values (raw values) 107 | */ 108 | @Output() public readonly formValueChange = this.ngForm.form.events.pipe( 109 | filter((v) => v instanceof ValueChangeEvent), 110 | map((v) => (v as ValueChangeEvent).value), 111 | map(() => mergeValuesAndRawValues(this.ngForm.form)) 112 | ); 113 | 114 | /** 115 | * Emits an object with all the errors of the form 116 | * every time a form control or form groups changes its status to valid or invalid 117 | */ 118 | @Output() public readonly errorsChange = this.ngForm.form.events.pipe( 119 | filter((v) => v instanceof StatusChangeEvent), 120 | map((v) => (v as StatusChangeEvent).status), 121 | filter((v) => v !== 'PENDING'), 122 | map(() => getAllFormErrors(this.ngForm.form)) 123 | ); 124 | 125 | /** 126 | * Triggered as soon as the form becomes dirty 127 | */ 128 | @Output() public readonly dirtyChange = this.ngForm.form.events.pipe( 129 | filter((v) => v instanceof PristineChangeEvent), 130 | map((v) => !(v as PristineChangeEvent).pristine), 131 | startWith(this.ngForm.form.dirty), 132 | distinctUntilChanged() 133 | ); 134 | private readonly destroy$$ = new Subject(); 135 | 136 | /** 137 | * Fired when the status of the root form changes. 138 | */ 139 | private readonly statusChanges$ = this.ngForm.form.statusChanges.pipe( 140 | startWith(this.ngForm.form.status), 141 | distinctUntilChanged() 142 | ); 143 | 144 | /** 145 | * Triggered When the form becomes valid but waits until the form is idle 146 | */ 147 | @Output() public readonly validChange = this.statusChanges$.pipe( 148 | filter((e) => e === 'VALID' || e === 'INVALID'), 149 | map((v) => v === 'VALID'), 150 | distinctUntilChanged() 151 | ); 152 | 153 | /** 154 | * Used to debounce formValues to make sure vest isn't triggered all the time 155 | */ 156 | private readonly formValueCache: { 157 | [field: string]: Partial<{ 158 | sub$$: ReplaySubject; 159 | debounced: Observable; 160 | }>; 161 | } = {}; 162 | 163 | public constructor() { 164 | // When the validation config changes 165 | // Listen to changes of the left-side of the config and trigger the updateValueAndValidity 166 | // function on the dependant controls or groups at the right-side of the config 167 | toObservable(this.validationConfig) 168 | .pipe( 169 | filter((conf) => !!conf), 170 | switchMap((conf) => { 171 | if (!conf) { 172 | return of(null); 173 | } 174 | const streams = Object.keys(conf).map((key) => { 175 | return this.ngForm?.form.get(key)?.valueChanges.pipe( 176 | // Wait until something is pending 177 | switchMap(() => this.pending$), 178 | // Wait until the form is not pending anymore 179 | switchMap(() => this.idle$), 180 | map(() => this.ngForm?.form.get(key)?.value), 181 | takeUntil(this.destroy$$), 182 | tap((v) => { 183 | conf[key]?.forEach((path: string) => { 184 | this.ngForm?.form.get(path)?.updateValueAndValidity({ 185 | onlySelf: true, 186 | emitEvent: true, 187 | }); 188 | }); 189 | }) 190 | ); 191 | }); 192 | return zip(streams); 193 | }) 194 | ) 195 | .subscribe(); 196 | 197 | /** 198 | * Trigger shape validations if the form gets updated 199 | * This is how we can throw run-time errors 200 | */ 201 | this.formValueChange.pipe(takeUntil(this.destroy$$)).subscribe((v) => { 202 | if (this.formShape()) { 203 | validateShape(v, this.formShape() as DeepRequired); 204 | } 205 | }); 206 | 207 | /** 208 | * Mark all the fields as touched when the form is submitted 209 | */ 210 | this.ngForm.ngSubmit.subscribe(() => { 211 | this.ngForm.form.markAllAsTouched(); 212 | }); 213 | } 214 | 215 | /** 216 | * This will feed the formValueCache, debounce it till the next tick 217 | * and create an asynchronous validator that runs a vest suite 218 | * @param field 219 | * @param validationOptions 220 | * @returns an asynchronous validator function 221 | */ 222 | public createAsyncValidator(field: string, validationOptions: ValidationOptions): AsyncValidatorFn { 223 | if (!this.suite()) { 224 | return () => of(null); 225 | } 226 | return (value: any) => { 227 | if (!this.formValue()) { 228 | return of(null); 229 | } 230 | const mod = cloneDeep(this.formValue() as T); 231 | set(mod as object, field, value); // Update the property with path 232 | if (!this.formValueCache[field]) { 233 | this.formValueCache[field] = { 234 | sub$$: new ReplaySubject(1), // Keep track of the last model 235 | }; 236 | this.formValueCache[field].debounced = this.formValueCache[ 237 | field 238 | ].sub$$!.pipe(debounceTime(validationOptions.debounceTime)); 239 | } 240 | // Next the latest model in the cache for a certain field 241 | this.formValueCache[field].sub$$!.next(mod); 242 | 243 | return this.formValueCache[field].debounced!.pipe( 244 | // When debounced, take the latest value and perform the asynchronous vest validation 245 | take(1), 246 | switchMap(() => { 247 | return new Observable((observer) => { 248 | this.suite()!(mod, field).done((result) => { 249 | const errors = result.getErrors()[field]; 250 | observer.next(errors ? { error: errors[0], errors } : null); 251 | observer.complete(); 252 | }); 253 | }) as Observable; 254 | }), 255 | takeUntil(this.destroy$$) 256 | ); 257 | }; 258 | } 259 | 260 | public ngOnDestroy(): void { 261 | this.destroy$$.next(); 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/directives/validate-root-form.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, input, OnDestroy } from '@angular/core'; 2 | import { 3 | AbstractControl, 4 | AsyncValidator, 5 | AsyncValidatorFn, 6 | NG_ASYNC_VALIDATORS, 7 | ValidationErrors, 8 | } from '@angular/forms'; 9 | import { 10 | debounceTime, 11 | Observable, 12 | of, 13 | ReplaySubject, 14 | Subject, 15 | switchMap, 16 | take, 17 | takeUntil, 18 | } from 'rxjs'; 19 | import { StaticSuite } from 'vest'; 20 | import { cloneDeep, set } from '../utils/form-utils'; 21 | import { ValidationOptions } from './validation-options'; 22 | 23 | @Directive({ 24 | selector: 'form[validateRootForm][formValue][suite]', 25 | standalone: true, 26 | providers: [ 27 | { 28 | provide: NG_ASYNC_VALIDATORS, 29 | useExisting: ValidateRootFormDirective, 30 | multi: true, 31 | }, 32 | ], 33 | }) 34 | export class ValidateRootFormDirective implements AsyncValidator, OnDestroy { 35 | public validationOptions = input({ debounceTime: 0 }); 36 | private readonly destroy$$ = new Subject(); 37 | 38 | public readonly formValue = input(null); 39 | public readonly suite = input void 43 | > | null>(null); 44 | 45 | /** 46 | * Whether the root form should be validated or not 47 | * This will use the field rootForm 48 | */ 49 | public readonly validateRootForm = input(false); 50 | 51 | /** 52 | * Used to debounce formValues to make sure vest isn't triggered all the time 53 | */ 54 | private readonly formValueCache: { 55 | [field: string]: Partial<{ 56 | sub$$: ReplaySubject; 57 | debounced: Observable; 58 | }>; 59 | } = {}; 60 | 61 | public validate( 62 | control: AbstractControl 63 | ): Observable { 64 | if (!this.suite() || !this.formValue()) { 65 | return of(null); 66 | } 67 | return this.createAsyncValidator('rootForm', this.validationOptions())( 68 | control.getRawValue() 69 | ) as Observable; 70 | } 71 | 72 | public createAsyncValidator(field: string, validationOptions: ValidationOptions): AsyncValidatorFn { 73 | if (!this.suite()) { 74 | return () => of(null); 75 | } 76 | return (value: any) => { 77 | if (!this.formValue()) { 78 | return of(null); 79 | } 80 | const mod = cloneDeep(value as T); 81 | set(mod as object, field, value); // Update the property with path 82 | if (!this.formValueCache[field]) { 83 | this.formValueCache[field] = { 84 | sub$$: new ReplaySubject(1), // Keep track of the last model 85 | }; 86 | this.formValueCache[field].debounced = this.formValueCache[ 87 | field 88 | ].sub$$!.pipe(debounceTime(validationOptions.debounceTime)); 89 | } 90 | // Next the latest model in the cache for a certain field 91 | this.formValueCache[field].sub$$!.next(mod); 92 | 93 | return this.formValueCache[field].debounced!.pipe( 94 | // When debounced, take the latest value and perform the asynchronous vest validation 95 | take(1), 96 | switchMap(() => { 97 | return new Observable((observer) => { 98 | this.suite()!(mod, field).done((result) => { 99 | const errors = result.getErrors()[field]; 100 | observer.next(errors ? { error: errors[0], errors } : null); 101 | observer.complete(); 102 | }); 103 | }) as Observable; 104 | }), 105 | takeUntil(this.destroy$$) 106 | ); 107 | }; 108 | } 109 | 110 | public ngOnDestroy(): void { 111 | this.destroy$$.next(); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/directives/validation-options.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Validation Options 4 | */ 5 | export interface ValidationOptions { 6 | 7 | /** 8 | * debounceTime for the next validation 9 | */ 10 | debounceTime: number; 11 | } 12 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/exports.ts: -------------------------------------------------------------------------------- 1 | import { Optional, Provider } from '@angular/core'; 2 | import { 3 | ControlContainer, 4 | FormsModule, 5 | NgForm, 6 | NgModelGroup, 7 | } from '@angular/forms'; 8 | import { ValidateRootFormDirective } from './directives/validate-root-form.directive'; 9 | import { ControlWrapperComponent } from './components/control-wrapper/control-wrapper.component'; 10 | import { FormDirective } from './directives/form.directive'; 11 | import { FormModelDirective } from './directives/form-model.directive'; 12 | import { FormModelGroupDirective } from './directives/form-model-group.directive'; 13 | 14 | /** 15 | * This is borrowed from [https://github.com/wardbell/ngc-validate/blob/main/src/app/core/form-container-view-provider.ts](https://github.com/wardbell/ngc-validate/blob/main/src/app/core/form-container-view-provider.ts) 16 | * Thank you so much Ward Bell for your effort!: 17 | * 18 | * Provide a ControlContainer to a form component from the 19 | * nearest parent NgModelGroup (preferred) or NgForm. 20 | * 21 | * Required for Reactive Forms as well (unless you write CVA) 22 | * 23 | * @example 24 | * ``` 25 | * @Component({ 26 | * ... 27 | * viewProviders[ formViewProvider ] 28 | * }) 29 | * ``` 30 | * @see Kara's AngularConnect 2017 talk: https://youtu.be/CD_t3m2WMM8?t=1826 31 | * 32 | * Without this provider 33 | * - Controls are not registered with parent NgForm or NgModelGroup 34 | * - Form-level flags say "untouched" and "valid" 35 | * - No form-level validation roll-up 36 | * - Controls still validate, update model, and update their statuses 37 | * - If within NgForm, no compiler error because ControlContainer is optional for ngModel 38 | * 39 | * Note: if the SubForm Component that uses this Provider 40 | * is not within a Form or NgModelGroup, the provider returns `null` 41 | * resulting in an error, something like 42 | * ``` 43 | * preview-fef3604083950c709c52b.js:1 ERROR Error: 44 | * ngModelGroup cannot be used with a parent formGroup directive. 45 | *``` 46 | */ 47 | const formViewProvider: Provider = { 48 | provide: ControlContainer, 49 | useFactory: _formViewProviderFactory, 50 | deps: [ 51 | [new Optional(), NgForm], 52 | [new Optional(), NgModelGroup], 53 | ], 54 | }; 55 | 56 | function _formViewProviderFactory(ngForm: NgForm, ngModelGroup: NgModelGroup) { 57 | return ngModelGroup || ngForm || null; 58 | } 59 | 60 | /** 61 | * The providers we need in every child component that holds an ngModelGroup 62 | */ 63 | export const vestFormsViewProviders = [ 64 | { provide: ControlContainer, useExisting: NgForm }, 65 | formViewProvider, // very important if we want nested components with ngModelGroup 66 | ]; 67 | 68 | /** 69 | * Exports all the stuff we need to use the template driven forms 70 | */ 71 | export const vestForms = [ 72 | ValidateRootFormDirective, 73 | ControlWrapperComponent, 74 | FormDirective, 75 | FormsModule, 76 | FormModelDirective, 77 | FormModelGroupDirective, 78 | ] as const; 79 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/testing/simple-form-with-validation-config.stories.ts: -------------------------------------------------------------------------------- 1 | import { componentWrapperDecorator, Meta, StoryObj } from '@storybook/angular'; 2 | import { Component, computed, signal } from '@angular/core'; 3 | import { vestForms } from '../exports'; 4 | import { getByText, userEvent, waitFor, within } from '@storybook/test'; 5 | import { expect } from '@storybook/jest'; 6 | import { 7 | FormModel, 8 | formShape, 9 | formValidationSuite, 10 | selectors, 11 | } from './simple-form'; 12 | import { JsonPipe } from '@angular/common'; 13 | 14 | @Component({ 15 | template: ` 16 |
29 |
30 |
35 | 45 |
46 |
51 | 61 |
62 |
68 |
69 |
74 | 84 |
85 |
90 | 100 |
101 |
102 |
103 | 110 | 111 |
112 |
113 | `, 114 | imports: [vestForms, JsonPipe], 115 | standalone: true, 116 | }) 117 | export class FormDirectiveDemoComponent { 118 | protected readonly formValue = signal({}); 119 | protected readonly formValid = signal(false); 120 | protected readonly errors = signal>({}); 121 | protected readonly shape = formShape; 122 | protected readonly suite = formValidationSuite; 123 | protected validationConfig: any = { 124 | firstName: ['lastName'], 125 | 'passwords.password': ['passwords.confirmPassword'], 126 | }; 127 | private readonly viewModel = computed(() => { 128 | return { 129 | formValue: this.formValue(), 130 | errors: this.errors(), 131 | formValid: this.formValid(), 132 | }; 133 | }); 134 | 135 | protected toggle(): void { 136 | if (this.validationConfig['passwords.password']) { 137 | this.validationConfig = { firstName: ['lastName'] }; 138 | } else { 139 | this.validationConfig = { 140 | firstName: ['lastName'], 141 | 'passwords.password': ['passwords.confirmPassword'], 142 | }; 143 | } 144 | } 145 | 146 | protected get vm() { 147 | return this.viewModel(); 148 | } 149 | 150 | protected setFormValue(v: FormModel): void { 151 | this.formValue.set(v); 152 | } 153 | 154 | protected onSubmit(): void { 155 | if (this.formValid()) { 156 | console.log(this.formValue()); 157 | } 158 | } 159 | } 160 | 161 | const meta: Meta = { 162 | title: 'simple form with validation config', 163 | component: FormDirectiveDemoComponent, 164 | parameters: { 165 | // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout 166 | layout: 'fullscreen', 167 | }, 168 | }; 169 | 170 | export default meta; 171 | export const Primary: StoryObj = { 172 | decorators: [componentWrapperDecorator(FormDirectiveDemoComponent)], 173 | }; 174 | 175 | export const ShouldRetriggerByValidationConfig: StoryObj = { 176 | play: async ({ canvasElement }) => { 177 | const canvas = within(canvasElement); 178 | await userEvent.click(canvas.getByTestId(selectors.btnSubmit)); 179 | await expect( 180 | canvas.getByTestId(selectors.scControlWrapperFirstName) 181 | ).toHaveTextContent('First name is required'); 182 | await expect( 183 | canvas.getByTestId(selectors.scControlWrapperLastName) 184 | ).toHaveTextContent('Last name is required'); 185 | await expect( 186 | canvas.getByTestId(selectors.scControlWrapperPassword) 187 | ).toHaveTextContent('Password is required'); 188 | await expect( 189 | canvas.getByTestId(selectors.scControlWrapperConfirmPassword) 190 | ).not.toHaveTextContent('Confirm password is required'); 191 | await userEvent.click(canvas.getByTestId(selectors.inputConfirmPassword)); 192 | await canvas.getByTestId(selectors.inputConfirmPassword).blur(); 193 | await userEvent.type(canvas.getByTestId(selectors.inputPassword), 'f'); 194 | await waitFor(() => { 195 | expect( 196 | canvas.getByTestId(selectors.scControlWrapperConfirmPassword) 197 | ).toHaveTextContent('Confirm password is required'); 198 | }); 199 | await userEvent.clear(canvas.getByTestId(selectors.inputPassword)); 200 | await waitFor(() => { 201 | expect( 202 | canvas.getByTestId(selectors.scControlWrapperConfirmPassword) 203 | ).not.toHaveTextContent('Confirm password is required'); 204 | }); 205 | }, 206 | }; 207 | 208 | export const ShouldReactToDynamicValidationConfig: StoryObj = { 209 | play: async ({ canvasElement }) => { 210 | const canvas = within(canvasElement); 211 | await userEvent.click(canvas.getByTestId(selectors.btnSubmit)); 212 | await expect( 213 | canvas.getByTestId(selectors.scControlWrapperFirstName) 214 | ).toHaveTextContent('First name is required'); 215 | await expect( 216 | canvas.getByTestId(selectors.scControlWrapperLastName) 217 | ).toHaveTextContent('Last name is required'); 218 | await expect( 219 | canvas.getByTestId(selectors.scControlWrapperPassword) 220 | ).toHaveTextContent('Password is required'); 221 | await expect( 222 | canvas.getByTestId(selectors.scControlWrapperConfirmPassword) 223 | ).not.toHaveTextContent('Confirm password is required'); 224 | await userEvent.click(canvas.getByTestId(selectors.inputConfirmPassword)); 225 | await canvas.getByTestId(selectors.inputConfirmPassword).blur(); 226 | await userEvent.type(canvas.getByTestId(selectors.inputPassword), 'f'); 227 | await waitFor(() => { 228 | expect( 229 | canvas.getByTestId(selectors.scControlWrapperConfirmPassword) 230 | ).toHaveTextContent('Confirm password is required'); 231 | }); 232 | await userEvent.clear(canvas.getByTestId(selectors.inputPassword)); 233 | await waitFor(() => { 234 | expect( 235 | canvas.getByTestId(selectors.scControlWrapperConfirmPassword) 236 | ).not.toHaveTextContent('Confirm password is required'); 237 | }); 238 | await userEvent.click( 239 | canvas.getByTestId(selectors.btnToggleValidationConfig) 240 | ); 241 | await userEvent.type(canvas.getByTestId(selectors.inputPassword), 'f'); 242 | await waitFor(() => { 243 | expect( 244 | canvas.getByTestId(selectors.scControlWrapperConfirmPassword) 245 | ).not.toHaveTextContent('Confirm password is required'); 246 | }); 247 | await userEvent.clear(canvas.getByTestId(selectors.inputPassword)); 248 | await userEvent.click( 249 | canvas.getByTestId(selectors.btnToggleValidationConfig) 250 | ); 251 | await userEvent.type(canvas.getByTestId(selectors.inputPassword), 'f'); 252 | await waitFor(() => { 253 | expect( 254 | canvas.getByTestId(selectors.scControlWrapperConfirmPassword) 255 | ).toHaveTextContent('Confirm password is required'); 256 | }); 257 | }, 258 | }; 259 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/testing/simple-form-with-validation-options.stories.ts: -------------------------------------------------------------------------------- 1 | import { componentWrapperDecorator, Meta, StoryObj } from '@storybook/angular'; 2 | import { Component, computed, signal } from '@angular/core'; 3 | import { vestForms } from '../exports'; 4 | import { userEvent, within } from '@storybook/test'; 5 | import { expect } from '@storybook/jest'; 6 | import { 7 | FormModel, 8 | formShape, 9 | formValidationSuite, 10 | selectors, 11 | } from './simple-form'; 12 | import { JsonPipe } from '@angular/common'; 13 | 14 | @Component({ 15 | template: ` 16 |
29 |
30 |
35 | 46 |
47 |
52 | 62 |
63 |
70 |
71 |
76 | 86 |
87 |
92 | 102 |
103 |
104 |
105 | 106 |
107 |
108 |         {{ vm.errors | json }}
109 |       
110 |
111 | 112 | `, 113 | imports: [vestForms, JsonPipe], 114 | standalone: true, 115 | }) 116 | export class FormDirectiveDemoComponent { 117 | protected readonly formValue = signal({}); 118 | protected readonly formValid = signal(false); 119 | protected readonly errors = signal>({}); 120 | protected readonly shape = formShape; 121 | protected readonly suite = formValidationSuite; 122 | private readonly viewModel = computed(() => { 123 | return { 124 | formValue: this.formValue(), 125 | errors: this.errors(), 126 | formValid: this.formValid(), 127 | }; 128 | }); 129 | 130 | protected get vm() { 131 | return this.viewModel(); 132 | } 133 | 134 | protected setFormValue(v: FormModel): void { 135 | this.formValue.set(v); 136 | } 137 | 138 | protected onSubmit(): void { 139 | if (this.formValid()) { 140 | console.log(this.formValue()); 141 | } 142 | } 143 | } 144 | 145 | const meta: Meta = { 146 | title: 'simple form with validation options', 147 | component: FormDirectiveDemoComponent, 148 | parameters: { 149 | // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout 150 | layout: 'fullscreen', 151 | }, 152 | }; 153 | 154 | export default meta; 155 | export const Primary: StoryObj = { 156 | decorators: [componentWrapperDecorator(FormDirectiveDemoComponent)], 157 | }; 158 | 159 | export const ShouldShowFirstnameRequiredAfterDelayForNgModel: StoryObj = { 160 | play: async ({ canvasElement }) => { 161 | const canvas = within(canvasElement); 162 | await userEvent.click(canvas.getByTestId(selectors.inputFirstName)); 163 | canvas.getByTestId(selectors.inputFirstName).blur(); 164 | 165 | await expect( 166 | canvas.getByTestId(selectors.scControlWrapperFirstName) 167 | ).not.toHaveTextContent('First name is required'); 168 | 169 | setTimeout(() => { 170 | expect( 171 | canvas.getByTestId(selectors.scControlWrapperFirstName) 172 | ).toHaveTextContent('First name is required'); 173 | }, 550) 174 | }, 175 | }; 176 | 177 | export const ShouldShowPasswordConfirmationAfterDelayForNgModelGroup: StoryObj = { 178 | play: async ({ canvasElement }) => { 179 | const canvas = within(canvasElement); 180 | await userEvent.type(canvas.getByTestId(selectors.inputPassword), 'first'); 181 | await userEvent.type( 182 | canvas.getByTestId(selectors.inputConfirmPassword), 183 | 'second' 184 | , { delay: 500}); 185 | await userEvent.click(canvas.getByTestId(selectors.inputConfirmPassword)); 186 | await canvas.getByTestId(selectors.inputConfirmPassword).blur(); 187 | 188 | await expect( 189 | canvas.getByTestId(selectors.scControlWrapperPasswords) 190 | ).not.toHaveTextContent('Passwords do not match'); 191 | 192 | setTimeout(() => { 193 | expect( 194 | canvas.getByTestId(selectors.scControlWrapperPasswords) 195 | ).toHaveTextContent('Passwords do not match'); 196 | }, 1000) 197 | }, 198 | }; 199 | 200 | 201 | export const ShouldValidateOnRootFormAfterDelay: StoryObj = { 202 | play: async ({ canvasElement }) => { 203 | const canvas = within(canvasElement); 204 | await userEvent.type( 205 | canvas.getByTestId(selectors.inputFirstName), 206 | 'Brecht' 207 | ); 208 | await userEvent.type( 209 | canvas.getByTestId(selectors.inputLastName), 210 | 'Billiet' 211 | ); 212 | await userEvent.type(canvas.getByTestId(selectors.inputPassword), '1234'); 213 | 214 | await expect( 215 | JSON.stringify( 216 | JSON.parse(canvas.getByTestId(selectors.preFormErrors).innerHTML) 217 | ) 218 | ).toEqual(JSON.stringify({})) 219 | 220 | const expectedErrors = { 221 | rootForm: ['Brecht his pass is not 1234'], 222 | }; 223 | 224 | setTimeout(() => { 225 | expect( 226 | JSON.stringify( 227 | JSON.parse(canvas.getByTestId(selectors.preFormErrors).innerHTML) 228 | ) 229 | ).toEqual(JSON.stringify(expectedErrors)); 230 | }, 550) 231 | }, 232 | }; 233 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/testing/simple-form.stories.ts: -------------------------------------------------------------------------------- 1 | import { componentWrapperDecorator, Meta, StoryObj } from '@storybook/angular'; 2 | import { Component, computed, signal } from '@angular/core'; 3 | import { vestForms } from '../exports'; 4 | import { userEvent, waitFor, within } from '@storybook/test'; 5 | import { expect } from '@storybook/jest'; 6 | import { 7 | FormModel, 8 | formShape, 9 | formValidationSuite, 10 | selectors, 11 | } from './simple-form'; 12 | import { JsonPipe } from '@angular/common'; 13 | 14 | @Component({ 15 | template: ` 16 |
29 |
30 |
35 | 45 |
46 |
51 | 61 |
62 |
68 |
69 |
74 | 84 |
85 |
90 | 100 |
101 |
102 |
103 | 104 |
105 |
106 |         {{ vm.formValue | json }}
107 |       
109 |
110 |         {{ vm.errors | json }}
111 |       
113 |
{{ vm.formValid }}
114 |
{{ vm.formDirty }}
115 |
116 | `, 117 | imports: [vestForms, JsonPipe], 118 | standalone: true, 119 | }) 120 | export class FormDirectiveDemoComponent { 121 | protected readonly formValue = signal({}); 122 | protected readonly formValid = signal(null); 123 | protected readonly formDirty = signal(null); 124 | protected readonly errors = signal>({}); 125 | protected readonly shape = formShape; 126 | protected readonly suite = formValidationSuite; 127 | private readonly viewModel = computed(() => { 128 | return { 129 | formValue: this.formValue(), 130 | errors: this.errors(), 131 | formValid: this.formValid(), 132 | formDirty: this.formDirty(), 133 | }; 134 | }); 135 | 136 | protected get vm() { 137 | return this.viewModel(); 138 | } 139 | 140 | protected setFormValue(v: FormModel): void { 141 | this.formValue.set(v); 142 | } 143 | 144 | protected onSubmit(): void { 145 | if (this.formValid()) { 146 | console.log(this.formValue()); 147 | } 148 | } 149 | } 150 | 151 | const meta: Meta = { 152 | title: 'simple form', 153 | component: FormDirectiveDemoComponent, 154 | parameters: { 155 | // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout 156 | layout: 'fullscreen', 157 | }, 158 | }; 159 | 160 | export default meta; 161 | 162 | export const Primary: StoryObj = { 163 | decorators: [componentWrapperDecorator(FormDirectiveDemoComponent)], 164 | }; 165 | 166 | export const ShouldShowErrorsOnSubmit: StoryObj = { 167 | play: async ({ canvasElement }) => { 168 | const canvas = within(canvasElement); 169 | await userEvent.click(canvas.getByTestId(selectors.btnSubmit)); 170 | await expect( 171 | canvas.getByTestId(selectors.scControlWrapperFirstName) 172 | ).toHaveTextContent('First name is required'); 173 | await expect( 174 | canvas.getByTestId(selectors.scControlWrapperLastName) 175 | ).toHaveTextContent('Last name is required'); 176 | await expect( 177 | canvas.getByTestId(selectors.scControlWrapperPassword) 178 | ).toHaveTextContent('Password is required'); 179 | await expect( 180 | canvas.getByTestId(selectors.scControlWrapperConfirmPassword) 181 | ).not.toHaveTextContent('Confirm password is required'); 182 | }, 183 | }; 184 | 185 | export const ShouldHideErrorsWhenValid: StoryObj = { 186 | play: async ({ canvasElement }) => { 187 | const canvas = within(canvasElement); 188 | await userEvent.click(canvas.getByTestId(selectors.btnSubmit)); 189 | 190 | await userEvent.type(canvas.getByTestId(selectors.inputFirstName), 'first'); 191 | await userEvent.type(canvas.getByTestId(selectors.inputLastName), 'last'); 192 | await userEvent.type(canvas.getByTestId(selectors.inputPassword), 'pass'); 193 | await expect( 194 | canvas.getByTestId(selectors.scControlWrapperFirstName) 195 | ).not.toHaveTextContent('First name is required'); 196 | await expect( 197 | canvas.getByTestId(selectors.scControlWrapperLastName) 198 | ).not.toHaveTextContent('Last name is required'); 199 | await expect( 200 | canvas.getByTestId(selectors.scControlWrapperPassword) 201 | ).not.toHaveTextContent('Password is required'); 202 | }, 203 | }; 204 | export const ShouldShowErrorsOnBlur: StoryObj = { 205 | play: async ({ canvasElement }) => { 206 | const canvas = within(canvasElement); 207 | await userEvent.click(canvas.getByTestId(selectors.inputFirstName)); 208 | canvas.getByTestId(selectors.inputFirstName).blur(); 209 | await expect( 210 | canvas.getByTestId(selectors.scControlWrapperFirstName) 211 | ).toHaveTextContent('First name is required'); 212 | 213 | await userEvent.click(canvas.getByTestId(selectors.inputLastName)); 214 | canvas.getByTestId(selectors.inputLastName).blur(); 215 | await expect( 216 | canvas.getByTestId(selectors.scControlWrapperLastName) 217 | ).toHaveTextContent('Last name is required'); 218 | 219 | await userEvent.click(canvas.getByTestId(selectors.inputPassword)); 220 | canvas.getByTestId(selectors.inputPassword).blur(); 221 | await expect( 222 | canvas.getByTestId(selectors.scControlWrapperPassword) 223 | ).toHaveTextContent('Password is required'); 224 | }, 225 | }; 226 | 227 | export const ShouldValidateOnGroups: StoryObj = { 228 | play: async ({ canvasElement }) => { 229 | const canvas = within(canvasElement); 230 | await userEvent.type(canvas.getByTestId(selectors.inputPassword), 'first'); 231 | await userEvent.type( 232 | canvas.getByTestId(selectors.inputConfirmPassword), 233 | 'second' 234 | ); 235 | await expect( 236 | canvas.getByTestId(selectors.scControlWrapperPasswords) 237 | ).toHaveTextContent('Passwords do not match'); 238 | await expect( 239 | canvas.getByTestId(selectors.scControlWrapperPasswords) 240 | ).toHaveClass('sc-control-wrapper--invalid'); 241 | }, 242 | }; 243 | 244 | export const ShouldHaveCorrectStatussesAndFormValueInitially: StoryObj = { 245 | play: async ({ canvasElement }) => { 246 | const canvas = within(canvasElement); 247 | await waitFor(() => { 248 | expect(canvas.getByTestId(selectors.preFormValid)).toHaveTextContent( 249 | 'false' 250 | ); 251 | expect(canvas.getByTestId(selectors.preFormDirty)).toHaveTextContent( 252 | 'false' 253 | ); 254 | const expectedContent = { 255 | passwords: { 256 | password: null, 257 | confirmPassword: null, 258 | }, 259 | }; 260 | expect( 261 | JSON.stringify( 262 | JSON.parse(canvas.getByTestId(selectors.preFormValue).innerHTML) 263 | ) 264 | ).toEqual(JSON.stringify(expectedContent)); 265 | const expectedErrors = { 266 | firstName: ['First name is required'], 267 | lastName: ['Last name is required'], 268 | 'passwords.password': ['Password is required'], 269 | }; 270 | expect( 271 | JSON.stringify( 272 | JSON.parse(canvas.getByTestId(selectors.preFormErrors).innerHTML) 273 | ) 274 | ).toEqual(JSON.stringify(expectedErrors)); 275 | }); 276 | }, 277 | }; 278 | 279 | export const ShouldHaveCorrectStatussesAndOnFormUpdate: StoryObj = { 280 | play: async ({ canvasElement }) => { 281 | const canvas = within(canvasElement); 282 | await userEvent.type(canvas.getByTestId(selectors.inputFirstName), 'f'); 283 | await waitFor(() => { 284 | expect(canvas.getByTestId(selectors.preFormValid)).toHaveTextContent( 285 | 'false' 286 | ); 287 | expect(canvas.getByTestId(selectors.preFormDirty)).toHaveTextContent( 288 | 'true' 289 | ); 290 | const expectedContent = { 291 | firstName: 'f', 292 | passwords: { 293 | password: null, 294 | confirmPassword: null, 295 | }, 296 | }; 297 | expect( 298 | JSON.stringify( 299 | JSON.parse(canvas.getByTestId(selectors.preFormValue).innerHTML) 300 | ) 301 | ).toEqual(JSON.stringify(expectedContent)); 302 | const expectedErrors = { 303 | lastName: ['Last name is required'], 304 | 'passwords.password': ['Password is required'], 305 | }; 306 | expect( 307 | JSON.stringify( 308 | JSON.parse(canvas.getByTestId(selectors.preFormErrors).innerHTML) 309 | ) 310 | ).toEqual(JSON.stringify(expectedErrors)); 311 | }); 312 | await userEvent.type(canvas.getByTestId(selectors.inputLastName), 'l'); 313 | await userEvent.type(canvas.getByTestId(selectors.inputPassword), 'p'); 314 | await userEvent.type( 315 | canvas.getByTestId(selectors.inputConfirmPassword), 316 | 'p' 317 | ); 318 | await waitFor(() => { 319 | expect(canvas.getByTestId(selectors.preFormValid)).toHaveTextContent( 320 | 'true' 321 | ); 322 | expect(canvas.getByTestId(selectors.preFormDirty)).toHaveTextContent( 323 | 'true' 324 | ); 325 | const expectedContent = { 326 | firstName: 'f', 327 | lastName: 'l', 328 | passwords: { 329 | password: 'p', 330 | confirmPassword: 'p', 331 | }, 332 | }; 333 | expect( 334 | JSON.stringify( 335 | JSON.parse(canvas.getByTestId(selectors.preFormValue).innerHTML) 336 | ) 337 | ).toEqual(JSON.stringify(expectedContent)); 338 | expect( 339 | JSON.stringify( 340 | JSON.parse(canvas.getByTestId(selectors.preFormErrors).innerHTML) 341 | ) 342 | ).toEqual(JSON.stringify({})); 343 | }); 344 | }, 345 | }; 346 | 347 | export const ShouldValidateOnRootForm: StoryObj = { 348 | play: async ({ canvasElement }) => { 349 | const canvas = within(canvasElement); 350 | await userEvent.type( 351 | canvas.getByTestId(selectors.inputFirstName), 352 | 'Brecht' 353 | ); 354 | await userEvent.type( 355 | canvas.getByTestId(selectors.inputLastName), 356 | 'Billiet' 357 | ); 358 | await userEvent.type(canvas.getByTestId(selectors.inputPassword), '1234'); 359 | const expectedErrors = { 360 | rootForm: ['Brecht his pass is not 1234'], 361 | }; 362 | await expect( 363 | JSON.stringify( 364 | JSON.parse(canvas.getByTestId(selectors.preFormErrors).innerHTML) 365 | ) 366 | ).toEqual(JSON.stringify(expectedErrors)); 367 | await userEvent.type(canvas.getByTestId(selectors.inputPassword), '5'); 368 | await expect( 369 | JSON.stringify( 370 | JSON.parse(canvas.getByTestId(selectors.preFormErrors).innerHTML) 371 | ) 372 | ).toEqual(JSON.stringify({})); 373 | }, 374 | }; 375 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/testing/simple-form.ts: -------------------------------------------------------------------------------- 1 | import { enforce, omitWhen, only, staticSuite, test } from 'vest'; 2 | import { DeepPartial } from '../utils/deep-partial'; 3 | import { DeepRequired } from '../utils/deep-required'; 4 | import { ROOT_FORM } from '../constants'; 5 | 6 | export type FormModel = DeepPartial<{ 7 | firstName: string; 8 | lastName: string; 9 | passwords: { 10 | password: string; 11 | confirmPassword?: string; 12 | }; 13 | }>; 14 | 15 | export const formShape: DeepRequired = { 16 | firstName: '', 17 | lastName: '', 18 | passwords: { 19 | password: '', 20 | confirmPassword: '', 21 | }, 22 | }; 23 | 24 | export const formValidationSuite = staticSuite( 25 | (model: FormModel, field?: string) => { 26 | if (field) { 27 | only(field); 28 | } 29 | test(ROOT_FORM, 'Brecht his pass is not 1234', () => { 30 | enforce( 31 | model.firstName === 'Brecht' && 32 | model.lastName === 'Billiet' && 33 | model.passwords?.password === '1234' 34 | ).isFalsy(); 35 | }); 36 | 37 | test('firstName', 'First name is required', () => { 38 | enforce(model.firstName).isNotBlank(); 39 | }); 40 | test('lastName', 'Last name is required', () => { 41 | enforce(model.lastName).isNotBlank(); 42 | }); 43 | test('passwords.password', 'Password is required', () => { 44 | enforce(model.passwords?.password).isNotBlank(); 45 | }); 46 | omitWhen(!model.passwords?.password, () => { 47 | test('passwords.confirmPassword', 'Confirm password is required', () => { 48 | enforce(model.passwords?.confirmPassword).isNotBlank(); 49 | }); 50 | }); 51 | omitWhen( 52 | !model.passwords?.password || !model.passwords?.confirmPassword, 53 | () => { 54 | test('passwords', 'Passwords do not match', () => { 55 | enforce(model.passwords?.confirmPassword).equals( 56 | model.passwords?.password 57 | ); 58 | }); 59 | } 60 | ); 61 | } 62 | ); 63 | 64 | export const selectors = { 65 | scControlWrapperFirstName: 'sc-control-wrapper__first-name', 66 | inputFirstName: 'input__first-name', 67 | scControlWrapperLastName: 'sc-control-wrapper__last-name', 68 | inputLastName: 'input__last-name', 69 | scControlWrapperPasswords: 'sc-control-wrapper__passwords', 70 | scControlWrapperPassword: 'sc-control-wrapper__password', 71 | inputPassword: 'input__password', 72 | scControlWrapperConfirmPassword: 'sc-control-wrapper__confirm-password', 73 | inputConfirmPassword: 'input__confirm-password', 74 | btnSubmit: 'btn__submit', 75 | btnToggleValidationConfig: 'btn__toggle-validation-config', 76 | preFormValue: 'pre__form-value', 77 | preFormErrors: 'pre__form-errors', 78 | preFormValid: 'pre__form-valid', 79 | preFormDirty: 'pre__form-dirty', 80 | }; 81 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/utils/array-to-object.spec.ts: -------------------------------------------------------------------------------- 1 | import { arrayToObject } from './array-to-object'; 2 | 3 | describe('arrayToObject', () => { 4 | it('should convert an array to an object with numerical keys', () => { 5 | const inputArray = ['a', 'b', 'c']; 6 | const expectedOutput = { 0: 'a', 1: 'b', 2: 'c' }; 7 | expect(arrayToObject(inputArray)).toEqual(expectedOutput); 8 | }); 9 | 10 | it('should return an empty object for an empty array', () => { 11 | const inputArray: string[] = []; 12 | const expectedOutput = {}; 13 | expect(arrayToObject(inputArray)).toEqual(expectedOutput); 14 | }); 15 | 16 | it('should handle arrays of objects', () => { 17 | const inputArray = [{ name: 'John' }, { name: 'Doe' }]; 18 | const expectedOutput = { 0: { name: 'John' }, 1: { name: 'Doe' } }; 19 | expect(arrayToObject(inputArray)).toEqual(expectedOutput); 20 | }); 21 | 22 | it('should handle arrays with mixed types', () => { 23 | const inputArray = [1, 'two', { prop: 'value' }]; 24 | const expectedOutput = { 0: 1, 1: 'two', 2: { prop: 'value' } }; 25 | expect(arrayToObject(inputArray)).toEqual(expectedOutput); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/utils/array-to-object.ts: -------------------------------------------------------------------------------- 1 | export function arrayToObject(arr: T[]): { [key: number]: T } { 2 | return arr.reduce((acc, value, index) => ({ ...acc, [index]: value }), {}); 3 | } 4 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/utils/deep-partial.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Simple type that makes every property and child property 3 | * partial, recursively. Why? Because template-driven forms are 4 | * deep partial, since they get created by the DOM 5 | */ 6 | export type DeepPartial = { 7 | [P in keyof T]?: T[P] extends Array 8 | ? Array> 9 | : T[P] extends ReadonlyArray 10 | ? ReadonlyArray> 11 | : T[P] extends object 12 | ? DeepPartial 13 | : T[P]; 14 | }; 15 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/utils/deep-required.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Sometimes we want to make every property of a type 3 | * required, but also child properties recursively 4 | */ 5 | export type DeepRequired = { 6 | [K in keyof T]-?: T[K] extends object ? DeepRequired : T[K]; 7 | }; 8 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/utils/form-utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EmailValidator, 3 | FormArray, 4 | FormControl, 5 | FormGroup, 6 | RequiredValidator, 7 | Validators, 8 | } from '@angular/forms'; 9 | import { 10 | cloneDeep, 11 | getAllFormErrors, 12 | getFormControlField, 13 | getFormGroupField, 14 | mergeValuesAndRawValues, 15 | set, 16 | } from './form-utils'; 17 | 18 | describe('getFormControlField function', () => { 19 | it('should return correct field name for FormControl in root FormGroup', () => { 20 | const form = new FormGroup({ 21 | name: new FormControl('John'), 22 | }); 23 | expect(getFormControlField(form, form.controls.name)).toBe('name'); 24 | }); 25 | 26 | it('should return correct field name for FormControl in nested FormGroup', () => { 27 | const form = new FormGroup({ 28 | personal: new FormGroup({ 29 | name: new FormControl('John'), 30 | }), 31 | }); 32 | expect(getFormControlField(form, form.get('personal')!.get('name')!)).toBe( 33 | 'personal.name' 34 | ); 35 | }); 36 | }); 37 | 38 | describe('getFormGroupField function', () => { 39 | it('should return correct field name for FormGroup in root FormGroup', () => { 40 | const form = new FormGroup({ 41 | personal: new FormGroup({ 42 | name: new FormControl('John'), 43 | }), 44 | }); 45 | expect(getFormGroupField(form, form.controls.personal)).toBe('personal'); 46 | }); 47 | 48 | it('should return correct field name for FormGroup in nested FormGroup', () => { 49 | const form = new FormGroup({ 50 | personal: new FormGroup({ 51 | contact: new FormGroup({ 52 | email: new FormControl('john@example.com'), 53 | }), 54 | }), 55 | }); 56 | expect(getFormGroupField(form, form.get('personal')!.get('contact')!)).toBe( 57 | 'personal.contact' 58 | ); 59 | }); 60 | }); 61 | 62 | describe('mergeValuesAndRawValues function', () => { 63 | it('should merge values and raw values correctly', () => { 64 | const form = new FormGroup({ 65 | name: new FormControl('John'), 66 | age: new FormControl(30), 67 | address: new FormGroup({ 68 | city: new FormControl('New York'), 69 | zip: new FormControl(12345), 70 | }), 71 | }); 72 | form.get('name')!.disable(); // Simulate a disabled field 73 | const mergedValues = mergeValuesAndRawValues(form); 74 | expect(mergedValues).toEqual({ 75 | name: 'John', 76 | age: 30, 77 | address: { 78 | city: 'New York', 79 | zip: 12345, 80 | }, 81 | }); 82 | }); 83 | }); 84 | 85 | describe('cloneDeep function', () => { 86 | it('should deep clone an object', () => { 87 | const original = { 88 | name: 'John', 89 | age: 30, 90 | address: { 91 | city: 'New York', 92 | zip: 12345, 93 | }, 94 | }; 95 | const cloned = cloneDeep(original); 96 | expect(cloned).toEqual(original); 97 | expect(cloned).not.toBe(original); // Ensure it's a deep clone, not a reference 98 | }); 99 | }); 100 | 101 | describe('set function', () => { 102 | it('should set a value in an object at the correct path', () => { 103 | const obj = {}; 104 | set(obj, 'address.city', 'New York'); 105 | expect(obj).toEqual({ 106 | address: { 107 | city: 'New York', 108 | }, 109 | }); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/utils/form-utils.ts: -------------------------------------------------------------------------------- 1 | import { AbstractControl, FormArray, FormGroup } from '@angular/forms'; 2 | import { ROOT_FORM } from '../constants'; 3 | 4 | /** 5 | * Recursively calculates the path of a form control 6 | * @param formGroup 7 | * @param control 8 | */ 9 | function getControlPath( 10 | formGroup: FormGroup, 11 | control: AbstractControl 12 | ): string { 13 | for (const key in formGroup.controls) { 14 | if (formGroup.controls.hasOwnProperty(key)) { 15 | const ctrl = formGroup.get(key); 16 | if (ctrl instanceof FormGroup) { 17 | const path = getControlPath(ctrl, control); 18 | if (path) { 19 | return key + '.' + path; 20 | } 21 | } else if (ctrl === control) { 22 | return key; 23 | } 24 | } 25 | } 26 | return ''; 27 | } 28 | 29 | /** 30 | * Recursively calculates the path of a form group 31 | * @param formGroup 32 | * @param control 33 | */ 34 | function getGroupPath(formGroup: FormGroup, control: AbstractControl): string { 35 | for (const key in formGroup.controls) { 36 | if (formGroup.controls.hasOwnProperty(key)) { 37 | const ctrl = formGroup.get(key); 38 | if (ctrl === control) { 39 | return key; 40 | } 41 | if (ctrl instanceof FormGroup) { 42 | const path = getGroupPath(ctrl, control); 43 | if (path) { 44 | return key + '.' + path; 45 | } 46 | } 47 | } 48 | } 49 | return ''; 50 | } 51 | 52 | /** 53 | * Calculates the field name of a form control: Eg: addresses.shippingAddress.street 54 | * @param rootForm 55 | * @param control 56 | */ 57 | export function getFormControlField( 58 | rootForm: FormGroup, 59 | control: AbstractControl 60 | ): string { 61 | return getControlPath(rootForm, control); 62 | } 63 | 64 | /** 65 | * Calcuates the field name of a form group Eg: addresses.shippingAddress 66 | * @param rootForm 67 | * @param control 68 | */ 69 | export function getFormGroupField( 70 | rootForm: FormGroup, 71 | control: AbstractControl 72 | ): string { 73 | return getGroupPath(rootForm, control); 74 | } 75 | 76 | /** 77 | * This RxJS operator merges the value of the form with the raw value. 78 | * By doing this we can assure that we don't lose values of disabled form fields 79 | * @param form 80 | */ 81 | export function mergeValuesAndRawValues(form: FormGroup): T { 82 | // Retrieve the standard values (respecting references) 83 | const value = { ...form.value }; 84 | 85 | // Retrieve the raw values (including disabled values) 86 | const rawValue = form.getRawValue(); 87 | 88 | // Recursive function to merge rawValue into value 89 | function mergeRecursive(target: any, source: any) { 90 | Object.keys(source).forEach((key) => { 91 | if (target[key] === undefined) { 92 | // If the key is not in the target, add it directly (for disabled fields) 93 | target[key] = source[key]; 94 | } else if ( 95 | typeof source[key] === 'object' && 96 | source[key] !== null && 97 | !Array.isArray(source[key]) 98 | ) { 99 | // If the value is an object, merge it recursively 100 | mergeRecursive(target[key], source[key]); 101 | } 102 | // If the target already has the key with a primitive value, it's left as is to maintain references 103 | }); 104 | } 105 | 106 | mergeRecursive(value, rawValue); 107 | return value; 108 | } 109 | 110 | type Primitive = undefined | null | boolean | string | number | Function; 111 | 112 | function isPrimitive(value: any): value is Primitive { 113 | return ( 114 | value === null || (typeof value !== 'object' && typeof value !== 'function') 115 | ); 116 | } 117 | 118 | /** 119 | * Performs a deep-clone of an object 120 | * @param obj 121 | */ 122 | export function cloneDeep(obj: T): T { 123 | // Handle primitives (null, undefined, boolean, string, number, function) 124 | if (isPrimitive(obj)) { 125 | return obj; 126 | } 127 | 128 | // Handle Date 129 | if (obj instanceof Date) { 130 | return new Date(obj.getTime()) as any as T; 131 | } 132 | 133 | // Handle Array 134 | if (Array.isArray(obj)) { 135 | return obj.map((item) => cloneDeep(item)) as any as T; 136 | } 137 | 138 | // Handle Object 139 | if (obj instanceof Object) { 140 | const clonedObj: any = {}; 141 | for (const key in obj) { 142 | if (obj.hasOwnProperty(key)) { 143 | clonedObj[key] = cloneDeep((obj as any)[key]); 144 | } 145 | } 146 | return clonedObj as T; 147 | } 148 | 149 | throw new Error("Unable to copy object! Its type isn't supported."); 150 | } 151 | 152 | /** 153 | * Sets a value in an object in the correct path 154 | * @param obj 155 | * @param path 156 | * @param value 157 | */ 158 | export function set(obj: object, path: string, value: any): void { 159 | const keys = path.split('.'); 160 | let current: any = obj; 161 | 162 | for (let i = 0; i < keys.length - 1; i++) { 163 | const key = keys[i]; 164 | if (!current[key]) { 165 | current[key] = {}; 166 | } 167 | current = current[key]; 168 | } 169 | 170 | current[keys[keys.length - 1]] = value; 171 | } 172 | 173 | /** 174 | * Traverses the form and returns the errors by path 175 | * @param form 176 | */ 177 | export function getAllFormErrors( 178 | form?: AbstractControl 179 | ): Record { 180 | const errors: Record = {}; 181 | if (!form) { 182 | return errors; 183 | } 184 | 185 | function collect(control: AbstractControl, path: string): void { 186 | if (control instanceof FormGroup || control instanceof FormArray) { 187 | Object.keys(control.controls).forEach((key) => { 188 | const childControl = control.get(key); 189 | const controlPath = path ? `${path}.${key}` : key; 190 | if (path && control.errors && control.enabled) { 191 | Object.keys(control.errors).forEach((errorKey) => { 192 | errors[path] = control.errors![errorKey]; 193 | }); 194 | } 195 | if (childControl) { 196 | collect(childControl, controlPath); 197 | } 198 | }); 199 | } else { 200 | if (control.errors && control.enabled) { 201 | Object.keys(control.errors).forEach((errorKey) => { 202 | errors[path] = control.errors![errorKey]; 203 | }); 204 | } 205 | } 206 | } 207 | 208 | collect(form, ''); 209 | if (form.errors && form.errors!['errors']) { 210 | errors[ROOT_FORM] = form.errors && form.errors!['errors']; 211 | } 212 | 213 | return errors; 214 | } 215 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/utils/shape-validation.spec.ts: -------------------------------------------------------------------------------- 1 | import { ShapeMismatchError, validateShape } from './shape-validation'; 2 | 3 | describe('validateShape function', () => { 4 | it('should not throw error when form value matches the shape', () => { 5 | const formValue = { 6 | name: 'John', 7 | age: 30, 8 | address: { 9 | city: 'New York', 10 | zip: 12345, 11 | }, 12 | }; 13 | 14 | const shape = { 15 | name: '', 16 | age: 0, 17 | address: { 18 | city: '', 19 | zip: 0, 20 | }, 21 | }; 22 | 23 | expect(() => { 24 | validateShape(formValue, shape); 25 | }).not.toThrow(); 26 | }); 27 | 28 | it('should throw ShapeMismatchError with correct ngModel error message', () => { 29 | const formValue = { 30 | name: 'John', 31 | age: 30, 32 | addresss: { 33 | city: 'New York', 34 | zip: 12345, 35 | }, 36 | }; 37 | 38 | const shape = { 39 | name: '', 40 | age: 0, 41 | // Intentional typo, should throw error 42 | address: { 43 | city: '', 44 | zip: 0, 45 | }, 46 | }; 47 | 48 | try { 49 | validateShape(formValue, shape); 50 | } catch (error) { 51 | expect(error).toBeInstanceOf(ShapeMismatchError); 52 | expect((error as ShapeMismatchError).message).toContain( 53 | `[ngModelGroup] Mismatch: 'addresss'` 54 | ); 55 | expect((error as ShapeMismatchError).message).toContain( 56 | `[ngModel] Mismatch 'addresss.city'` 57 | ); 58 | expect((error as ShapeMismatchError).message).toContain( 59 | `[ngModel] Mismatch 'addresss.zip'` 60 | ); 61 | } 62 | }); 63 | 64 | it('should throw ShapeMismatchError with correct ngModelGroup error message', () => { 65 | const formValue = { 66 | name: 'John', 67 | age: 30, 68 | address: { 69 | city: 'New York', 70 | zip: 12345, 71 | }, 72 | }; 73 | 74 | const shape = { 75 | name: '', 76 | age: 0, 77 | // Intentional typo, should throw error 78 | address: { 79 | city: '', 80 | zip: 0, 81 | }, 82 | // Intentional typo, should throw error 83 | contact: { 84 | email: '', 85 | phone: '', 86 | }, 87 | }; 88 | 89 | try { 90 | validateShape(formValue, shape); 91 | } catch (error) { 92 | expect(error).toBeInstanceOf(ShapeMismatchError); 93 | expect((error as ShapeMismatchError).message).toContain( 94 | "[ngModelGroup] Mismatch: 'contact'" 95 | ); 96 | } 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/lib/utils/shape-validation.ts: -------------------------------------------------------------------------------- 1 | import { isDevMode } from '@angular/core'; 2 | 3 | /** 4 | * Clean error that improves the DX when making typo's in the `name` or `ngModelGroup` attributes 5 | */ 6 | export class ShapeMismatchError extends Error { 7 | constructor(errorList: string[]) { 8 | super(`Shape mismatch:\n\n${errorList.join('\n')}\n\n`); 9 | } 10 | } 11 | 12 | /** 13 | * Validates a form value against a shape 14 | * When there is something in the form value that is not in the shape, throw an error 15 | * This is how we throw runtime errors in develop when the developer has made a typo in the `name` or `ngModelGroup` 16 | * attributes. 17 | * @param formVal 18 | * @param shape 19 | */ 20 | export function validateShape( 21 | formVal: Record, 22 | shape: Record 23 | ): void { 24 | // Only execute in dev mode 25 | if (isDevMode()) { 26 | const errors = validateFormValue(formVal, shape); 27 | if (errors.length) { 28 | throw new ShapeMismatchError(errors); 29 | } 30 | } 31 | } 32 | 33 | /** 34 | * Validates a form value against a shape value to see if it matches 35 | * Returns clean errors that have a good DX 36 | * @param formValue 37 | * @param shape 38 | * @param path 39 | */ 40 | function validateFormValue( 41 | formValue: Record, 42 | shape: Record, 43 | path: string = '' 44 | ): string[] { 45 | const errors: string[] = []; 46 | for (const key in formValue) { 47 | if (Object.keys(formValue).includes(key)) { 48 | // In form arrays we don't know how many items there are 49 | // This means that we always need to provide one record in the shape of our form array 50 | // so every time reset the key to '0' when the key is a number and is bigger than 0 51 | let keyToCompareWith = key; 52 | if (parseFloat(key) > 0) { 53 | keyToCompareWith = '0'; 54 | } 55 | const newPath = path ? `${path}.${key}` : key; 56 | if (typeof formValue[key] === 'object' && formValue[key] !== null) { 57 | if ( 58 | (typeof shape[keyToCompareWith] !== 'object' || 59 | shape[keyToCompareWith] === null) && 60 | isNaN(parseFloat(key)) 61 | ) { 62 | errors.push(`[ngModelGroup] Mismatch: '${newPath}'`); 63 | } 64 | errors.push( 65 | ...validateFormValue(formValue[key], shape[keyToCompareWith], newPath) 66 | ); 67 | } else if ((shape ? !(key in shape) : true) && isNaN(parseFloat(key))) { 68 | errors.push(`[ngModel] Mismatch '${newPath}'`); 69 | } 70 | } 71 | } 72 | return errors; 73 | } 74 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/public-api.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Public API Surface of ngx-vest-forms 3 | */ 4 | 5 | export { vestForms, vestFormsViewProviders } from './lib/exports'; 6 | export { DeepPartial } from './lib/utils/deep-partial'; 7 | export { DeepRequired } from './lib/utils/deep-required'; 8 | export { 9 | set, 10 | cloneDeep, 11 | getAllFormErrors, 12 | getFormControlField, 13 | getFormGroupField, 14 | mergeValuesAndRawValues, 15 | } from './lib/utils/form-utils'; 16 | export { 17 | validateShape, 18 | ShapeMismatchError, 19 | } from './lib/utils/shape-validation'; 20 | export { arrayToObject } from './lib/utils/array-to-object'; 21 | export { ROOT_FORM } from './lib/constants'; 22 | export { ControlWrapperComponent } from './lib/components/control-wrapper/control-wrapper.component'; 23 | export { FormDirective } from './lib/directives/form.directive'; 24 | export { FormModelDirective } from './lib/directives/form-model.directive'; 25 | export { FormModelGroupDirective } from './lib/directives/form-model-group.directive'; 26 | export { ValidateRootFormDirective } from './lib/directives/validate-root-form.directive'; 27 | export { ValidationOptions } from './lib/directives/validation-options'; 28 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/src/setup-jest.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'jest'; 2 | import 'jest-preset-angular/setup-jest'; 3 | const config: Config = { 4 | verbose: true, 5 | }; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/lib", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "inlineSources": true, 9 | "types": [] 10 | }, 11 | "exclude": ["**/*.spec.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/tsconfig.lib.prod.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.lib.json", 4 | "compilerOptions": { 5 | "declarationMap": false 6 | }, 7 | "angularCompilerOptions": { 8 | "compilationMode": "partial" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /projects/ngx-vest-forms/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "../../tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "../../out-tsc/spec", 6 | "module": "CommonJs", 7 | "types": ["jest"] 8 | }, 9 | "include": ["**/*.spec.ts", "**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./projects/examples/src/**/*.{html,ts}", 5 | ], 6 | darkMode: 'class', 7 | theme: { 8 | extend: { 9 | colors: { 10 | primary: { 11 | 500: '#0CC', // Replace with your color code 12 | 600: '#2CC', // Replace with your color code 13 | }, 14 | }, 15 | }, 16 | }, 17 | variants: { 18 | extend: { 19 | ringColor: ['focus'], 20 | borderColor: ['focus'], 21 | }, 22 | }, 23 | plugins: [ 24 | require('@tailwindcss/forms'), 25 | ], 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "sourceMap": true, 14 | "paths": { 15 | "ngx-vest-forms": [ 16 | "dist/ngx-vest-forms" 17 | ], 18 | }, 19 | "declaration": false, 20 | "downlevelIteration": true, 21 | "experimentalDecorators": true, 22 | "moduleResolution": "node", 23 | "importHelpers": true, 24 | "target": "ES2022", 25 | "module": "ES2022", 26 | "useDefineForClassFields": false, 27 | "lib": [ 28 | "ES2022", 29 | "dom" 30 | ] 31 | }, 32 | "angularCompilerOptions": { 33 | "enableI18nLegacyMessageIdFormat": false, 34 | "strictInjectionParameters": true, 35 | "strictInputAccessModifiers": true, 36 | "strictTemplates": true 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "module": "CommonJs", 7 | "types": [ 8 | "jest" 9 | ] 10 | }, 11 | "include": [ 12 | "**/*.spec.ts", 13 | "**/*.d.ts" 14 | ] 15 | } 16 | --------------------------------------------------------------------------------