├── .browserslistrc
├── .editorconfig
├── .eslintrc.json
├── .gitignore
├── .prettierrc
├── .vscode
└── settings.json
├── README.md
├── angular.json
├── karma.conf.js
├── package-lock.json
├── package.json
├── src
├── app
│ ├── app-routing.module.ts
│ ├── app.component.css
│ ├── app.component.html
│ ├── app.component.ts
│ ├── app.module.ts
│ ├── forms
│ │ ├── components
│ │ │ ├── delivery-page
│ │ │ │ ├── delivery-page.component.css
│ │ │ │ ├── delivery-page.component.html
│ │ │ │ └── delivery-page.component.ts
│ │ │ ├── forms-home
│ │ │ │ ├── forms-home.component.css
│ │ │ │ ├── forms-home.component.html
│ │ │ │ └── forms-home.component.ts
│ │ │ ├── payment-page
│ │ │ │ ├── payment-page.component.css
│ │ │ │ ├── payment-page.component.html
│ │ │ │ └── payment-page.component.ts
│ │ │ ├── review-page
│ │ │ │ ├── review-page.component.css
│ │ │ │ ├── review-page.component.html
│ │ │ │ └── review-page.component.ts
│ │ │ └── shippping-page
│ │ │ │ ├── shipping-page.component.css
│ │ │ │ ├── shipping-page.component.html
│ │ │ │ └── shipping-page.component.ts
│ │ ├── forms-routing.module.ts
│ │ ├── forms.module.ts
│ │ ├── models
│ │ │ ├── delivery-page-form-value.interface.ts
│ │ │ ├── delivery-page-form-value.mock.ts
│ │ │ ├── forms-state-model-init.const.ts
│ │ │ ├── froms-state-model.interface.ts
│ │ │ ├── payment-page-form-value.interface.ts
│ │ │ ├── shipping-method.interface.ts
│ │ │ ├── shipping-methods.const.ts
│ │ │ └── shipping-page-form-value.interface.ts
│ │ ├── services
│ │ │ ├── can-activate-forms.ts
│ │ │ ├── delivery-page-form.validator.spec.ts
│ │ │ └── delivery-page-form.validator.ts
│ │ └── store
│ │ │ ├── forms.selectors.ts
│ │ │ └── forms.state.ts
│ ├── models
│ │ ├── app-state-model.interface.ts
│ │ ├── forms-state-init.const.ts
│ │ ├── forms-state.interface.ts
│ │ └── route-path.enum.ts
│ └── shared
│ │ ├── address-form
│ │ ├── address-form-value.interface.ts
│ │ ├── address-form.component.html
│ │ └── address-form.component.ts
│ │ ├── form-group-error-state-matcher.ts
│ │ ├── material.module.ts
│ │ ├── regexes.const.ts
│ │ └── shared.module.ts
├── assets
│ ├── .gitkeep
│ ├── i18n
│ │ └── en.json
│ └── icon.png
├── environments
│ ├── environment.prod.ts
│ └── environment.ts
├── icon.ico
├── index.html
├── main.ts
├── polyfills.ts
├── styles.css
└── test.ts
├── tsconfig.app.json
├── tsconfig.json
└── tsconfig.spec.json
/.browserslistrc:
--------------------------------------------------------------------------------
1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
2 | # For additional information regarding the format and rule options, please see:
3 | # https://github.com/browserslist/browserslist#queries
4 |
5 | # You can see what browsers were selected by your queries by running:
6 | # npx browserslist
7 |
8 | > 0.5%
9 | last 2 versions
10 | Firefox ESR
11 | not dead
12 | not IE 9-11 # For IE 9-11 support, remove 'not'.
--------------------------------------------------------------------------------
/.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 | [*.md]
12 | max_line_length = off
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "ignorePatterns": [
4 | "projects/**/*"
5 | ],
6 | "overrides": [
7 | {
8 | "files": [
9 | "*.ts"
10 | ],
11 | "parserOptions": {
12 | "project": [
13 | "tsconfig.json",
14 | "e2e/tsconfig.json"
15 | ],
16 | "createDefaultProgram": true
17 | },
18 | "extends": [
19 | "plugin:@angular-eslint/recommended",
20 | "plugin:@angular-eslint/template/process-inline-templates"
21 | ],
22 | "rules": {
23 | "@angular-eslint/directive-selector": [
24 | "error",
25 | {
26 | "type": "attribute",
27 | "prefix": "afn",
28 | "style": "camelCase"
29 | }
30 | ],
31 | "@angular-eslint/component-selector": [
32 | "error",
33 | {
34 | "type": "element",
35 | "prefix": "afn",
36 | "style": "kebab-case"
37 | }
38 | ]
39 | }
40 | },
41 | {
42 | "files": [
43 | "*.html"
44 | ],
45 | "extends": [
46 | "plugin:@angular-eslint/template/recommended"
47 | ],
48 | "rules": {}
49 | }
50 | ]
51 | }
52 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See http://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # compiled output
4 | /dist
5 | /tmp
6 | /out-tsc
7 | # Only exists if Bazel was run
8 | /bazel-out
9 |
10 | # dependencies
11 | /node_modules
12 |
13 | # profiling files
14 | chrome-profiler-events*.json
15 | speed-measure-plugin*.json
16 |
17 | # IDEs and editors
18 | /.idea
19 | .project
20 | .classpath
21 | .c9/
22 | *.launch
23 | .settings/
24 | *.sublime-workspace
25 |
26 | # IDE - VSCode
27 | .vscode/*
28 | !.vscode/settings.json
29 | !.vscode/tasks.json
30 | !.vscode/launch.json
31 | !.vscode/extensions.json
32 | .history/*
33 |
34 | # misc
35 | /.angular/cache
36 | /.sass-cache
37 | /connect.lock
38 | /coverage
39 | /libpeerconnection.log
40 | npm-debug.log
41 | yarn-error.log
42 | testem.log
43 | /typings
44 |
45 | # System Files
46 | .DS_Store
47 | Thumbs.db
48 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "none",
3 | "singleQuote": true,
4 | "printWidth": 140
5 | }
6 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "esbenp.prettier-vscode",
3 | "editor.formatOnSave": true,
4 | "editor.tabSize": 2
5 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Angular Forms Example with Ngxs
2 |
3 | Blog: https://medium.com/swlh/how-to-create-a-complex-form-in-angular-bdfaee0464d3
4 |
5 | ## Commands
6 |
7 | - run locally: `npm run start` and it would be served on http://localhost:4200/angular-form-ngxs
8 | - deploy to github pages: `npm run build && npm run deploy`
9 | - lint: `npm run lint` and `npm run lint-fix`
10 |
11 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 9.1.4.
12 |
13 | ## Libraries
14 |
15 | - Component Library: [Angular Material](https://material.angular.io/)
16 | - State Management: [NGXS](https://www.ngxs.io/)
17 | - CSS: [tachyons](https://tachyons.io/)
18 |
19 | ## Related Links
20 |
21 | - github page: https://xiongemi.github.io/angular-form-ngxs
22 | - medium post: https://medium.com/@emilyxiong/how-to-create-a-complex-form-in-angular-bdfaee0464d3
23 |
24 | ## Development server
25 |
26 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/angular-form-ngxs`. The app will automatically reload if you change any of the source files.
27 |
28 | ## Code scaffolding
29 |
30 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
31 |
32 | ## Build
33 |
34 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build.
35 |
36 | ## Running unit tests
37 |
38 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
39 |
40 | ## Running end-to-end tests
41 |
42 | Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
43 |
44 | ## Further help
45 |
46 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).
47 |
--------------------------------------------------------------------------------
/angular.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
3 | "version": 1,
4 | "newProjectRoot": "projects",
5 | "projects": {
6 | "angular-forms-ngxs": {
7 | "projectType": "application",
8 | "schematics": {},
9 | "root": "",
10 | "sourceRoot": "src",
11 | "prefix": "afn",
12 | "architect": {
13 | "build": {
14 | "builder": "@angular-devkit/build-angular:browser",
15 | "options": {
16 | "outputPath": "dist/angular-forms-ngxs",
17 | "index": "src/index.html",
18 | "main": "src/main.ts",
19 | "polyfills": "src/polyfills.ts",
20 | "tsConfig": "tsconfig.app.json",
21 | "assets": [
22 | "src/icon.ico",
23 | "src/assets"
24 | ],
25 | "styles": [
26 | "src/styles.css"
27 | ],
28 | "scripts": [],
29 | "vendorChunk": true,
30 | "extractLicenses": false,
31 | "buildOptimizer": false,
32 | "sourceMap": true,
33 | "optimization": false,
34 | "namedChunks": true
35 | },
36 | "configurations": {
37 | "production": {
38 | "fileReplacements": [
39 | {
40 | "replace": "src/environments/environment.ts",
41 | "with": "src/environments/environment.prod.ts"
42 | }
43 | ],
44 | "optimization": true,
45 | "outputHashing": "all",
46 | "sourceMap": false,
47 | "namedChunks": false,
48 | "extractLicenses": true,
49 | "vendorChunk": false,
50 | "buildOptimizer": true,
51 | "budgets": [
52 | {
53 | "type": "initial",
54 | "maximumWarning": "2mb",
55 | "maximumError": "5mb"
56 | },
57 | {
58 | "type": "anyComponentStyle",
59 | "maximumWarning": "6kb",
60 | "maximumError": "10kb"
61 | }
62 | ]
63 | }
64 | },
65 | "defaultConfiguration": ""
66 | },
67 | "serve": {
68 | "builder": "@angular-devkit/build-angular:dev-server",
69 | "options": {
70 | "browserTarget": "angular-forms-ngxs:build"
71 | },
72 | "configurations": {
73 | "production": {
74 | "browserTarget": "angular-forms-ngxs:build:production"
75 | }
76 | }
77 | },
78 | "extract-i18n": {
79 | "builder": "@angular-devkit/build-angular:extract-i18n",
80 | "options": {
81 | "browserTarget": "angular-forms-ngxs:build"
82 | }
83 | },
84 | "test": {
85 | "builder": "@angular-devkit/build-angular:karma",
86 | "options": {
87 | "main": "src/test.ts",
88 | "polyfills": "src/polyfills.ts",
89 | "tsConfig": "tsconfig.spec.json",
90 | "karmaConfig": "karma.conf.js",
91 | "assets": [
92 | "src/icon.ico",
93 | "src/assets"
94 | ],
95 | "styles": [
96 | "./node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css",
97 | "src/styles.css"
98 | ],
99 | "scripts": []
100 | }
101 | },
102 | "e2e": {
103 | "builder": "@angular-devkit/build-angular:protractor",
104 | "options": {
105 | "protractorConfig": "e2e/protractor.conf.js",
106 | "devServerTarget": "angular-forms-ngxs:serve"
107 | },
108 | "configurations": {
109 | "production": {
110 | "devServerTarget": "angular-forms-ngxs:serve:production"
111 | }
112 | }
113 | },
114 | "deploy": {
115 | "builder": "angular-cli-ghpages:deploy",
116 | "options": {
117 | "baseHref": "/angular-form-ngxs/",
118 | "name": "Emily Xiong",
119 | "email": "xiongemi@gmail.com"
120 | }
121 | },
122 | "lint": {
123 | "builder": "@angular-eslint/builder:lint",
124 | "options": {
125 | "lintFilePatterns": [
126 | "src/**/*.ts",
127 | "src/**/*.html"
128 | ]
129 | }
130 | }
131 | }
132 | }
133 | },
134 | "defaultProject": "angular-forms-ngxs",
135 | "cli": {
136 | "analytics": "0abf01a7-93bc-4bbd-a242-c12c962270d0",
137 | "defaultCollection": "@angular-eslint/schematics"
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/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/angular-forms-ngxs'),
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 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "angular-forms-ngxs",
3 | "version": "0.0.0",
4 | "scripts": {
5 | "ng": "ng",
6 | "start": "ng serve --serve-path=/angular-form-ngxs/",
7 | "build": "ng build --baseHref=/angular-form-ngxs/",
8 | "test": "ng test",
9 | "lint": "ng lint",
10 | "lint-fix": "ng lint --fix",
11 | "deploy": "ng deploy"
12 | },
13 | "private": true,
14 | "dependencies": {
15 | "@angular/animations": "~13.2.5",
16 | "@angular/cdk": "~13.2.5",
17 | "@angular/common": "~13.2.5",
18 | "@angular/compiler": "~13.2.5",
19 | "@angular/core": "~13.2.5",
20 | "@angular/forms": "~13.2.5",
21 | "@angular/material": "^13.2.5",
22 | "@angular/platform-browser": "~13.2.5",
23 | "@angular/platform-browser-dynamic": "~13.2.5",
24 | "@angular/router": "~13.2.5",
25 | "@ngx-translate/core": "^14.0.0",
26 | "@ngx-translate/http-loader": "^7.0.0",
27 | "@ngxs/form-plugin": "^3.7.3",
28 | "@ngxs/logger-plugin": "^3.7.3",
29 | "@ngxs/storage-plugin": "^3.7.3",
30 | "@ngxs/store": "^3.7.3",
31 | "ngx-mask": "^13.1.1",
32 | "ngxs-reset-plugin": "^2.0.0",
33 | "prettier": "^2.0.5",
34 | "rxjs": "^7.5.4",
35 | "tslib": "^2.0.0",
36 | "zone.js": "~0.11.4"
37 | },
38 | "devDependencies": {
39 | "@angular-devkit/build-angular": "~13.2.5",
40 | "@angular-eslint/builder": "13.1.0",
41 | "@angular-eslint/eslint-plugin": "13.1.0",
42 | "@angular-eslint/eslint-plugin-template": "13.1.0",
43 | "@angular-eslint/schematics": "13.1.0",
44 | "@angular-eslint/template-parser": "13.1.0",
45 | "@angular/cli": "~13.2.5",
46 | "@angular/compiler-cli": "~13.2.5",
47 | "@angular/language-service": "~13.2.5",
48 | "@ngxs/devtools-plugin": "^3.7.3",
49 | "@types/jasmine": "~3.6.0",
50 | "@types/jasminewd2": "~2.0.3",
51 | "@types/node": "^12.11.1",
52 | "@typescript-eslint/eslint-plugin": "5.11.0",
53 | "@typescript-eslint/parser": "5.11.0",
54 | "angular-cli-ghpages": "^1.0.0",
55 | "codelyzer": "^6.0.0",
56 | "date-fns": "^2.28.0",
57 | "eslint": "^8.2.0",
58 | "jasmine-core": "~3.6.0",
59 | "jasmine-spec-reporter": "~5.0.0",
60 | "karma": "~6.3.17",
61 | "karma-chrome-launcher": "~3.1.0",
62 | "karma-coverage-istanbul-reporter": "~3.0.2",
63 | "karma-jasmine": "~4.0.0",
64 | "karma-jasmine-html-reporter": "^1.5.0",
65 | "tachyons": "^4.12.0",
66 | "ts-node": "~7.0.0",
67 | "tslint": "~6.1.0",
68 | "typescript": "~4.5.5"
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/src/app/app-routing.module.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { RouterModule, Routes } from '@angular/router';
3 |
4 | const routes: Routes = [
5 | {
6 | path: '',
7 | loadChildren: () => import('./forms/forms.module').then((m) => m.FormsModule)
8 | }
9 | ];
10 |
11 | @NgModule({
12 | imports: [RouterModule.forRoot(routes)],
13 | exports: [RouterModule]
14 | })
15 | export class AppRoutingModule {}
16 |
--------------------------------------------------------------------------------
/src/app/app.component.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiongemi/angular-form-ngxs/56c4018052e70aeab744b3e365d2dfbe0e898a4e/src/app/app.component.css
--------------------------------------------------------------------------------
/src/app/app.component.html:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/src/app/app.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core';
2 |
3 | @Component({
4 | selector: 'afn-root',
5 | templateUrl: './app.component.html',
6 | styleUrls: ['./app.component.css']
7 | })
8 | export class AppComponent {}
9 |
--------------------------------------------------------------------------------
/src/app/app.module.ts:
--------------------------------------------------------------------------------
1 | import { HttpClient, HttpClientModule } from '@angular/common/http';
2 | import { NgModule } from '@angular/core';
3 | import { BrowserModule } from '@angular/platform-browser';
4 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
5 | import { TranslateLoader, TranslateModule } from '@ngx-translate/core';
6 | import { TranslateHttpLoader } from '@ngx-translate/http-loader';
7 | import { NgxsReduxDevtoolsPluginModule } from '@ngxs/devtools-plugin';
8 | import { NgxsFormPluginModule } from '@ngxs/form-plugin';
9 | import { NgxsLoggerPluginModule } from '@ngxs/logger-plugin';
10 | import { NgxsStoragePluginModule } from '@ngxs/storage-plugin';
11 | import { NgxsModule } from '@ngxs/store';
12 | import { NgxMaskModule } from 'ngx-mask';
13 | import { NgxsResetPluginModule } from 'ngxs-reset-plugin';
14 |
15 | import { environment } from 'src/environments/environment';
16 | import { AppRoutingModule } from './app-routing.module';
17 | import { AppComponent } from './app.component';
18 |
19 | export function createTranslateLoader(http: HttpClient) {
20 | return new TranslateHttpLoader(http, './assets/i18n/', '.json');
21 | }
22 |
23 | @NgModule({
24 | declarations: [AppComponent],
25 | imports: [
26 | BrowserModule,
27 | BrowserAnimationsModule,
28 | AppRoutingModule,
29 | HttpClientModule,
30 | TranslateModule.forRoot({
31 | defaultLanguage: 'en',
32 | loader: {
33 | provide: TranslateLoader,
34 | useFactory: createTranslateLoader,
35 | deps: [HttpClient]
36 | }
37 | }),
38 | NgxMaskModule.forRoot(),
39 | NgxsModule.forRoot([], {
40 | developmentMode: !environment.production
41 | }),
42 | NgxsFormPluginModule.forRoot(),
43 | NgxsResetPluginModule.forRoot(),
44 | NgxsLoggerPluginModule.forRoot({ disabled: environment.production }),
45 | NgxsReduxDevtoolsPluginModule.forRoot({ disabled: environment.production }),
46 | NgxsStoragePluginModule.forRoot({ key: ['forms'] })
47 | ],
48 | providers: [],
49 | bootstrap: [AppComponent]
50 | })
51 | export class AppModule {}
52 |
--------------------------------------------------------------------------------
/src/app/forms/components/delivery-page/delivery-page.component.css:
--------------------------------------------------------------------------------
1 | .mat-checkbox-layout {
2 | white-space: normal !important;
3 | }
4 |
--------------------------------------------------------------------------------
/src/app/forms/components/delivery-page/delivery-page.component.html:
--------------------------------------------------------------------------------
1 |
96 |
--------------------------------------------------------------------------------
/src/app/forms/components/delivery-page/delivery-page.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnDestroy, OnInit } from '@angular/core';
2 | import { FormBuilder, Validators } from '@angular/forms';
3 | import { Router } from '@angular/router';
4 | import { Subscription } from 'rxjs';
5 | import { filter } from 'rxjs/operators';
6 |
7 | import { RoutePath } from 'src/app/models/route-path.enum';
8 | import { FormGroupErrorStateMatcher } from 'src/app/shared/form-group-error-state-matcher';
9 | import { passwordRegex } from 'src/app/shared/regexes.const';
10 | import { deliveryPageFromValidator } from '../../services/delivery-page-form.validator';
11 |
12 | @Component({
13 | selector: 'afn-delivery-page',
14 | templateUrl: './delivery-page.component.html',
15 | styleUrls: ['./delivery-page.component.css']
16 | })
17 | export class DeliveryPageComponent implements OnInit, OnDestroy {
18 | deliveryPageForm = this.fb.group(
19 | {
20 | billingAddress: [null, Validators.required],
21 | isShippingSame: [true, Validators.required],
22 | shippingAddress: [null],
23 | createAccount: [true, Validators.required],
24 | account: this.fb.group({
25 | email: ['', [Validators.required, Validators.email]],
26 | password: ['', [Validators.pattern(passwordRegex)]],
27 | confirmPassword: ['']
28 | })
29 | },
30 | { validator: deliveryPageFromValidator }
31 | );
32 |
33 | passwordErrorStateMatcher = new FormGroupErrorStateMatcher(['password']);
34 | confirmPassowrdErrorStateMatcher = new FormGroupErrorStateMatcher(['confirmPassword', 'passwordMatch']);
35 | submitted = false;
36 |
37 | private subscription = new Subscription();
38 |
39 | constructor(private fb: FormBuilder, private router: Router) {}
40 |
41 | ngOnInit() {
42 | this.subscription.add(
43 | this.deliveryPageForm
44 | .get('isShippingSame')
45 | .valueChanges.pipe(filter(Boolean))
46 | .subscribe(() => {
47 | this.deliveryPageForm.get('shippingAddress').reset();
48 | })
49 | );
50 |
51 | this.subscription.add(
52 | this.deliveryPageForm
53 | .get('createAccount')
54 | .valueChanges.pipe(filter((createAccount) => !createAccount))
55 | .subscribe(() => {
56 | this.deliveryPageForm.get(['account', 'password']).reset();
57 | this.deliveryPageForm.get(['account', 'confirmPassword']).reset();
58 | })
59 | );
60 |
61 | this.subscription.add(
62 | this.deliveryPageForm.valueChanges.subscribe(() => {
63 | this.submitted = false;
64 | })
65 | );
66 | }
67 |
68 | ngOnDestroy() {
69 | this.subscription.unsubscribe();
70 | }
71 |
72 | onSubmit() {
73 | this.submitted = true;
74 | this.deliveryPageForm.markAllAsTouched();
75 | if (this.deliveryPageForm.valid) {
76 | this.router.navigate([RoutePath.shipping]);
77 | }
78 | }
79 |
80 | onReset() {
81 | this.deliveryPageForm.reset({
82 | billingAddress: {},
83 | isShippingSame: true,
84 | shippingAddress: {},
85 | createAccount: true,
86 | account: {}
87 | });
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/src/app/forms/components/forms-home/forms-home.component.css:
--------------------------------------------------------------------------------
1 | .mat-tab-links {
2 | justify-content: center;
3 | }
4 |
--------------------------------------------------------------------------------
/src/app/forms/components/forms-home/forms-home.component.html:
--------------------------------------------------------------------------------
1 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/src/app/forms/components/forms-home/forms-home.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit } from '@angular/core';
2 | import { Store } from '@ngxs/store';
3 | import { Observable } from 'rxjs';
4 |
5 | import * as FormsSelectors from '../../store/forms.selectors';
6 |
7 | @Component({
8 | selector: 'afn-forms-home',
9 | templateUrl: './forms-home.component.html',
10 | styleUrls: ['./forms-home.component.css']
11 | })
12 | export class FormsHomeComponent implements OnInit {
13 | isDeliveryFormValid$: Observable;
14 | isPaymentEnabled$: Observable;
15 |
16 | constructor(private store: Store) {}
17 |
18 | ngOnInit() {
19 | this.isDeliveryFormValid$ = this.store.select(FormsSelectors.isDeliveryFormValid);
20 | this.isPaymentEnabled$ = this.store.select(FormsSelectors.isPaymentEnabled);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/app/forms/components/payment-page/payment-page.component.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiongemi/angular-form-ngxs/56c4018052e70aeab744b3e365d2dfbe0e898a4e/src/app/forms/components/payment-page/payment-page.component.css
--------------------------------------------------------------------------------
/src/app/forms/components/payment-page/payment-page.component.html:
--------------------------------------------------------------------------------
1 |
67 |
--------------------------------------------------------------------------------
/src/app/forms/components/payment-page/payment-page.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnDestroy, OnInit } from '@angular/core';
2 | import { FormBuilder, Validators } from '@angular/forms';
3 | import { Router } from '@angular/router';
4 | import { format } from 'date-fns';
5 | import { Subscription } from 'rxjs';
6 |
7 | import { RoutePath } from 'src/app/models/route-path.enum';
8 | import { numberOnlyRegex } from 'src/app/shared/regexes.const';
9 |
10 | const minExpiryDate = format(new Date(), 'yyyy-MM');
11 |
12 | @Component({
13 | selector: 'afn-payment-page',
14 | templateUrl: './payment-page.component.html',
15 | styleUrls: ['./payment-page.component.css']
16 | })
17 | export class PaymentPageComponent implements OnInit, OnDestroy {
18 | paymentPageForm = this.fb.group({
19 | cardNumber: [null, [Validators.required, Validators.pattern(numberOnlyRegex)]],
20 | expiryDate: [null, [Validators.required]],
21 | cvv: [null, [Validators.required, Validators.pattern(numberOnlyRegex)]]
22 | });
23 |
24 | minExpiryDate = minExpiryDate;
25 | submitted = false;
26 |
27 | private subscription = new Subscription();
28 |
29 | constructor(private fb: FormBuilder, private router: Router) {}
30 |
31 | ngOnInit() {
32 | this.subscription.add(
33 | this.paymentPageForm.valueChanges.subscribe(() => {
34 | this.submitted = false;
35 | })
36 | );
37 | }
38 |
39 | ngOnDestroy() {
40 | this.subscription.unsubscribe();
41 | }
42 |
43 | onSubmit() {
44 | this.submitted = true;
45 | this.paymentPageForm.markAllAsTouched();
46 | if (this.paymentPageForm.valid) {
47 | this.router.navigate([RoutePath.review]);
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/src/app/forms/components/review-page/review-page.component.css:
--------------------------------------------------------------------------------
1 | th {
2 | padding: 0.5rem;
3 | text-align: right;
4 | vertical-align: top;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/forms/components/review-page/review-page.component.html:
--------------------------------------------------------------------------------
1 |
2 |
{{ 'COMMON.REVIEW' | translate }}
3 |
4 |
5 |
6 | {{ 'TABS.DELIVERY' | translate }}
7 |
8 |
9 |
10 | {{ 'ADDRESS.BILLING_ADDRESS' | translate }}: |
11 | |
12 |
13 |
14 | {{ 'ADDRESS.SHIPPING_ADDRESS' | translate }}: |
15 |
16 |
17 | {{ 'DELIVERY.SHIPPING_SAME' | translate }}
18 |
19 |
23 | |
24 |
25 |
26 | {{ 'DELIVERY.EMAIL' | translate }}: |
27 | {{ formsValues?.deliveryForm?.model?.account?.email }} |
28 |
29 |
30 |
31 |
32 |
33 | {{ 'TABS.SHIPPING' | translate }}
34 |
35 |
36 |
37 |
38 | {{ shippingMethod?.price ? (shippingMethod?.price | currency) : '' }}
39 | {{ shippingMethod?.type }}:
40 |
41 | {{ 'SHIPPING.GET_IT_BY' | translate }}
42 | {{ shippingMethod?.arrival?.min | date: 'E, MMM d' }}
43 | {{ shippingMethod?.arrival?.min ? '-' : '' }}
44 | {{ shippingMethod?.arrival?.max | date: 'E, MMM d' }}
45 |
46 |
47 |
48 | {{ 'SHIPPING.IS_A_GIFT' | translate }}
49 |
50 |
51 | {{ 'SHIPPING.INCLUDE_GIFT_RECEIPT' | translate }}: |
52 |
53 |
54 | check
55 |
56 |
57 | close
58 |
59 | |
60 |
61 |
62 | {{ 'SHIPPING.RECIPIENT_NAME' | translate }}: |
63 |
64 | {{ formsValues?.shippingForm?.model?.giftOptions?.name || '/' }}
65 | |
66 |
67 |
68 | {{ 'SHIPPING.CUSTOME_MESSAGE' | translate }}: |
69 | {{ formsValues?.shippingForm?.model?.giftOptions?.message || '/' }} |
70 |
71 |
72 | {{ 'SHIPPING.GIFT_WRAP' | translate }}: |
73 |
74 | check
75 | close
76 | |
77 |
78 |
79 |
80 |
81 |
82 |
83 | {{ 'TABS.PAYMENT' | translate }}
84 |
85 |
86 | {{ 'PAYMENT.CARD_NUMBER' | translate }}:
87 | {{ maskCardNumber(formsValues?.paymentForm?.model?.cardNumber) }}
88 |
89 |
90 |
91 |
92 |
96 |
97 |
--------------------------------------------------------------------------------
/src/app/forms/components/review-page/review-page.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, OnInit } from '@angular/core';
2 | import { Store } from '@ngxs/store';
3 | import { StateReset } from 'ngxs-reset-plugin';
4 |
5 | import { addressFormValueToHTML } from 'src/app/shared/address-form/address-form-value.interface';
6 | import { FormsStateModel } from '../../models/froms-state-model.interface';
7 | import { ShippingMethod } from '../../models/shipping-method.interface';
8 | import { shippingMethods } from '../../models/shipping-methods.const';
9 | import { FormsState } from '../../store/forms.state';
10 |
11 | @Component({
12 | selector: 'afn-review-page',
13 | templateUrl: './review-page.component.html',
14 | styleUrls: ['./review-page.component.css']
15 | })
16 | export class ReviewPageComponent implements OnInit {
17 | formsValues: FormsStateModel;
18 | addressFormValueToHTML = addressFormValueToHTML;
19 | shippingMethod: ShippingMethod;
20 |
21 | constructor(private store: Store) {}
22 |
23 | ngOnInit() {
24 | this.formsValues = this.store.selectSnapshot(FormsState);
25 | this.shippingMethod = shippingMethods.find((method) => method.id === this.formsValues?.shippingForm?.model?.method);
26 | this.store.dispatch(new StateReset(FormsState));
27 | }
28 |
29 | maskCardNumber(cardNumber: number): string {
30 | const cardNumberString = cardNumber.toString().split('');
31 | cardNumberString.splice(0, 12, '****-****-****-');
32 | return cardNumberString.join('');
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/app/forms/components/shippping-page/shipping-page.component.css:
--------------------------------------------------------------------------------
1 | #shipping-method mat-radio-button {
2 | display: block;
3 | }
4 |
5 | #gift-options mat-form-field {
6 | display: block;
7 | }
8 |
--------------------------------------------------------------------------------
/src/app/forms/components/shippping-page/shipping-page.component.html:
--------------------------------------------------------------------------------
1 |
69 |
--------------------------------------------------------------------------------
/src/app/forms/components/shippping-page/shipping-page.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core';
2 | import { FormBuilder, Validators } from '@angular/forms';
3 | import { Router } from '@angular/router';
4 |
5 | import { RoutePath } from 'src/app/models/route-path.enum';
6 | import { shippingMethods } from '../../models/shipping-methods.const';
7 |
8 | @Component({
9 | selector: 'afn-shipping-page',
10 | templateUrl: './shipping-page.component.html',
11 | styleUrls: ['./shipping-page.component.css']
12 | })
13 | export class ShippingPageComponent {
14 | shippingPageForm = this.fb.group({
15 | method: [1, Validators.required],
16 | isAGift: [null],
17 | giftOptions: this.fb.group({
18 | includeGiftRecipt: [false],
19 | name: [null],
20 | message: [null],
21 | wrap: [false]
22 | })
23 | });
24 |
25 | shippingMethods = shippingMethods;
26 | submitted = false;
27 |
28 | constructor(private fb: FormBuilder, private router: Router) {}
29 |
30 | onSubmit() {
31 | this.submitted = true;
32 | if (this.shippingPageForm.valid) {
33 | this.router.navigate([RoutePath.payment]);
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/app/forms/forms-routing.module.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { RouterModule, Routes } from '@angular/router';
3 |
4 | import { RoutePath } from '../models/route-path.enum';
5 | import { DeliveryPageComponent } from './components/delivery-page/delivery-page.component';
6 | import { FormsHomeComponent } from './components/forms-home/forms-home.component';
7 | import { PaymentPageComponent } from './components/payment-page/payment-page.component';
8 | import { ReviewPageComponent } from './components/review-page/review-page.component';
9 | import { ShippingPageComponent } from './components/shippping-page/shipping-page.component';
10 | import { CanActivateForms } from './services/can-activate-forms';
11 |
12 | const routes: Routes = [
13 | {
14 | path: '',
15 | component: FormsHomeComponent,
16 | children: [
17 | { path: '', redirectTo: RoutePath.delivery },
18 | { path: RoutePath.delivery, component: DeliveryPageComponent },
19 | { path: RoutePath.shipping, component: ShippingPageComponent, canActivate: [CanActivateForms] },
20 | { path: RoutePath.payment, component: PaymentPageComponent, canActivate: [CanActivateForms] }
21 | ]
22 | },
23 | {
24 | path: RoutePath.review,
25 | component: ReviewPageComponent
26 | }
27 | ];
28 |
29 | @NgModule({
30 | imports: [RouterModule.forChild(routes)],
31 | exports: [RouterModule]
32 | })
33 | export class FormsRoutingModule {}
34 |
--------------------------------------------------------------------------------
/src/app/forms/forms.module.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { NgxsFormPluginModule } from '@ngxs/form-plugin';
3 | import { NgxsModule } from '@ngxs/store';
4 | import { NgxMaskModule } from 'ngx-mask';
5 | import { SharedModule } from '../shared/shared.module';
6 | import { DeliveryPageComponent } from './components/delivery-page/delivery-page.component';
7 | import { FormsHomeComponent } from './components/forms-home/forms-home.component';
8 | import { PaymentPageComponent } from './components/payment-page/payment-page.component';
9 | import { ReviewPageComponent } from './components/review-page/review-page.component';
10 | import { ShippingPageComponent } from './components/shippping-page/shipping-page.component';
11 | import { FormsRoutingModule } from './forms-routing.module';
12 | import { FormsState } from './store/forms.state';
13 |
14 | @NgModule({
15 | declarations: [DeliveryPageComponent, FormsHomeComponent, ShippingPageComponent, PaymentPageComponent, ReviewPageComponent],
16 | imports: [FormsRoutingModule, SharedModule, NgxMaskModule.forChild(), NgxsModule.forFeature([FormsState]), NgxsFormPluginModule]
17 | })
18 | export class FormsModule {}
19 |
--------------------------------------------------------------------------------
/src/app/forms/models/delivery-page-form-value.interface.ts:
--------------------------------------------------------------------------------
1 | import { AddressFormValue } from 'src/app/shared/address-form/address-form-value.interface';
2 |
3 | export interface DeliveryPageFormValue {
4 | billingAddress: AddressFormValue;
5 | isShippingSame: boolean;
6 | shippingAddress?: AddressFormValue;
7 | createAccount: boolean;
8 | account: {
9 | email: string;
10 | password?: string;
11 | confirmPassword?: string;
12 | };
13 | }
14 |
--------------------------------------------------------------------------------
/src/app/forms/models/delivery-page-form-value.mock.ts:
--------------------------------------------------------------------------------
1 | import { DeliveryPageFormValue } from './delivery-page-form-value.interface';
2 |
3 | export const mockDeliveryPageFormValue: DeliveryPageFormValue = {
4 | billingAddress: {
5 | firstName: 'Joe',
6 | lastName: 'Doe',
7 | addressLine1: 'address line 1',
8 | city: 'city',
9 | province: 'province',
10 | country: 'country',
11 | postalCode: '12345'
12 | },
13 | isShippingSame: true,
14 | createAccount: true,
15 | account: {
16 | email: 'joe@gmail.com',
17 | password: 'Password123.',
18 | confirmPassword: 'Password123.'
19 | }
20 | };
21 |
--------------------------------------------------------------------------------
/src/app/forms/models/forms-state-model-init.const.ts:
--------------------------------------------------------------------------------
1 | import { initFormState } from 'src/app/models/forms-state-init.const';
2 | import { FormsStateModel } from './froms-state-model.interface';
3 |
4 | export const initFormsStateModel: FormsStateModel = {
5 | deliveryForm: initFormState,
6 | shippingForm: initFormState,
7 | paymentForm: initFormState
8 | };
9 |
--------------------------------------------------------------------------------
/src/app/forms/models/froms-state-model.interface.ts:
--------------------------------------------------------------------------------
1 | import { FormState } from 'src/app/models/forms-state.interface';
2 | import { DeliveryPageFormValue } from './delivery-page-form-value.interface';
3 | import { PaymentPageFormValue } from './payment-page-form-value.interface';
4 | import { ShippingPageFormValue } from './shipping-page-form-value.interface';
5 |
6 | export interface FormsStateModel {
7 | deliveryForm: FormState;
8 | shippingForm: FormState;
9 | paymentForm: FormState;
10 | }
11 |
--------------------------------------------------------------------------------
/src/app/forms/models/payment-page-form-value.interface.ts:
--------------------------------------------------------------------------------
1 | export interface PaymentPageFormValue {
2 | cardNumber: number;
3 | expiryDate: Date;
4 | cvv: number;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/forms/models/shipping-method.interface.ts:
--------------------------------------------------------------------------------
1 | export interface ShippingMethod {
2 | id: number;
3 | type: string;
4 | price: number;
5 | arrival: {
6 | min?: Date;
7 | max: Date;
8 | };
9 | }
10 |
--------------------------------------------------------------------------------
/src/app/forms/models/shipping-methods.const.ts:
--------------------------------------------------------------------------------
1 | import { add } from 'date-fns';
2 | import { ShippingMethod } from './shipping-method.interface';
3 |
4 | export const shippingMethods: ShippingMethod[] = [
5 | {
6 | id: 1,
7 | type: 'Standard Shipping',
8 | price: 5.99,
9 | arrival: {
10 | max: add(new Date(), { days: 4 })
11 | }
12 | },
13 | {
14 | id: 2,
15 | type: 'FREE Shipping',
16 | price: 0,
17 | arrival: {
18 | min: add(new Date(), { days: 5 }),
19 | max: add(new Date(), { days: 7 })
20 | }
21 | },
22 | {
23 | id: 3,
24 | type: 'Two-Day Shipping',
25 | price: 8.99,
26 | arrival: {
27 | max: add(new Date(), { days: 2 })
28 | }
29 | }
30 | ].sort((method1, method2) => method1.price - method2.price);
31 |
--------------------------------------------------------------------------------
/src/app/forms/models/shipping-page-form-value.interface.ts:
--------------------------------------------------------------------------------
1 | export interface ShippingPageFormValue {
2 | method: number;
3 | isAGift: boolean;
4 | giftOptions: {
5 | includeGiftRecipt: boolean;
6 | name: string;
7 | message: string;
8 | wrap: boolean;
9 | };
10 | }
11 |
--------------------------------------------------------------------------------
/src/app/forms/services/can-activate-forms.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 | import { ActivatedRouteSnapshot, CanActivate, RouterStateSnapshot } from '@angular/router';
3 | import { Store } from '@ngxs/store';
4 | import { Observable } from 'rxjs';
5 | import { RoutePath } from 'src/app/models/route-path.enum';
6 | import * as FormsSelectors from '../store/forms.selectors';
7 |
8 | @Injectable({
9 | providedIn: 'root'
10 | })
11 | export class CanActivateForms implements CanActivate {
12 | constructor(private store: Store) {}
13 |
14 | canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable | boolean {
15 | if (route.url[0].path === RoutePath.payment) {
16 | return this.store.select(FormsSelectors.isPaymentEnabled);
17 | }
18 | if (route.url[0].path === RoutePath.shipping) {
19 | return this.store.select(FormsSelectors.isDeliveryFormValid);
20 | }
21 | return true;
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/src/app/forms/services/delivery-page-form.validator.spec.ts:
--------------------------------------------------------------------------------
1 | import { mockDeliveryPageFormValue } from '../models/delivery-page-form-value.mock';
2 | import { deliveryPageFromValueValidator } from './delivery-page-form.validator';
3 |
4 | describe('Delivery Page Form Validator', () => {
5 | it('should return null if form is valid', () => {
6 | const actual = deliveryPageFromValueValidator(mockDeliveryPageFormValue);
7 | const expected = null;
8 | expect(actual).toEqual(expected);
9 | });
10 |
11 | it('should return shippingAddress if shipping address is not same and shipping address is missing', () => {
12 | const invalidForm = {
13 | billingAddress: {
14 | firstName: 'Joe',
15 | lastName: 'Doe',
16 | addressLine1: 'address line 1',
17 | city: 'city',
18 | province: 'province',
19 | country: 'country',
20 | postalCode: '12345'
21 | },
22 | isShippingSame: false,
23 | createAccount: true,
24 | account: {
25 | email: 'joe@gmail.com',
26 | password: 'Password123.',
27 | confirmPassword: 'Password123.'
28 | }
29 | };
30 | const actual = deliveryPageFromValueValidator(invalidForm);
31 | const expected = { shippingAddress: true };
32 | expect(actual).toEqual(expected);
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/src/app/forms/services/delivery-page-form.validator.ts:
--------------------------------------------------------------------------------
1 | import { FormGroup } from '@angular/forms';
2 |
3 | import { DeliveryPageFormValue } from '../models/delivery-page-form-value.interface';
4 |
5 | export function deliveryPageFromValidator(deliveryPageFrom: FormGroup): { [key: string]: any } | null {
6 | const formValue: DeliveryPageFormValue = deliveryPageFrom.value;
7 | return deliveryPageFromValueValidator(formValue);
8 | }
9 |
10 | export function deliveryPageFromValueValidator(deliveryPageFromValue: DeliveryPageFormValue): { [key: string]: any } | null {
11 | const errors = {};
12 | if (!deliveryPageFromValue.isShippingSame && !deliveryPageFromValue.shippingAddress) {
13 | errors['shippingAddress'] = true;
14 | }
15 | if (deliveryPageFromValue.createAccount) {
16 | if (!deliveryPageFromValue.account.password) {
17 | errors['password'] = true;
18 | }
19 | if (!deliveryPageFromValue.account.confirmPassword) {
20 | errors['confirmPassword'] = true;
21 | } else if (deliveryPageFromValue.account.password !== deliveryPageFromValue.account.confirmPassword) {
22 | errors['passwordMatch'] = true;
23 | }
24 | }
25 | return Object.keys(errors).length ? errors : null;
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/forms/store/forms.selectors.ts:
--------------------------------------------------------------------------------
1 | import { createSelector } from '@ngxs/store';
2 | import { AppStateModel } from 'src/app/models/app-state-model.interface';
3 |
4 | export const formState = (appState: AppStateModel) => appState.forms;
5 |
6 | export const deliveryForm = (appState: AppStateModel) => formState(appState).deliveryForm;
7 |
8 | export const shippingForm = (appState: AppStateModel) => formState(appState).shippingForm;
9 |
10 | export const paymentForm = (appState: AppStateModel) => formState(appState).paymentForm;
11 |
12 | export const isDeliveryFormValid = (appState: AppStateModel) => deliveryForm(appState).status === 'VALID';
13 |
14 | export const isShippingFormValid = (appState: AppStateModel) => shippingForm(appState).status === 'VALID';
15 |
16 | export const isPaymentFormValid = (appState: AppStateModel) => paymentForm(appState).status === 'VALID';
17 |
18 | export const isPaymentEnabled = createSelector(
19 | [isDeliveryFormValid, isShippingFormValid],
20 | (deliveryFormValid, shippingFormValid) => deliveryFormValid && shippingFormValid
21 | );
22 |
--------------------------------------------------------------------------------
/src/app/forms/store/forms.state.ts:
--------------------------------------------------------------------------------
1 | import { Injectable } from '@angular/core';
2 | import { State } from '@ngxs/store';
3 |
4 | import { initFormsStateModel } from '../models/forms-state-model-init.const';
5 |
6 | @State({
7 | name: 'forms',
8 | defaults: initFormsStateModel
9 | })
10 | @Injectable()
11 | export class FormsState {}
12 |
--------------------------------------------------------------------------------
/src/app/models/app-state-model.interface.ts:
--------------------------------------------------------------------------------
1 | import { FormsStateModel } from '../forms/models/froms-state-model.interface';
2 |
3 | export interface AppStateModel {
4 | forms: FormsStateModel;
5 | }
6 |
--------------------------------------------------------------------------------
/src/app/models/forms-state-init.const.ts:
--------------------------------------------------------------------------------
1 | import { FormState } from './forms-state.interface';
2 |
3 | export const initFormState: FormState = {
4 | model: undefined,
5 | dirty: false,
6 | status: '',
7 | errors: null
8 | };
9 |
--------------------------------------------------------------------------------
/src/app/models/forms-state.interface.ts:
--------------------------------------------------------------------------------
1 | import { ValidationErrors } from '@angular/forms';
2 |
3 | export interface FormState {
4 | model: T;
5 | dirty: boolean;
6 | status: string;
7 | errors: ValidationErrors | null;
8 | }
9 |
--------------------------------------------------------------------------------
/src/app/models/route-path.enum.ts:
--------------------------------------------------------------------------------
1 | export enum RoutePath {
2 | delivery = 'delivery',
3 | shipping = 'shipping',
4 | payment = 'payment',
5 | review = 'review'
6 | }
7 |
--------------------------------------------------------------------------------
/src/app/shared/address-form/address-form-value.interface.ts:
--------------------------------------------------------------------------------
1 | export interface AddressFormValue {
2 | firstName: string;
3 | lastName: string;
4 | addressLine1: string;
5 | addressLine2?: string;
6 | city: string;
7 | province: string;
8 | country: string;
9 | postalCode: string;
10 | }
11 |
12 | export function addressFormValueToHTML(addressFormValue: AddressFormValue): string {
13 | if (!addressFormValue) {
14 | return '';
15 | }
16 | return (
17 | addressFormValue.firstName +
18 | ' ' +
19 | addressFormValue.lastName +
20 | '
' +
21 | addressFormValue.addressLine1 +
22 | ' ' +
23 | addressFormValue.addressLine2 +
24 | '
' +
25 | addressFormValue.city +
26 | ', ' +
27 | addressFormValue.province +
28 | ', ' +
29 | addressFormValue.country +
30 | '
' +
31 | addressFormValue.postalCode
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/app/shared/address-form/address-form.component.html:
--------------------------------------------------------------------------------
1 |
73 |
--------------------------------------------------------------------------------
/src/app/shared/address-form/address-form.component.ts:
--------------------------------------------------------------------------------
1 | import { Component, forwardRef, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core';
2 | import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR, Validators } from '@angular/forms';
3 | import { Subscription } from 'rxjs';
4 |
5 | import { nameRegex } from '../regexes.const';
6 | import { AddressFormValue } from './address-form-value.interface';
7 |
8 | @Component({
9 | selector: 'afn-address-form',
10 | templateUrl: './address-form.component.html',
11 | providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AddressFormComponent), multi: true }]
12 | })
13 | export class AddressFormComponent implements ControlValueAccessor, OnInit, OnDestroy, OnChanges {
14 | @Input() touched: boolean;
15 |
16 | addressForm = this.fb.group({
17 | firstName: [null, [Validators.required, Validators.pattern(nameRegex)]],
18 | lastName: [null, [Validators.required, Validators.pattern(nameRegex)]],
19 | addressLine1: [null, Validators.required],
20 | addressLine2: [null],
21 | city: [null, Validators.required],
22 | province: [null, Validators.required],
23 | country: [null, Validators.required],
24 | postalCode: [null, Validators.required]
25 | });
26 |
27 | private subscription = new Subscription();
28 |
29 | onChange: any = (_: AddressFormValue) => {};
30 | onTouch: any = () => {};
31 |
32 | constructor(private fb: FormBuilder) {}
33 |
34 | ngOnInit() {
35 | this.subscription.add(
36 | this.addressForm.valueChanges.subscribe((value: AddressFormValue) => {
37 | this.onChange(value);
38 | })
39 | );
40 | }
41 |
42 | ngOnDestroy() {
43 | this.subscription.unsubscribe();
44 | }
45 |
46 | ngOnChanges(simpleChanges: SimpleChanges) {
47 | if (simpleChanges['touched'] && simpleChanges['touched'].currentValue) {
48 | this.addressForm.markAllAsTouched();
49 | }
50 | }
51 |
52 | writeValue(value: null | AddressFormValue): void {
53 | if (value) {
54 | this.addressForm.reset(value);
55 | }
56 | }
57 |
58 | registerOnChange(fn: () => {}): void {
59 | this.onChange = fn;
60 | }
61 |
62 | registerOnTouched(fn: (_: AddressFormValue) => {}): void {
63 | this.onTouch = fn;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/app/shared/form-group-error-state-matcher.ts:
--------------------------------------------------------------------------------
1 | import { FormControl, FormGroupDirective, NgForm } from '@angular/forms';
2 | import { ErrorStateMatcher } from '@angular/material/core';
3 |
4 | export class FormGroupErrorStateMatcher implements ErrorStateMatcher {
5 | errors: string[];
6 |
7 | constructor(errors: string[]) {
8 | this.errors = errors;
9 | }
10 |
11 | isErrorState(control: FormControl | null, formDirective: FormGroupDirective | NgForm | null): boolean {
12 | const formGroupHasError = this.errors.reduce((total, error) => total || formDirective.form.hasError(error), false);
13 | return control.touched && (control.invalid || formGroupHasError);
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/src/app/shared/material.module.ts:
--------------------------------------------------------------------------------
1 | import { NgModule } from '@angular/core';
2 | import { MatButtonModule } from '@angular/material/button';
3 | import { MatCardModule } from '@angular/material/card';
4 | import { MatCheckboxModule } from '@angular/material/checkbox';
5 | import { MatFormFieldModule } from '@angular/material/form-field';
6 | import { MatIconModule } from '@angular/material/icon';
7 | import { MatInputModule } from '@angular/material/input';
8 | import { MatListModule } from '@angular/material/list';
9 | import { MatRadioModule } from '@angular/material/radio';
10 | import { MatTabsModule } from '@angular/material/tabs';
11 |
12 | const modules = [
13 | MatButtonModule,
14 | MatCardModule,
15 | MatTabsModule,
16 | MatFormFieldModule,
17 | MatIconModule,
18 | MatInputModule,
19 | MatCheckboxModule,
20 | MatRadioModule,
21 | MatListModule
22 | ];
23 |
24 | @NgModule({
25 | imports: modules,
26 | exports: modules
27 | })
28 | export class MaterialModule {}
29 |
--------------------------------------------------------------------------------
/src/app/shared/regexes.const.ts:
--------------------------------------------------------------------------------
1 | // regex taken from https://stackoverflow.com/questions/2385701/regular-expression-for-first-and-last-name
2 | export const nameRegex = /^[a-z ,.'-]+$/i;
3 |
4 | // regex taken from
5 | // https://stackoverflow.com/questions/19605150/regex-for-password-must-contain-at-least-eight-characters-at-least-one-number-a
6 | export const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
7 |
8 | export const numberOnlyRegex = /^[0-9]*$/;
9 |
--------------------------------------------------------------------------------
/src/app/shared/shared.module.ts:
--------------------------------------------------------------------------------
1 | import { CommonModule } from '@angular/common';
2 | import { NgModule } from '@angular/core';
3 | import { FormsModule, ReactiveFormsModule } from '@angular/forms';
4 | import { TranslateModule } from '@ngx-translate/core';
5 |
6 | import { AddressFormComponent } from './address-form/address-form.component';
7 | import { MaterialModule } from './material.module';
8 |
9 | @NgModule({
10 | imports: [MaterialModule, TranslateModule, CommonModule, FormsModule, ReactiveFormsModule],
11 | declarations: [AddressFormComponent],
12 | exports: [AddressFormComponent, MaterialModule, TranslateModule, CommonModule, FormsModule, ReactiveFormsModule]
13 | })
14 | export class SharedModule {}
15 |
--------------------------------------------------------------------------------
/src/assets/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiongemi/angular-form-ngxs/56c4018052e70aeab744b3e365d2dfbe0e898a4e/src/assets/.gitkeep
--------------------------------------------------------------------------------
/src/assets/i18n/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "TABS": {
3 | "DELIVERY": "Delivery",
4 | "SHIPPING": "Shipping",
5 | "PAYMENT": "Payment"
6 | },
7 | "DELIVERY": {
8 | "SHIPPING_SAME": "My shipping address is same as my billing address.",
9 | "USER_CHECKOUT_OPTIONS": "User checkout options",
10 | "CREATE_ACCOUNT": "Create Account",
11 | "CHECKOUT_AS_GUEST": "Checkout as a Guest",
12 | "EMAIL": "Email",
13 | "PASSWORD": "Password",
14 | "CONFIRM_PASSWORD": "Confirm Password",
15 | "PASSWORD_MATCH": "Passwords must match",
16 | "PASSWORD_HINT": "Minimum eight characters, at least one uppercase letter, one lowercase letter, one number and one special character (@$!%*#?&)"
17 | },
18 | "SHIPPING": {
19 | "SHIPPING_METHODS": "Shipping Methods",
20 | "GET_IT_BY": "get it by",
21 | "IS_A_GIFT": "Item is a gift",
22 | "GIFT_OPTIONS": "Gift Options",
23 | "INCLUDE_GIFT_RECEIPT": "Include gift receipt",
24 | "RECIPIENT_NAME": "Recipient Name",
25 | "CUSTOM_MESSAGE": "Custom Message",
26 | "GIFT_WRAP": "Add gift wrap"
27 | },
28 | "PAYMENT": {
29 | "PAYMENT_METHOD": "Payment Method",
30 | "CARD_NUMBER": "Card Number",
31 | "EXPIRY_DATE": "Expiry Date",
32 | "CVV": "CVV"
33 | },
34 | "ADDRESS": {
35 | "FIRST_NAME": "First Name",
36 | "LAST_NAME": "Last Name",
37 | "ADDRESS_LINE_1": "Address Line 1",
38 | "ADDRESS_LINE_2": "Address Line 2",
39 | "CITY": "City / District",
40 | "PROVINCE": "Province / State",
41 | "COUNTRY": "Country",
42 | "POSTAL_CODE": "Postal Code",
43 | "SHIPPING_ADDRESS": "Shipping Address",
44 | "BILLING_ADDRESS": "Billing Address"
45 | },
46 | "ERRORS": {
47 | "REQUIRED": "Field is required",
48 | "PATTERN": "Invalid format",
49 | "FORM_ERROR": "Please fix all the errors."
50 | },
51 | "COMMON": {
52 | "NEXT": "Next",
53 | "PAY": "Pay",
54 | "PREVIOUS": "Previous",
55 | "RESET": "Reset",
56 | "REVIEW": "Review",
57 | "EDIT": "Edit",
58 | "RESTART": "Restart"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiongemi/angular-form-ngxs/56c4018052e70aeab744b3e365d2dfbe0e898a4e/src/assets/icon.png
--------------------------------------------------------------------------------
/src/environments/environment.prod.ts:
--------------------------------------------------------------------------------
1 | export const environment = {
2 | production: true
3 | };
4 |
--------------------------------------------------------------------------------
/src/environments/environment.ts:
--------------------------------------------------------------------------------
1 | // This file can be replaced during build by using the `fileReplacements` array.
2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
3 | // The list of file replacements can be found in `angular.json`.
4 |
5 | export const environment = {
6 | production: false
7 | };
8 |
9 | /*
10 | * For easier debugging in development mode, you can import the following file
11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
12 | *
13 | * This import should be commented out in production mode because it will have a negative impact
14 | * on performance if an error is thrown.
15 | */
16 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI.
17 |
--------------------------------------------------------------------------------
/src/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/xiongemi/angular-form-ngxs/56c4018052e70aeab744b3e365d2dfbe0e898a4e/src/icon.ico
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Angular Checkout Form with NGXS
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { enableProdMode } from '@angular/core';
2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
3 |
4 | import { AppModule } from './app/app.module';
5 | import { environment } from './environments/environment';
6 |
7 | if (environment.production) {
8 | enableProdMode();
9 | }
10 |
11 | platformBrowserDynamic().bootstrapModule(AppModule)
12 | .catch(err => console.error(err));
13 |
--------------------------------------------------------------------------------
/src/polyfills.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This file includes polyfills needed by Angular and is loaded before the app.
3 | * You can add your own extra polyfills to this file.
4 | *
5 | * This file is divided into 2 sections:
6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main
8 | * file.
9 | *
10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that
11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
13 | *
14 | * Learn more in https://angular.io/guide/browser-support
15 | */
16 |
17 | /***************************************************************************************************
18 | * BROWSER POLYFILLS
19 | */
20 |
21 | /**
22 | * By default, zone.js will patch all possible macroTask and DomEvents
23 | * user can disable parts of macroTask/DomEvents patch by setting following flags
24 | * because those flags need to be set before `zone.js` being loaded, and webpack
25 | * will put import in the top of bundle, so user need to create a separate file
26 | * in this directory (for example: zone-flags.ts), and put the following flags
27 | * into that file, and then add the following code before importing zone.js.
28 | * import './zone-flags.ts';
29 | *
30 | * The flags allowed in zone-flags.ts are listed here.
31 | *
32 | * The following flags will work for all browsers.
33 | *
34 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
35 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
36 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
37 | *
38 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
39 | * with the following flag, it will bypass `zone.js` patch for IE/Edge
40 | *
41 | * (window as any).__Zone_enable_cross_context_check = true;
42 | *
43 | */
44 |
45 | /***************************************************************************************************
46 | * Zone JS is required by default for Angular itself.
47 | */
48 | import 'zone.js'; // Included with Angular CLI.
49 |
50 |
51 | /***************************************************************************************************
52 | * APPLICATION IMPORTS
53 | */
54 |
--------------------------------------------------------------------------------
/src/styles.css:
--------------------------------------------------------------------------------
1 | /* You can add global styles to this file, and also import other style files */
2 |
3 | @import '~tachyons/css/tachyons.min';
4 | @import '@angular/material/prebuilt-themes/indigo-pink.css';
5 |
6 | html,
7 | body {
8 | height: 100%;
9 | }
10 |
11 | body {
12 | margin: 0;
13 | font-family: Roboto, 'Helvetica Neue', sans-serif;
14 | }
15 |
16 | .mat-tab-links {
17 | justify-content: center;
18 | }
19 |
20 | .mat-checkbox-layout {
21 | white-space: normal !important;
22 | }
23 |
--------------------------------------------------------------------------------
/src/test.ts:
--------------------------------------------------------------------------------
1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files
2 |
3 | import { getTestBed } from '@angular/core/testing';
4 | import {
5 | BrowserDynamicTestingModule,
6 | platformBrowserDynamicTesting
7 | } from '@angular/platform-browser-dynamic/testing';
8 | import 'zone.js/testing';
9 |
10 | declare const require: any;
11 |
12 | // First, initialize the Angular testing environment.
13 | getTestBed().initTestEnvironment(
14 | BrowserDynamicTestingModule,
15 | platformBrowserDynamicTesting(), {
16 | teardown: { destroyAfterEach: false }
17 | }
18 | );
19 | // Then we find all the tests.
20 | const context = require.context('./', true, /\.spec\.ts$/);
21 | // And load the modules.
22 | context.keys().map(context);
23 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./out-tsc/app",
5 | "types": []
6 | },
7 | "files": [
8 | "src/main.ts",
9 | "src/polyfills.ts"
10 | ],
11 | "include": [
12 | "src/**/*.d.ts"
13 | ],
14 | "exclude": [
15 | "src/test.ts",
16 | "src/**/*.spec.ts"
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compileOnSave": false,
3 | "compilerOptions": {
4 | "baseUrl": "./",
5 | "outDir": "./dist/out-tsc",
6 | "sourceMap": true,
7 | "declaration": false,
8 | "downlevelIteration": true,
9 | "experimentalDecorators": true,
10 | "module": "es2020",
11 | "moduleResolution": "node",
12 | "importHelpers": true,
13 | "target": "es2015",
14 | "typeRoots": [
15 | "node_modules/@types"
16 | ],
17 | "lib": [
18 | "es2018",
19 | "dom"
20 | ]
21 | },
22 | "angularCompilerOptions": {
23 | "fullTemplateTypeCheck": true,
24 | "strictInjectionParameters": true
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./out-tsc/spec",
5 | "types": [
6 | "jasmine",
7 | "node"
8 | ]
9 | },
10 | "files": [
11 | "src/test.ts",
12 | "src/polyfills.ts"
13 | ],
14 | "include": [
15 | "src/**/*.spec.ts",
16 | "src/**/*.d.ts"
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------