├── .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 |
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 |
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 | First name
192 |
193 |
194 | @if(lastNameAvailable()){
195 | Last name
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 | First name
216 |
217 |
218 | Last name
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 |
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 | First name
344 |
345 |
346 |
347 | Last name
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 |
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 |
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 |
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 | [](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 |
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 |
Fetch data
52 |
53 |
54 |
55 | {{ vm.errors?.['rootForm'] }}
56 |
57 | @if (vm.errors?.['rootForm']) {
58 |
62 | {{ vm.errors?.['rootForm'] }}
63 |
64 | }
65 |
66 |
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 |
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 |
3 | From
4 |
11 |
12 |
13 |
14 |
15 | To
16 |
23 |
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 |
14 |
15 |
16 |
17 |
18 | }
19 |
20 |
21 |
22 |
23 |
26 |
32 |
33 |
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 |
2 | Phonenumbers
3 |
4 | @for (item of values | keyvalue; track item.key) {
5 |
6 |
7 |
13 |
18 | Remove
19 |
20 |
21 |
22 | }
23 |
24 |
25 |
26 |
32 |
37 | Add
38 |
39 |
40 |
41 |
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 |
19 |
26 |
29 |
30 |
37 |
42 |
43 |
44 |
45 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------