├── .gitignore ├── .idea ├── .gitignore ├── modules.xml ├── template-driven-forms.iml └── vcs.xml ├── README.md ├── angular.json ├── package-lock.json ├── package.json ├── src ├── app │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.ts │ ├── array-to-object.ts │ ├── components │ │ ├── address │ │ │ ├── address.component.html │ │ │ ├── address.component.scss │ │ │ └── address.component.ts │ │ ├── phonenumbers │ │ │ ├── phonenumbers.component.html │ │ │ ├── phonenumbers.component.scss │ │ │ └── phonenumbers.component.ts │ │ └── purchase-form │ │ │ ├── purchase-form.component.html │ │ │ ├── purchase-form.component.scss │ │ │ └── purchase-form.component.ts │ ├── luke.service.ts │ ├── models │ │ ├── address.model.ts │ │ ├── form.model.ts │ │ └── phonenumber.model.ts │ ├── product.service.ts │ ├── product.type.ts │ ├── swapi.service.ts │ ├── template-driven-forms │ │ ├── control-wrapper │ │ │ ├── control-wrapper.component.html │ │ │ ├── control-wrapper.component.scss │ │ │ └── control-wrapper.component.ts │ │ ├── deep-partial.ts │ │ ├── deep-required.ts │ │ ├── form-model-group.directive.ts │ │ ├── form-model.directive.ts │ │ ├── form.directive.ts │ │ ├── shape-validation.ts │ │ ├── template-driven.forms.ts │ │ └── utils.ts │ └── validations │ │ ├── address.validations.ts │ │ ├── phonenumber.validations.ts │ │ └── purchase.validations.ts ├── assets │ ├── .gitkeep │ ├── course.jpg │ └── simplified-logo.png ├── favicon.ico ├── global_styles.scss ├── index.html ├── karma.conf.js ├── main.ts ├── polyfills.ts ├── tsconfig.app.json └── tsconfig.spec.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .angular 2 | dist 3 | node_modules 4 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/template-driven-forms.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # template-driven-forms 2 | 3 | This code is part of the E-course [Advanced Template-Driven Forms](https://www.simplified.courses/complex-angular-template-driven-forms) 4 | 5 | ![Alt text](src/assets/course.jpeg) 6 | 7 | I open-sourced this to help developers use Template-driven Forms: 8 | - Without any Boilerplate 9 | - With a focus onReactivity 10 | - With Declarative code 11 | - With Model validations 12 | 13 | This project will also be the default project that will be used on the [Template-driven forms playlist](https://www.youtube.com/watch?v=djod9on45wc&list=PLTItqHpooUL4SWBZmVIYXCOTFDaOFdU5N) of Simplified YouTube Channel. 14 | 15 | The demo application has the following functionality: 16 | - Show validation errors on blur 17 | - Show validation errors on submit 18 | - When first name is Brecht: Set gender to male 19 | - When first name is Brecht and last name is Billiet: Set age and passwords 20 | - When first name is Luke: Fetch Luke Skywalker from the swapi api 21 | - When age is below 18, make Emergency contact required 22 | - When age is of legal age, disable Emergency contact 23 | - There should be at least one phone number 24 | - Phone numbers should not be empty 25 | - When gender is other, show Specify gender 26 | - When gender is other, make Specify gender required 27 | - Password is required 28 | - Confirm password is only required when password is filled in 29 | - Passwords should match, but only check if both are filled in 30 | - Billing address is required 31 | - Show shipping address only when needed (otherwise remove from DOM) 32 | - If shipping address is different from billing address, make it required 33 | - If shipping address is different from billing address, make sure they are not the same 34 | - When providing shipping address and toggling the checkbox back and forth, make sure the state is kept 35 | - When clicking the Fetch data button, load data, disable the form, and patch and re-enable the form 36 | 37 | [Edit in Codeflow ⚡️](https://stackblitz.com/~/github.com/simplifiedcourses/template-driven-forms) -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "cli": { 4 | "analytics": "1e1de97b-a744-405a-8b5a-0397bb3d01ce" 5 | }, 6 | "newProjectRoot": "projects", 7 | "projects": { 8 | "demo": { 9 | "architect": { 10 | "build": { 11 | "builder": "@angular-devkit/build-angular:browser", 12 | "configurations": { 13 | "development": { 14 | "buildOptimizer": false, 15 | "extractLicenses": false, 16 | "namedChunks": true, 17 | "optimization": false, 18 | "sourceMap": true, 19 | "vendorChunk": true 20 | }, 21 | "production": { 22 | "aot": true, 23 | "buildOptimizer": true, 24 | "extractLicenses": true, 25 | "fileReplacements": [ 26 | { 27 | "replace": "src/environments/environment.ts", 28 | "with": "src/environments/environment.prod.ts" 29 | } 30 | ], 31 | "namedChunks": false, 32 | "optimization": true, 33 | "outputHashing": "all", 34 | "sourceMap": false, 35 | "vendorChunk": false 36 | } 37 | }, 38 | "options": { 39 | "index": "src/index.html", 40 | "main": "src/main.ts", 41 | "outputPath": "dist/demo", 42 | "scripts": [], 43 | "assets": [ 44 | "src/favicon.ico", 45 | "src/assets" 46 | ], 47 | "styles": ["src/global_styles.scss"], 48 | "tsConfig": "src/tsconfig.app.json" 49 | } 50 | }, 51 | "extract-i18n": { 52 | "builder": "@angular-devkit/build-angular:extract-i18n", 53 | "options": { 54 | "buildTarget": "demo:build" 55 | } 56 | }, 57 | "lint": { 58 | "builder": "@angular-devkit/build-angular:tslint", 59 | "options": { 60 | "exclude": ["**/node_modules/**"], 61 | "tsConfig": ["src/tsconfig.app.json", "src/tsconfig.spec.json"] 62 | } 63 | }, 64 | "serve": { 65 | "builder": "@angular-devkit/build-angular:dev-server", 66 | "configurations": { 67 | "development": { 68 | "buildTarget": "demo:build:development" 69 | }, 70 | "production": { 71 | "buildTarget": "demo:build:production" 72 | } 73 | }, 74 | "defaultConfiguration": "development" 75 | }, 76 | "test": { 77 | "builder": "@angular-devkit/build-angular:karma", 78 | "options": { 79 | "assets": ["src/favicon.ico", "src/assets"], 80 | "karmaConfig": "src/karma.conf.js", 81 | "main": "src/test.ts", 82 | "polyfills": [], 83 | "scripts": [], 84 | "styles": ["styles.css"], 85 | "tsConfig": "src/tsconfig.spec.json" 86 | } 87 | } 88 | }, 89 | "prefix": "app", 90 | "projectType": "application", 91 | "root": "", 92 | "schematics": {}, 93 | "sourceRoot": "src" 94 | } 95 | }, 96 | "version": 1 97 | } 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stackblitz-starters-nijaaf", 3 | "version": "0.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@angular/animations": "^18.0.1", 7 | "@angular/common": "^18.0.1", 8 | "@angular/compiler": "^18.0.1", 9 | "@angular/core": "^18.0.1", 10 | "@angular/forms": "^18.0.1", 11 | "@angular/platform-browser": "^18.0.1", 12 | "@angular/router": "^18.0.1", 13 | "lodash": "^4.17.21", 14 | "rxjs": "^7.8.1", 15 | "tslib": "^2.5.0", 16 | "zone.js": "~0.14.6", 17 | "vest": "^5.2.8" 18 | }, 19 | "scripts": { 20 | "ng": "ng", 21 | "start": "ng serve", 22 | "build": "ng build", 23 | "test": "ng test", 24 | "lint": "ng lint", 25 | "e2e": "ng e2e" 26 | }, 27 | "devDependencies": { 28 | "@angular-devkit/build-angular": "^18.0.2", 29 | "@angular/cli": "^18.0.2", 30 | "@angular/compiler-cli": "^18.0.1", 31 | "typescript": "~5.4.5", 32 | "@types/lodash": "^4.14.200" 33 | }, 34 | "overrides": { 35 | "@angular-devkit/build-angular": { 36 | "piscina": "~4.2.0" 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | width: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | align-items: center; 6 | } 7 | 8 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { PurchaseFormComponent } from './components/purchase-form/purchase-form.component'; 3 | 4 | @Component({ 5 | selector: 'app-root', 6 | imports: [PurchaseFormComponent], 7 | standalone: true, 8 | templateUrl: './app.component.html', 9 | styleUrls: ['./app.component.scss'] 10 | }) 11 | export class AppComponent { 12 | title = 'purchase'; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/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 | -------------------------------------------------------------------------------- /src/app/components/address/address.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 7 |
8 |
9 | 13 |
14 |
15 |
16 |
17 | 21 |
22 |
23 | 27 |
28 |
29 |
30 | 34 |
35 | -------------------------------------------------------------------------------- /src/app/components/address/address.component.scss: -------------------------------------------------------------------------------- 1 | sc-address { 2 | width: 100%; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/components/address/address.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, ViewEncapsulation } from '@angular/core'; 2 | 3 | import { AddressModel } from '../../models/address.model'; 4 | import { 5 | templateDrivenForms, 6 | templateDrivenFormsViewProviders 7 | } from '../../template-driven-forms/template-driven.forms'; 8 | 9 | @Component({ 10 | selector: 'sc-address', 11 | standalone: true, 12 | imports: [templateDrivenForms], 13 | viewProviders: [templateDrivenFormsViewProviders], 14 | encapsulation: ViewEncapsulation.None, 15 | templateUrl: './address.component.html', 16 | styleUrls: ['./address.component.scss'] 17 | }) 18 | export class AddressComponent { 19 | @Input() address?: AddressModel; 20 | } 21 | -------------------------------------------------------------------------------- /src/app/components/phonenumbers/phonenumbers.component.html: -------------------------------------------------------------------------------- 1 | 24 | -------------------------------------------------------------------------------- /src/app/components/phonenumbers/phonenumbers.component.scss: -------------------------------------------------------------------------------- 1 | .phonenumbers, .phonenumbers__values { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | .phonenumbers__input-with-button { 7 | display: flex; 8 | width: 100%; 9 | input { 10 | flex: 1; 11 | } 12 | } 13 | 14 | button { 15 | margin-left: 8px; 16 | } 17 | 18 | -------------------------------------------------------------------------------- /src/app/components/phonenumbers/phonenumbers.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input } from '@angular/core'; 2 | import { CommonModule, KeyValuePipe } from '@angular/common'; 3 | import { 4 | templateDrivenForms, 5 | templateDrivenFormsViewProviders 6 | } from '../../template-driven-forms/template-driven.forms'; 7 | import { arrayToObject } from '../../array-to-object'; 8 | 9 | @Component({ 10 | selector: 'sc-phonenumbers', 11 | standalone: true, 12 | imports: [CommonModule, templateDrivenForms, KeyValuePipe], 13 | templateUrl: './phonenumbers.component.html', 14 | styleUrls: ['./phonenumbers.component.scss'], 15 | viewProviders: [templateDrivenFormsViewProviders] 16 | }) 17 | export class PhonenumbersComponent { 18 | @Input() public phonenumbers: { [key: string]: string } = {}; 19 | public addValue = ''; 20 | 21 | protected tracker = (i: number) => i; 22 | 23 | public addPhonenumber(): void { 24 | const phoneNumbers = [...Object.values(this.phonenumbers), this.addValue]; 25 | this.phonenumbers = arrayToObject(phoneNumbers); 26 | this.addValue = ''; 27 | } 28 | 29 | public removePhonenumber(key: string): void { 30 | const phonenumbers = Object.values(this.phonenumbers).filter( 31 | (v, index) => index !== Number(key)) 32 | this.phonenumbers = arrayToObject(phonenumbers) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/app/components/purchase-form/purchase-form.component.html: -------------------------------------------------------------------------------- 1 |

Test it out

2 | 25 | 26 |
27 |
33 |
34 |

Purchase form

35 |
36 | 40 |
41 |
42 |
43 | 47 |
48 |
49 | 53 |
54 |
55 |
56 |
57 | 61 |
62 |
63 | 68 |
69 |
70 |
71 | 72 | 73 |
74 |
75 | 107 |
108 | @if (vm.showGenderOther) { 109 |
110 | 115 |
116 | } 117 |
118 |
119 |
120 | 124 |
125 |
126 | 130 |
131 |
132 |
133 |
134 | 142 |
143 |
144 |
145 |

Billing address

146 | 147 |
148 | 154 | @if (vm.showShippingAddress) { 155 |
156 |

Shipping Address

157 | 158 |
159 | } 160 |
161 |
162 |   163 | 164 |
165 |
166 | 167 |
168 |
169 |

Valid: {{vm.formValid}}

170 | 171 |

The value of the form

172 |
173 |       {{vm.formValue|json}}
174 |     
175 | -------------------------------------------------------------------------------- /src/app/components/purchase-form/purchase-form.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | width: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | align-items:center; 6 | p, ul { 7 | width: 500px; 8 | margin: 0px; 9 | padding: 0px; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/app/components/purchase-form/purchase-form.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, computed, effect, inject, signal, ViewChild } from '@angular/core'; 2 | import { CommonModule } from '@angular/common'; 3 | import { ProductService } from '../../product.service'; 4 | import { toObservable, toSignal } from '@angular/core/rxjs-interop'; 5 | import { FormModel, formShape } from '../../models/form.model'; 6 | import { AddressComponent } from '../address/address.component'; 7 | import { debounceTime, filter, switchMap } from 'rxjs'; 8 | import { LukeService } from '../../luke.service'; 9 | import { PhonenumbersComponent } from '../phonenumbers/phonenumbers.component'; 10 | import { AddressModel } from '../../models/address.model'; 11 | import { templateDrivenForms } from '../../template-driven-forms/template-driven.forms'; 12 | import { validateShape } from '../../template-driven-forms/shape-validation'; 13 | import { createPurchaseValidationSuite } from '../../validations/purchase.validations'; 14 | import { SwapiService } from 'src/app/swapi.service'; 15 | 16 | @Component({ 17 | selector: 'sc-purchase-form', 18 | standalone: true, 19 | imports: [CommonModule, templateDrivenForms, AddressComponent, PhonenumbersComponent], 20 | templateUrl: './purchase-form.component.html', 21 | styleUrls: ['./purchase-form.component.scss'] 22 | }) 23 | export class PurchaseFormComponent { 24 | private readonly lukeService = inject(LukeService); 25 | private readonly swapiService = inject(SwapiService); 26 | private readonly productService = inject(ProductService); 27 | public readonly products = toSignal(this.productService.getAll()); 28 | protected readonly formValue = signal({}); 29 | protected readonly formValid = signal(false); 30 | protected readonly loading = signal(false); 31 | protected readonly suite = createPurchaseValidationSuite(this.swapiService); 32 | private readonly shippingAddress = signal({}); 33 | 34 | private readonly viewModel = computed(() => { 35 | return { 36 | formValue: this.formValue(), 37 | formValid: this.formValid(), 38 | emergencyContactDisabled: (this.formValue().age || 0) >= 18, 39 | showShippingAddress: this.formValue().addresses?.shippingAddressDifferentFromBillingAddress, 40 | showGenderOther: this.formValue().gender === 'other', 41 | shippingAddress: this.formValue().addresses?.shippingAddress || this.shippingAddress(), 42 | loading: this.loading() 43 | } 44 | }); 45 | 46 | protected readonly validationConfig: { 47 | [key: string]: string[]; 48 | } = { 49 | 'age': ['emergencyContact'], 50 | 'passwords.password': ['passwords.confirmPassword'], 51 | 'gender': ['genderOther'] 52 | }; 53 | 54 | constructor() { 55 | const firstName = computed(() => this.formValue().firstName); 56 | const lastName = computed(() => this.formValue().lastName); 57 | effect( 58 | () => { 59 | if (firstName() === 'Brecht') { 60 | this.formValue.update((val) => ({ 61 | ...val, 62 | gender: 'male' 63 | })); 64 | } 65 | if (firstName() === 'Brecht' && lastName() === 'Billiet') { 66 | this.formValue.update((val) => ({ 67 | ...val, 68 | age: 35, 69 | passwords: { 70 | password: 'Test1234', 71 | confirmPassword: 'Test12345' 72 | } 73 | })); 74 | } 75 | }, 76 | { allowSignalWrites: true } 77 | ); 78 | 79 | toObservable(firstName) 80 | .pipe( 81 | debounceTime(1000), 82 | filter(v => v === 'Luke'), 83 | switchMap(() => this.lukeService.getLuke()) 84 | ) 85 | .subscribe((luke) => { 86 | this.formValue.update(v => ({ ...v, ...luke })) 87 | }) 88 | } 89 | 90 | protected setFormValue(v: FormModel): void { 91 | this.formValue.set(v); 92 | validateShape(v, formShape); 93 | if (v.addresses?.shippingAddress) { 94 | this.shippingAddress.set(v.addresses.shippingAddress); 95 | } 96 | } 97 | 98 | protected get vm() { 99 | return this.viewModel(); 100 | } 101 | 102 | protected onSubmit(): void { 103 | if (this.formValid()) { 104 | console.log(this.formValue()) 105 | } 106 | } 107 | 108 | protected fetchData() { 109 | this.loading.set(true); 110 | this.lukeService.getLuke().subscribe((luke) => { 111 | this.formValue.update(v => ({ ...v, ...luke })) 112 | this.loading.set(false); 113 | }) 114 | } 115 | }; 116 | -------------------------------------------------------------------------------- /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<{ firstName: string, lastName: string, gender: 'male' | 'female' | 'other' }> { 10 | return this.httpClient.get('https://swapi.dev/api/people/1').pipe( 11 | map((resp: any) => { 12 | const name = resp.name.split(' '); 13 | return { 14 | firstName: name[0], 15 | lastName: name[1], 16 | gender: resp.gender, 17 | }; 18 | }) 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/app/models/address.model.ts: -------------------------------------------------------------------------------- 1 | import { DeepRequired } from '../template-driven-forms/deep-required'; 2 | 3 | export type AddressModel = Partial<{ 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 | -------------------------------------------------------------------------------- /src/app/models/form.model.ts: -------------------------------------------------------------------------------- 1 | import { AddressModel, addressShape } from './address.model'; 2 | import { PhonenumberModel, phonenumberShape } from './phonenumber.model'; 3 | import { DeepRequired } from '../template-driven-forms/deep-required'; 4 | 5 | export type FormModel = Partial<{ 6 | userId: string; 7 | firstName: string; 8 | lastName: string; 9 | age: number; 10 | emergencyContact: string; 11 | passwords: Partial<{ 12 | password: string; 13 | confirmPassword?: string; 14 | }>; 15 | phonenumbers: PhonenumberModel; 16 | gender: 'male' | 'female' | 'other'; 17 | genderOther: string; 18 | productId: string; 19 | addresses: Partial<{ 20 | shippingAddress: Partial; 21 | billingAddress: Partial; 22 | shippingAddressDifferentFromBillingAddress: boolean; 23 | }> 24 | }> 25 | 26 | 27 | export const formShape: DeepRequired = { 28 | userId: '', 29 | firstName: '', 30 | lastName: '', 31 | age: 0, 32 | emergencyContact: '', 33 | addresses: { 34 | shippingAddress: addressShape, 35 | billingAddress: addressShape, 36 | shippingAddressDifferentFromBillingAddress: true 37 | }, 38 | passwords: { 39 | password: '', 40 | confirmPassword: '' 41 | }, 42 | phonenumbers:phonenumberShape, 43 | gender: 'other', 44 | genderOther: '', 45 | productId: '' 46 | } 47 | -------------------------------------------------------------------------------- /src/app/models/phonenumber.model.ts: -------------------------------------------------------------------------------- 1 | import { DeepRequired } from '../template-driven-forms/deep-required'; 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/app/product.type.ts: -------------------------------------------------------------------------------- 1 | export type Product = { 2 | name: string; 3 | id: string; 4 | } 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/app/template-driven-forms/control-wrapper/control-wrapper.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 |
5 |
    6 | @for (error of errors; track error) { 7 |
  • {{error}}
  • 8 | } 9 |
10 |
11 | -------------------------------------------------------------------------------- /src/app/template-driven-forms/control-wrapper/control-wrapper.component.scss: -------------------------------------------------------------------------------- 1 | .input-wrapper__errors { 2 | margin-top: 8px; 3 | li { 4 | list-style-type: none; 5 | border-color: #774771; 6 | font-weight:600; 7 | } 8 | ul { 9 | padding: 0px; 10 | margin: 0px; 11 | } 12 | } 13 | :host { 14 | display: flex; 15 | width: 100%; 16 | flex-direction: column; 17 | padding-bottom: 8px; 18 | } 19 | -------------------------------------------------------------------------------- /src/app/template-driven-forms/control-wrapper/control-wrapper.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ContentChild, HostBinding, inject } from '@angular/core'; 2 | 3 | import { AbstractControl, NgModel, NgModelGroup } from '@angular/forms'; 4 | 5 | @Component({ 6 | selector: '[scControlWrapper]', 7 | standalone: true, 8 | imports: [], 9 | templateUrl: './control-wrapper.component.html', 10 | styleUrls: ['./control-wrapper.component.scss'] 11 | }) 12 | export class ControlWrapperComponent { 13 | // Cache the previous error to avoi 'flickering' 14 | private previousError?: string[]; 15 | @ContentChild(NgModel) public ngModel?: NgModel; // Optional ngModel 16 | 17 | // Optional ngModelGroup 18 | public readonly ngModelGroup: NgModelGroup | null = inject(NgModelGroup, { 19 | optional: true, 20 | self: true, 21 | }); 22 | 23 | private get control(): AbstractControl|undefined { 24 | return this.ngModelGroup ? this.ngModelGroup.control : this.ngModel?.control; 25 | } 26 | 27 | @HostBinding('class.input-wrapper--invalid') 28 | public get invalid() { 29 | return this.control?.touched && this.errors;//&& this.previousError; 30 | } 31 | 32 | public get errors(): string[] | undefined { 33 | if (this.control?.pending) { 34 | return this.previousError; 35 | } else { 36 | this.previousError = this.control?.errors?.['errors']; 37 | } 38 | return this.control?.errors?.['errors']; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/template-driven-forms/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 | }; -------------------------------------------------------------------------------- /src/app/template-driven-forms/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 | -------------------------------------------------------------------------------- /src/app/template-driven-forms/form-model-group.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, inject } from '@angular/core'; 2 | import { AbstractControl, AsyncValidator, NG_ASYNC_VALIDATORS, ValidationErrors } from '@angular/forms'; 3 | import { FormDirective } from './form.directive'; 4 | import { getFormGroupField } from './utils'; 5 | import { Observable, of } from 'rxjs'; 6 | 7 | @Directive({ 8 | selector: '[ngModelGroup]', 9 | standalone: true, 10 | providers: [ 11 | { provide: NG_ASYNC_VALIDATORS, useExisting: FormModelGroupDirective, multi: true }, 12 | ], 13 | }) 14 | export class FormModelGroupDirective implements AsyncValidator { 15 | private readonly formDirective = inject(FormDirective); 16 | 17 | public validate(control: AbstractControl): Observable { 18 | const { ngForm, suite, formValue } = this.formDirective; 19 | if (!suite || !formValue) { 20 | return of(null); 21 | } 22 | const field = getFormGroupField(ngForm.control, control); 23 | return this.formDirective.createAsyncValidator(field, formValue, suite)(control.value) as Observable 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/template-driven-forms/form-model.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, inject } from '@angular/core'; 2 | import { AbstractControl, AsyncValidator, NG_ASYNC_VALIDATORS, ValidationErrors } from '@angular/forms'; 3 | import { FormDirective } from './form.directive'; 4 | import { getFormControlField } from './utils'; 5 | import { Observable, of } from 'rxjs'; 6 | 7 | @Directive({ 8 | selector: '[ngModel]', 9 | standalone: true, 10 | providers: [ 11 | { provide: NG_ASYNC_VALIDATORS, useExisting: FormModelDirective, multi: true }, 12 | ], 13 | }) 14 | export class FormModelDirective implements AsyncValidator { 15 | private readonly formDirective = inject(FormDirective); 16 | public validate(control: AbstractControl): Observable { 17 | const { ngForm, suite, formValue } = this.formDirective; 18 | if (!suite || !formValue) { 19 | throw of(null); 20 | } 21 | const field = getFormControlField(ngForm.control, control); 22 | return this.formDirective.createAsyncValidator(field, formValue, suite)(control.getRawValue()) as Observable 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/template-driven-forms/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 | BehaviorSubject, 12 | debounceTime, 13 | distinctUntilChanged, 14 | filter, 15 | map, 16 | Observable, 17 | of, 18 | ReplaySubject, 19 | Subject, 20 | switchMap, 21 | take, 22 | takeUntil, 23 | tap, 24 | zip 25 | } from 'rxjs'; 26 | import { StaticSuite } from 'vest'; 27 | import { cloneDeep, set } from 'lodash'; 28 | import { mergeValuesAndRawValues } from './utils'; 29 | 30 | @Directive({ 31 | selector: 'form', 32 | standalone: true, 33 | }) 34 | export class FormDirective implements OnDestroy { 35 | public readonly ngForm = inject(NgForm, { self: true }); 36 | @Input() public formValue: T | null = null; 37 | @Input() public suite: StaticSuite void> | null = null; 38 | 39 | private readonly statusChanges$ = this.ngForm.form.events.pipe( 40 | filter(v => v instanceof StatusChangeEvent), 41 | map(v => (v as StatusChangeEvent).status), 42 | distinctUntilChanged() 43 | ); 44 | 45 | private readonly idle$ = this.statusChanges$.pipe( 46 | filter(v => v !== 'PENDING'), 47 | distinctUntilChanged() 48 | ); 49 | 50 | private readonly valueChanges$ = this.ngForm.form.events.pipe( 51 | filter(v => v instanceof ValueChangeEvent), 52 | map(v => (v as ValueChangeEvent).value), 53 | map(() => mergeValuesAndRawValues(this.ngForm.form)), 54 | ); 55 | 56 | private readonly dirtyChanges$ = this.ngForm.form.events.pipe( 57 | filter(v => v instanceof PristineChangeEvent), 58 | map(v => !(v as PristineChangeEvent).pristine), 59 | distinctUntilChanged() 60 | ); 61 | 62 | private readonly validChanges$ = this.statusChanges$.pipe( 63 | filter(e => e === 'VALID' || e === 'INVALID'), 64 | map(v => v === 'VALID'), 65 | distinctUntilChanged() 66 | ); 67 | 68 | /** 69 | * Triggered as soon as the form value changes 70 | */ 71 | @Output() public readonly formValueChange = this.valueChanges$; 72 | 73 | /** 74 | * Triggered as soon as the form becomes dirty 75 | */ 76 | @Output() public readonly dirtyChange = this.dirtyChanges$; 77 | 78 | /** 79 | * TriggerdWhen the form becomes valid 80 | */ 81 | @Output() public readonly validChange = this.validChanges$; 82 | 83 | /** 84 | * Used to debounce formValues to make sure vest isn't triggered all the time 85 | */ 86 | private readonly formValueCache: { 87 | [field: string]: Partial<{ 88 | sub$$: ReplaySubject; 89 | debounced: Observable; 90 | }>; 91 | } = {}; 92 | 93 | /** 94 | * Contains the ValidationConfig in a BehaviorSubject. We use a subject because the ValidationConfig has to be 95 | * dynamic and reactive 96 | * @private 97 | */ 98 | private readonly validationConfig$$ = new BehaviorSubject<{ [key: string]: string[] } | null>(null); 99 | private readonly destroy$$ = new Subject(); 100 | 101 | public constructor() { 102 | this.validationConfig$$ 103 | .pipe( 104 | filter((conf) => !!conf), 105 | switchMap((conf) => { 106 | if (!conf) { 107 | return of(null); 108 | } 109 | const streams = Object.keys(conf).map((key) => { 110 | return this.formValueChange?.pipe( 111 | // wait until the form is idle 112 | switchMap((v) => this.idle$), 113 | map(() => this.ngForm?.form.get(key)?.value), 114 | distinctUntilChanged(), // only trigger dependants when the value actually changed 115 | takeUntil(this.destroy$$), 116 | tap((v) => { 117 | conf[key]?.forEach((path: string) => { 118 | this.ngForm?.form.get(path)?.updateValueAndValidity({ 119 | onlySelf: true, 120 | emitEvent: true 121 | }); 122 | }); 123 | }), 124 | ); 125 | }); 126 | return zip(streams); 127 | }), 128 | ) 129 | .subscribe(); 130 | 131 | /** 132 | * Mark all the fields as touched when the form is submitted 133 | */ 134 | this.ngForm.ngSubmit.subscribe(() => { 135 | this.ngForm.form.markAllAsTouched(); 136 | }); 137 | } 138 | 139 | /** 140 | * Updates the validation config which is a dynamic object that will be used to 141 | * trigger validations on depending fields 142 | * @param v 143 | */ 144 | @Input() 145 | public set validationConfig(v: { [key: string]: string[] }) { 146 | this.validationConfig$$.next(v); 147 | } 148 | 149 | public ngOnDestroy(): void { 150 | this.destroy$$.next(); 151 | } 152 | 153 | /** 154 | * This will feed the formValueCache, debounce it till the next tick 155 | * and create an asynchronous validator that runs a vest suite 156 | * @param field 157 | * @param model 158 | * @param suite 159 | * @returns an asynchronous vlaidator function 160 | */ 161 | public createAsyncValidator( 162 | field: string, 163 | model: T, 164 | suite: 165 | | StaticSuite void> 166 | ): AsyncValidatorFn { 167 | return (value: any) => { 168 | const mod = cloneDeep(model); 169 | set(mod as object, field, value); // Update the property with path 170 | if (!this.formValueCache[field]) { 171 | this.formValueCache[field] = { 172 | sub$$: new ReplaySubject(1), // Keep track of the last model 173 | }; 174 | this.formValueCache[field].debounced = this.formValueCache[field].sub$$!.pipe(debounceTime(0)); 175 | } 176 | // Next the latest model in the cache for a certain field 177 | this.formValueCache[field].sub$$!.next(mod); 178 | 179 | return this.formValueCache[field].debounced!.pipe( 180 | // When debounced, take the latest value and perform the asynchronous vest validation 181 | take(1), 182 | switchMap(() => { 183 | return new Observable((observer) => { 184 | suite(mod, field).done((result) => { 185 | const errors = result.getErrors()[field]; 186 | observer.next((errors ? { error: errors[0], errors } : null)); 187 | observer.complete(); 188 | }); 189 | }) as Observable; 190 | }), 191 | takeUntil(this.destroy$$), 192 | ); 193 | }; 194 | } 195 | } -------------------------------------------------------------------------------- /src/app/template-driven-forms/shape-validation.ts: -------------------------------------------------------------------------------- 1 | import { isDevMode } from '@angular/core'; 2 | 3 | export class ShapeMismatchError extends Error { 4 | constructor(errorList: string[]) { 5 | super(`Shape mismatch:\n\n${errorList.join('\n')}\n\n`); 6 | } 7 | } 8 | 9 | export function validateShape( 10 | val: Record, 11 | shape: Record, 12 | ): void { 13 | if (isDevMode()) { 14 | const errors = validateFormValue(val, shape); 15 | if (errors.length) { 16 | throw new ShapeMismatchError(errors); 17 | } 18 | } 19 | } 20 | 21 | function validateFormValue(formValue: Record, shape: Record, path: string = ''): string[] { 22 | const errors: string[] = []; 23 | for (const key in formValue) { 24 | if (Object.keys(formValue).includes(key)) { 25 | // In form arrays we don't know how many items there are 26 | // so every time reset the key to '0' when the key is a number and is bigger than 0 27 | let keyToCompareWith = key; 28 | if(parseFloat(key) > 0){ 29 | keyToCompareWith = '0'; 30 | } 31 | const newPath = path ? `${path}.${key}` : key; 32 | if (typeof formValue[key] === 'object' && formValue[key] !== null) { 33 | if ((typeof shape[keyToCompareWith] !== 'object' || shape[keyToCompareWith] === null) && isNaN(parseFloat(key))) { 34 | errors.push(`[ngModelGroup] Mismatch: '${newPath}'`); 35 | } 36 | errors.push(...validateFormValue(formValue[key], shape[keyToCompareWith], newPath)); 37 | } else if ((shape ? !(key in shape) : true) && isNaN(parseFloat(key))) { 38 | errors.push(`[ngModel] Mismatch '${newPath}'`); 39 | } 40 | } 41 | } 42 | return errors; 43 | } -------------------------------------------------------------------------------- /src/app/template-driven-forms/template-driven.forms.ts: -------------------------------------------------------------------------------- 1 | import { Optional, Provider } from '@angular/core'; 2 | import { ControlContainer, FormsModule, NgForm, NgModelGroup } from '@angular/forms'; 3 | import { FormDirective } from './form.directive'; 4 | import { FormModelDirective } from './form-model.directive'; 5 | import { FormModelGroupDirective } from './form-model-group.directive'; 6 | import { ControlWrapperComponent } from './control-wrapper/control-wrapper.component'; 7 | 8 | /** 9 | * 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) 10 | * Thank you so much Ward Bell for your effort!: 11 | * 12 | * Provide a ControlContainer to a form component from the 13 | * nearest parent NgModelGroup (preferred) or NgForm. 14 | * 15 | * Required for Reactive Forms as well (unless you write CVA) 16 | * 17 | * @example 18 | * ``` 19 | * @Component({ 20 | * ... 21 | * viewProviders[ formViewProvider ] 22 | * }) 23 | * ``` 24 | * @see Kara's AngularConnect 2017 talk: https://youtu.be/CD_t3m2WMM8?t=1826 25 | * 26 | * Without this provider 27 | * - Controls are not registered with parent NgForm or NgModelGroup 28 | * - Form-level flags say "untouched" and "valid" 29 | * - No form-level validation roll-up 30 | * - Controls still validate, update model, and update their statuses 31 | * - If within NgForm, no compiler error because ControlContainer is optional for ngModel 32 | * 33 | * Note: if the SubForm Component that uses this Provider 34 | * is not within a Form or NgModelGroup, the provider returns `null` 35 | * resulting in an error, something like 36 | * ``` 37 | * preview-fef3604083950c709c52b.js:1 ERROR Error: 38 | * ngModelGroup cannot be used with a parent formGroup directive. 39 | *``` 40 | */ 41 | export const formViewProvider: Provider = { 42 | provide: ControlContainer, 43 | useFactory: _formViewProviderFactory, 44 | deps: [ 45 | [new Optional(), NgForm], 46 | [new Optional(), NgModelGroup] 47 | ] 48 | }; 49 | 50 | export function _formViewProviderFactory( 51 | ngForm: NgForm, ngModelGroup: NgModelGroup 52 | ) { 53 | return ngModelGroup || ngForm || null; 54 | } 55 | 56 | export const templateDrivenFormsViewProviders = [ 57 | { provide: ControlContainer, useExisting: NgForm }, 58 | formViewProvider // very important if we want nested components with ngModelGroup 59 | ] 60 | 61 | export const templateDrivenForms = [ControlWrapperComponent, FormDirective, FormsModule, FormModelDirective, FormModelGroupDirective]; 62 | -------------------------------------------------------------------------------- /src/app/template-driven-forms/utils.ts: -------------------------------------------------------------------------------- 1 | import { AbstractControl, FormGroup} from '@angular/forms'; 2 | 3 | function getControlPath( 4 | rootForm: FormGroup, 5 | controlName: string, 6 | control: AbstractControl 7 | ): string { 8 | for (const key in rootForm.controls) { 9 | if (rootForm.controls.hasOwnProperty(key)) { 10 | const ctrl = rootForm.get(key); 11 | if (ctrl instanceof FormGroup) { 12 | const path = getControlPath(ctrl, controlName, control); 13 | if (path) { 14 | return key + '.' + path; 15 | } 16 | } else if (ctrl === control) { 17 | return key; 18 | } 19 | } 20 | } 21 | return ''; 22 | } 23 | 24 | function getGroupPath( 25 | formGroup: FormGroup, 26 | controlName: string, 27 | control: AbstractControl 28 | ): string { 29 | for (const key in formGroup.controls) { 30 | if (formGroup.controls.hasOwnProperty(key)) { 31 | const ctrl = formGroup.get(key); 32 | if (ctrl === control) { 33 | return key; 34 | } 35 | if (ctrl instanceof FormGroup) { 36 | const path = getGroupPath(ctrl, controlName, control); 37 | if (path) { 38 | return key + '.' + path; 39 | } 40 | } 41 | 42 | } 43 | } 44 | return ''; 45 | } 46 | 47 | /** 48 | * Calculates the name of an abstract control in a form group 49 | * @param formGroup 50 | * @param control 51 | */ 52 | function findControlNameInGroup( 53 | formGroup: 54 | | { [key: string]: AbstractControl } 55 | | AbstractControl[], 56 | control: AbstractControl 57 | ): string { 58 | return ( 59 | Object.keys(formGroup).find( 60 | (name: string) => control === control.parent?.get(name) 61 | ) || '' 62 | ); 63 | } 64 | 65 | /** 66 | * Calculates the field name of a form control: Eg: addresses.shippingAddress.street 67 | * @param rootForm 68 | * @param control 69 | */ 70 | export function getFormControlField(rootForm: FormGroup, control: AbstractControl): string { 71 | const parentFormGroup = control.parent?.controls; 72 | if (!parentFormGroup) { 73 | throw new Error('An ngModel should always be wrapped in a parent FormGroup'); 74 | } 75 | const abstractControlName = findControlNameInGroup(parentFormGroup, control); 76 | return getControlPath(rootForm, abstractControlName, control); 77 | } 78 | 79 | /** 80 | * Calcuates the field name of a form group Eg: addresses.shippingAddress 81 | * @param rootForm 82 | * @param control 83 | */ 84 | export function getFormGroupField(rootForm: FormGroup, control: AbstractControl): string { 85 | const parentFormGroup = control.parent?.controls; 86 | if (!parentFormGroup) { 87 | throw new Error('An ngModelGroup should always be wrapped in a parent FormGroup'); 88 | } 89 | const abstractControlName = findControlNameInGroup(parentFormGroup, control); 90 | return getGroupPath(rootForm, abstractControlName, control); 91 | } 92 | 93 | 94 | export function mergeValuesAndRawValues(form: FormGroup): T { 95 | // Retrieve the standard values (respecting references) 96 | const value = { ...form.value }; 97 | 98 | // Retrieve the raw values (including disabled values) 99 | const rawValue = form.getRawValue(); 100 | 101 | // Recursive function to merge rawValue into value 102 | function mergeRecursive(target: any, source: any) { 103 | Object.keys(source).forEach(key => { 104 | if (target[key] === undefined) { 105 | // If the key is not in the target, add it directly (for disabled fields) 106 | target[key] = source[key]; 107 | } else if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) { 108 | // If the value is an object, merge it recursively 109 | mergeRecursive(target[key], source[key]); 110 | } 111 | // If the target already has the key with a primitive value, it's left as is to maintain references 112 | }); 113 | } 114 | 115 | // Start the merging process only if the form is a FormGroup 116 | if (form instanceof FormGroup) { 117 | mergeRecursive(value, rawValue); 118 | } 119 | 120 | return value; 121 | } -------------------------------------------------------------------------------- /src/app/validations/address.validations.ts: -------------------------------------------------------------------------------- 1 | import { AddressModel } from '../models/address.model'; 2 | import { enforce, test } from 'vest'; 3 | 4 | export function addressValidations(model: AddressModel | undefined, field: string): void { 5 | test(`${field}.street`, 'Street is required', () => { 6 | enforce(model?.street).isNotBlank(); 7 | }); 8 | test(`${field}.city`, 'City is required', () => { 9 | enforce(model?.city).isNotBlank(); 10 | }); 11 | test(`${field}.zipcode`, 'Zipcode is required', () => { 12 | enforce(model?.zipcode).isNotBlank(); 13 | }); 14 | test(`${field}.number`, 'Number is required', () => { 15 | enforce(model?.number).isNotBlank(); 16 | }); 17 | test(`${field}.country`, 'Country is required', () => { 18 | enforce(model?.country).isNotBlank(); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /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 9 | ? Object.values(model.values) 10 | : []; 11 | 12 | test(`${field}`, 'You should have at least one phonenumber', () => { 13 | enforce(phonenumbers.length).greaterThan(0); 14 | }); 15 | each(phonenumbers, (phonenumber, index) => { 16 | test( 17 | `${field}.values.${index}`, 18 | 'Should be a valid phonenumber', 19 | () => { 20 | enforce(phonenumber).isNotBlank(); 21 | } 22 | ); 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/validations/purchase.validations.ts: -------------------------------------------------------------------------------- 1 | import { enforce, omitWhen, only, staticSuite, test } from 'vest'; 2 | import { FormModel } from '../models/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 | 8 | export const createPurchaseValidationSuite = (swapiService: SwapiService) => { 9 | return staticSuite( 10 | (model: FormModel, field: string) => { 11 | only(field); 12 | 13 | omitWhen((!model.userId), () => { 14 | test('userId', 'userId is already taken', async ({ signal }) => { 15 | await lastValueFrom( 16 | swapiService 17 | .searchUserById(model.userId as string) 18 | .pipe(takeUntil(fromEvent(signal, 'abort'))) 19 | ).then( 20 | () => Promise.reject(), 21 | () => Promise.resolve() 22 | ); 23 | }); 24 | }); 25 | 26 | test('firstName', 'First name is required', () => { 27 | enforce(model.firstName).isNotBlank(); 28 | }); 29 | test('lastName', 'Last name is required', () => { 30 | enforce(model.lastName).isNotBlank(); 31 | }); 32 | test('age', 'Age is required', () => { 33 | enforce(model.age).isNotBlank(); 34 | }); 35 | omitWhen((model.age || 0) >= 18, () => { 36 | test('emergencyContact', 'Emergency contact is required', () => { 37 | enforce(model.emergencyContact).isNotBlank(); 38 | }); 39 | }); 40 | test('gender', 'Gender is required', () => { 41 | enforce(model.gender).isNotBlank(); 42 | }); 43 | omitWhen(model.gender !== 'other', () => { 44 | test( 45 | 'genderOther', 46 | 'If gender is other, you have to specify the gender', 47 | () => { 48 | enforce(model.genderOther).isNotBlank(); 49 | } 50 | ); 51 | }); 52 | test('productId', 'Product is required', () => { 53 | enforce(model.productId).isNotBlank(); 54 | }); 55 | addressValidations( 56 | model.addresses?.billingAddress, 57 | 'addresses.billingAddress' 58 | ); 59 | omitWhen( 60 | !model.addresses?.shippingAddressDifferentFromBillingAddress, 61 | () => { 62 | addressValidations( 63 | model.addresses?.shippingAddress, 64 | 'addresses.shippingAddress' 65 | ); 66 | test('addresses', 'The addresses appear to be the same', () => { 67 | enforce(JSON.stringify(model.addresses?.billingAddress)).notEquals( 68 | JSON.stringify(model.addresses?.shippingAddress) 69 | ); 70 | }); 71 | } 72 | ); 73 | test('passwords.password', 'Password is not filled in', () => { 74 | enforce(model.passwords?.password).isNotBlank(); 75 | }); 76 | omitWhen(!model.passwords?.password, () => { 77 | test('passwords.confirmPassword', 'Confirm password is not filled in', () => { 78 | enforce(model.passwords?.confirmPassword).isNotBlank(); 79 | }); 80 | }); 81 | omitWhen(!model.passwords?.password || !model.passwords?.confirmPassword, () => { 82 | test('passwords', 'Passwords do not match', () => { 83 | enforce(model.passwords?.confirmPassword).equals(model.passwords?.password); 84 | }); 85 | }); 86 | phonenumberValidations(model?.phonenumbers, 'phonenumbers'); 87 | } 88 | ); 89 | }; 90 | -------------------------------------------------------------------------------- /src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplifiedcourses/template-driven-forms/b5dffea83c9f977f4df2e93e0c86ee61882a2c6b/src/assets/.gitkeep -------------------------------------------------------------------------------- /src/assets/course.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplifiedcourses/template-driven-forms/b5dffea83c9f977f4df2e93e0c86ee61882a2c6b/src/assets/course.jpg -------------------------------------------------------------------------------- /src/assets/simplified-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplifiedcourses/template-driven-forms/b5dffea83c9f977f4df2e93e0c86ee61882a2c6b/src/assets/simplified-logo.png -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplifiedcourses/template-driven-forms/b5dffea83c9f977f4df2e93e0c86ee61882a2c6b/src/favicon.ico -------------------------------------------------------------------------------- /src/global_styles.scss: -------------------------------------------------------------------------------- 1 | /* Add application styles & imports to this file! */ 2 | /* Add application styles & imports to this file! */ 3 | * { 4 | font-family: 'Poppins'; 5 | font-size: 0.9rem; 6 | } 7 | h1 { 8 | font-size:3rem; 9 | } 10 | h3 { 11 | font-size: 1.5rem; 12 | } 13 | .input-wrapper--invalid { 14 | select, 15 | input[type='text'], 16 | input[type='password'], 17 | input[type='number'] { 18 | border-color: #774771; 19 | background-color: #d7c5d5; 20 | } 21 | input[type='radio'] { 22 | border-color: #774771; 23 | } 24 | color: #774771; 25 | span { 26 | color: #774771; 27 | } 28 | } 29 | select, 30 | input[type='text'], 31 | input[type='password'], 32 | input[type='number'] { 33 | border-radius: 8px; 34 | border: 1px solid #ccc; 35 | padding: 16px 8px; 36 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1); 37 | background: #fff; 38 | &:hover { 39 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.5); 40 | } 41 | &:disabled { 42 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2); 43 | background: #f1f1f1; 44 | } 45 | } 46 | 47 | label { 48 | display: flex; 49 | flex-direction: column; 50 | span { 51 | display: flex; 52 | color: #477777; 53 | font-size: 1rem; 54 | padding-bottom: 8px; 55 | } 56 | } 57 | form { 58 | box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.2); 59 | width: 500px; 60 | @media screen and (max-width: 768px) { 61 | width: 350px; 62 | } 63 | background: #f6f7fc; 64 | border-radius: 16px; 65 | padding: 24px; 66 | display: flex; 67 | flex-direction: column; 68 | align-items: flex-start; 69 | 70 | .buttons { 71 | margin-top: 16px; 72 | margin-left: 150px; 73 | } 74 | .other-gender { 75 | margin-top: 16px; 76 | margin-left: 150px; 77 | } 78 | 79 | } 80 | button { 81 | border-radius: 8px; 82 | border: none; 83 | background: #477577; 84 | color: #fff; 85 | padding: 8px 16px; 86 | &:hover { 87 | background: #0c7c7c; 88 | } 89 | } 90 | button:disabled { 91 | opacity: 0.35 92 | } 93 | .form--horizontal-split { 94 | display: flex; 95 | width: 100%; 96 | > :first-child{ 97 | margin-right: 8px; 98 | } 99 | } 100 | .radiobuttons--horizontal{ 101 | display: flex; 102 | .radiobutton__wrapper{ 103 | margin-right: 8px; 104 | } 105 | } 106 | 107 | fieldset { 108 | border: none; 109 | width: 100%; 110 | margin: 0; 111 | padding: 0; 112 | } 113 | .logo { 114 | img { 115 | width: 100px; 116 | } 117 | 118 | margin-bottom: 20px; 119 | } 120 | body { 121 | display: flex; 122 | flex-direction: column; 123 | align-items: center; 124 | } 125 | 126 | .course { 127 | img { 128 | width: 500px; 129 | } 130 | } -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Purchase 7 | 8 | 9 | 10 | 11 | 12 | 14 | 15 | 16 | 17 | 20 | 21 | 22 | Advanced Template-driven forms course 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/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-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma'), 14 | ], 15 | client: { 16 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, './coverage/my-app'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true, 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true, 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import 'zone.js'; 2 | import { bootstrapApplication } from '@angular/platform-browser'; 3 | import { AppComponent } from './app/app.component'; 4 | import { importProvidersFrom } from '@angular/core'; 5 | import { provideHttpClient } from '@angular/common/http'; 6 | 7 | bootstrapApplication(AppComponent, { providers: [provideHttpClient()] }) 8 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simplifiedcourses/template-driven-forms/b5dffea83c9f977f4df2e93e0c86ee61882a2c6b/src/polyfills.ts -------------------------------------------------------------------------------- /src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "types": [] 6 | }, 7 | "files": ["main.ts", "polyfills.ts"], 8 | "include": ["**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "types": ["jasmine", "node"] 6 | }, 7 | "files": ["test.ts", "polyfills.ts"], 8 | "include": ["**/*.spec.ts", "**/*.d.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "strict": true, 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "sourceMap": true, 8 | "declaration": false, 9 | "downlevelIteration": true, 10 | "experimentalDecorators": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "importHelpers": true, 14 | "target": "es2015", 15 | "typeRoots": ["node_modules/@types"], 16 | "lib": ["es2018", "dom"] 17 | }, 18 | "angularCompilerOptions": { 19 | "strictTemplates": true, 20 | "strictInjectionParameters": true 21 | } 22 | } 23 | --------------------------------------------------------------------------------