├── client
├── src
│ ├── assets
│ │ └── .gitkeep
│ ├── app
│ │ ├── app.component.css
│ │ ├── components
│ │ │ ├── control-errors
│ │ │ │ ├── control-errors.component.scss
│ │ │ │ ├── control-errors.component.html
│ │ │ │ ├── control-errors.component.ts
│ │ │ │ └── control-errors.component.spec.ts
│ │ │ └── signup-form
│ │ │ │ ├── signup-form.component.ts
│ │ │ │ ├── signup-form.component.scss
│ │ │ │ ├── signup-form.component.spec.ts
│ │ │ │ └── signup-form.component.html
│ │ ├── app.component.html
│ │ ├── sass
│ │ │ ├── mixins.scss
│ │ │ ├── functions.scss
│ │ │ └── variables.scss
│ │ ├── app.component.ts
│ │ ├── app.component.spec.ts
│ │ ├── spec-helpers
│ │ │ ├── signup-data.spec-helper.ts
│ │ │ └── element.spec-helper.ts
│ │ ├── app.module.ts
│ │ ├── util
│ │ │ └── findFormControl.ts
│ │ ├── services
│ │ │ ├── signup.service.ts
│ │ │ └── signup.service.spec.ts
│ │ └── directives
│ │ │ ├── error-message.directive.ts
│ │ │ └── error-message.directive.spec.ts
│ ├── favicon.ico
│ ├── environments
│ │ ├── environment.prod.ts
│ │ └── environment.ts
│ ├── proxy.conf.json
│ ├── index.html
│ ├── main.ts
│ └── styles.scss
├── .pa11yci
├── tsconfig.app.json
├── tsconfig.spec.json
├── .gitignore
├── README.md
├── tsconfig.json
├── .eslintrc.json
├── karma.conf.js
├── package.json
└── angular.json
├── .vscode
└── settings.json
├── .prettierrc
├── .gitignore
├── .editorconfig
├── server
├── package.json
├── README.md
├── index.js
└── package-lock.json
├── README.md
└── LICENSE
/client/src/assets/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/app/app.component.css:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/app/components/control-errors/control-errors.component.scss:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/app/app.component.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "git.ignoreLimitWarning": true
4 | }
--------------------------------------------------------------------------------
/client/src/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/molily/angular-form-testing/HEAD/client/src/favicon.ico
--------------------------------------------------------------------------------
/client/src/app/sass/mixins.scss:
--------------------------------------------------------------------------------
1 | @mixin dark-scheme {
2 | @media (prefers-color-scheme: dark) {
3 | @content;
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "all",
3 | "printWidth": 90,
4 | "tabWidth": 2,
5 | "singleQuote": true,
6 | "arrowParens": "always"
7 | }
8 |
--------------------------------------------------------------------------------
/client/src/environments/environment.prod.ts:
--------------------------------------------------------------------------------
1 | export const environment = {
2 | production: true,
3 | signupServiceUrl: 'https://angular-form-testing.onrender.com',
4 | };
5 |
--------------------------------------------------------------------------------
/client/.pa11yci:
--------------------------------------------------------------------------------
1 | {
2 | "defaults": {
3 | "runner": [
4 | "axe",
5 | "htmlcs"
6 | ]
7 | },
8 | "urls": [
9 | "http://localhost:4200"
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/client/src/proxy.conf.json:
--------------------------------------------------------------------------------
1 | {
2 | "/api": {
3 | "target": "http://localhost:3000",
4 | "secure": false,
5 | "pathRewrite": {
6 | "^/api": ""
7 | }
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | # IDE - VSCode
4 | .vscode/*
5 | !.vscode/settings.json
6 | !.vscode/tasks.json
7 | !.vscode/launch.json
8 | !.vscode/extensions.json
9 |
10 | # System Files
11 | .DS_Store
12 | Thumbs.db
13 |
--------------------------------------------------------------------------------
/client/src/app/app.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core';
2 |
3 | @Component({
4 | selector: 'app-root',
5 | templateUrl: './app.component.html',
6 | styleUrls: ['./app.component.css'],
7 | })
8 | export class AppComponent {}
9 |
--------------------------------------------------------------------------------
/client/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */
2 | {
3 | "extends": "./tsconfig.json",
4 | "compilerOptions": {
5 | "outDir": "./out-tsc/app",
6 | "types": []
7 | },
8 | "include": ["src/**/*.d.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/client/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */
2 | {
3 | "extends": "./tsconfig.json",
4 | "compilerOptions": {
5 | "outDir": "./out-tsc/spec",
6 | "types": ["jasmine"]
7 | },
8 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/.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 | [*.{js,ts}]
12 | quote_type = single
13 |
14 | [*.md]
15 | max_line_length = off
16 | trim_trailing_whitespace = false
17 |
--------------------------------------------------------------------------------
/client/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Sign-up form
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/client/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 |
--------------------------------------------------------------------------------
/client/src/app/components/control-errors/control-errors.component.html:
--------------------------------------------------------------------------------
1 |
11 | ❗
12 |
13 |
14 |
--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "signup",
3 | "version": "0.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "node index.js"
8 | },
9 | "private": true,
10 | "license": "Unlicense",
11 | "author": "Mathias Schäfer (https://molily.de)",
12 | "dependencies": {
13 | "cors": "^2.8.5",
14 | "express": "^4.21.2",
15 | "express-rate-limit": "^7.5.0",
16 | "nodemon": "^3.1.9",
17 | "zxcvbn": "^4.4.2"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Angular Form testing
2 |
3 | 📖 This example is part of the **[free online book: Testing Angular – A Guide to Robust Angular Applications
4 | ](https://testing-angular.com/)**. 📖
5 |
6 | This is an example for testing complex Angular forms.
7 |
8 | - The front-end Angular application can be found in [client](client/). Run `npm install` and `npm start` to start the client.
9 | - The back-end Node.js application can be found in [server](server/). Run `npm install` and `npm start` to start the server.
10 |
--------------------------------------------------------------------------------
/server/README.md:
--------------------------------------------------------------------------------
1 | # Angular form testing: Server
2 |
3 | This is a simple HTTP service based on Node.js and Express. It implements a fake backend for the sign-up form testing. This service does nothing useful and just simulates input validation and signup. Do not use it in production.
4 |
5 | ## Setup
6 |
7 | - Install the dependencies with `npm install`.
8 | - Start the server with `npm start`. The server runs at http://localhost:3000.
9 | - Start the client Angular application in `../client/`. The form uses the HTTP service.
10 |
--------------------------------------------------------------------------------
/client/src/app/sass/functions.scss:
--------------------------------------------------------------------------------
1 | /// Replace `$search` with `$replace` in `$string`
2 | /// https://css-tricks.com/snippets/sass/str-replace-function/
3 | ///
4 | /// @author Hugo Giraudel
5 | /// @param {String} $string - Initial string
6 | /// @param {String} $search - Substring to replace
7 | /// @param {String} $replace ('') - New value
8 | /// @return {String} - Updated string
9 | @function str-replace($string, $search, $replace: '') {
10 | $index: str-index($string, $search);
11 |
12 | @if $index {
13 | @return str-slice($string, 1, $index - 1) + $replace +
14 | str-replace(str-slice($string, $index + str-length($search)), $search, $replace);
15 | }
16 |
17 | @return $string;
18 | }
19 |
--------------------------------------------------------------------------------
/client/src/app/app.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { NO_ERRORS_SCHEMA } from '@angular/core';
2 | import { TestBed } from '@angular/core/testing';
3 |
4 | import { AppComponent } from './app.component';
5 | import { findComponent } from './spec-helpers/element.spec-helper';
6 |
7 | describe('AppComponent', () => {
8 | beforeEach(async () => {
9 | await TestBed.configureTestingModule({
10 | declarations: [AppComponent],
11 | schemas: [NO_ERRORS_SCHEMA],
12 | }).compileComponents();
13 | });
14 |
15 | it('renders the sign-up form', () => {
16 | const fixture = TestBed.createComponent(AppComponent);
17 | expect(findComponent(fixture, 'app-signup-form')).toBeTruthy();
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/client/src/app/spec-helpers/signup-data.spec-helper.ts:
--------------------------------------------------------------------------------
1 | import { SignupData } from '../services/signup.service';
2 |
3 | export const username = 'quickBrownFox';
4 | export const password = 'dog lazy the over jumps fox brown quick the';
5 | export const email = 'quick.brown.fox@example.org';
6 | export const name = 'Mr. Fox';
7 | export const addressLine1 = '';
8 | export const addressLine2 = 'Under the Tree 1';
9 | export const city = 'Farmtown';
10 | export const postcode = '123456';
11 | export const region = 'Upper South';
12 | export const country = 'Luggnagg';
13 |
14 | export const signupData: SignupData = {
15 | plan: 'personal',
16 | username,
17 | email,
18 | password,
19 | address: { name, addressLine1, addressLine2, city, postcode, region, country },
20 | tos: true,
21 | };
22 |
--------------------------------------------------------------------------------
/client/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 | signupServiceUrl: 'http://localhost:4200/api',
8 | };
9 |
10 | /*
11 | * For easier debugging in development mode, you can import the following file
12 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
13 | *
14 | * This import should be commented out in production mode because it will have a negative impact
15 | * on performance if an error is thrown.
16 | */
17 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI.
18 |
--------------------------------------------------------------------------------
/client/.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 |
--------------------------------------------------------------------------------
/client/src/app/sass/variables.scss:
--------------------------------------------------------------------------------
1 | $background-color-light: #fdfdfd;
2 | $text-color-light: black;
3 |
4 | $background-color-dark: #202020;
5 | $text-color-dark: #fdfdfd;
6 |
7 | $primary-color: #1976d2;
8 |
9 | $field-border-light: #486684;
10 | $field-border-dark: #f0f0f0;
11 |
12 | $field-border-selected-light: #486684;
13 | $field-border-selected-dark: $field-border-dark;
14 |
15 | $field-background-light: #f5f5fa;
16 | $field-background-dark: #2a2a2a;
17 |
18 | $field-text-selected-light: $text-color-light;
19 | $field-background-selected-light: #eaeaff;
20 | $field-text-selected-dark: $field-background-dark;
21 | $field-background-selected-dark: #bababa;
22 |
23 | $success-color-light: green;
24 | $success-color-dark: chartreuse;
25 |
26 | $error-color-light: crimson;
27 | $error-color-dark: #f55;
28 |
29 | $focus-shadow: 0 0 2px 4px rgba($primary-color, 0.2);
30 |
--------------------------------------------------------------------------------
/client/README.md:
--------------------------------------------------------------------------------
1 | # Angular form testing: Client
2 |
3 | ## Development server
4 |
5 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
6 |
7 | ## Build
8 |
9 | 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.
10 |
11 | ## Running unit tests
12 |
13 | Run `ng test` to execute the unit tests.
14 |
15 | ## Running accessibility tests
16 |
17 | Run `npm run a11y` to start a dev server and execute the accessibility tests.
18 |
19 | Run `npm run pa11y-ci` to run the accessibility tests against an already running dev server.
20 |
21 | ## Further help
22 |
23 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI Overview and Command Reference](https://angular.io/cli) page.
24 |
--------------------------------------------------------------------------------
/client/src/app/app.module.ts:
--------------------------------------------------------------------------------
1 | import { HttpClientModule } from '@angular/common/http';
2 | import { NgModule } from '@angular/core';
3 | import { ReactiveFormsModule } from '@angular/forms';
4 | import { BrowserModule } from '@angular/platform-browser';
5 |
6 | import { AppComponent } from './app.component';
7 | import { ControlErrorsComponent } from './components/control-errors/control-errors.component';
8 | import { SignupFormComponent } from './components/signup-form/signup-form.component';
9 | import { ErrorMessageDirective } from './directives/error-message.directive';
10 |
11 | @NgModule({
12 | declarations: [
13 | AppComponent,
14 | SignupFormComponent,
15 | ErrorMessageDirective,
16 | ControlErrorsComponent,
17 | ],
18 | imports: [BrowserModule, ReactiveFormsModule, HttpClientModule],
19 | providers: [],
20 | bootstrap: [AppComponent],
21 | })
22 | export class AppModule {}
23 |
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */
2 | {
3 | "compileOnSave": false,
4 | "compilerOptions": {
5 | "baseUrl": "./",
6 | "outDir": "./dist/out-tsc",
7 | "forceConsistentCasingInFileNames": true,
8 | "strict": true,
9 | "noImplicitReturns": true,
10 | "noFallthroughCasesInSwitch": true,
11 | "sourceMap": true,
12 | "declaration": false,
13 | "downlevelIteration": true,
14 | "experimentalDecorators": true,
15 | "moduleResolution": "node",
16 | "importHelpers": true,
17 | "target": "ES2022",
18 | "module": "es2020",
19 | "lib": [
20 | "es2018",
21 | "dom"
22 | ],
23 | "useDefineForClassFields": false
24 | },
25 | "angularCompilerOptions": {
26 | "enableI18nLegacyMessageIdFormat": false,
27 | "strictInjectionParameters": true,
28 | "strictInputAccessModifiers": true,
29 | "strictTemplates": true
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/client/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "ignorePatterns": ["projects/**/*"],
4 | "overrides": [
5 | {
6 | "files": ["*.ts"],
7 | "parserOptions": {
8 | "project": ["tsconfig.json"],
9 | "createDefaultProgram": true
10 | },
11 | "extends": [
12 | "plugin:@angular-eslint/recommended",
13 | "plugin:@angular-eslint/template/process-inline-templates"
14 | ],
15 | "rules": {
16 | "@angular-eslint/directive-selector": [
17 | "error",
18 | {
19 | "type": "attribute",
20 | "prefix": "app",
21 | "style": "camelCase"
22 | }
23 | ],
24 | "@angular-eslint/component-selector": [
25 | "error",
26 | {
27 | "type": "element",
28 | "prefix": "app",
29 | "style": "kebab-case"
30 | }
31 | ]
32 | }
33 | },
34 | {
35 | "files": ["*.html"],
36 | "extends": ["plugin:@angular-eslint/template/recommended"],
37 | "rules": {}
38 | }
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/client/src/app/util/findFormControl.ts:
--------------------------------------------------------------------------------
1 | import { AbstractControl, ControlContainer } from '@angular/forms';
2 |
3 | /**
4 | * Finds a form control explicitly or by name from the ControlContainer.
5 | *
6 | * @param control An existing form control, as passed with the formControl directive
7 | * @param controlName An form control name, as passed with the formControlName directive
8 | * @param controlContainer The Directive’s ControlContainer
9 | */
10 | export const findFormControl = (
11 | control?: AbstractControl,
12 | controlName?: string,
13 | controlContainer?: ControlContainer,
14 | ): AbstractControl => {
15 | if (control) {
16 | return control;
17 | }
18 | if (!controlName) {
19 | throw new Error('getFormControl: control or control name must be given');
20 | }
21 | if (!(controlContainer && controlContainer.control)) {
22 | throw new Error(
23 | 'getFormControl: control name was given but parent control not found',
24 | );
25 | }
26 | const controlFromName = controlContainer.control.get(controlName);
27 | if (!controlFromName) {
28 | throw new Error(`getFormControl: control '${controlName}' not found`);
29 | }
30 | return controlFromName;
31 | };
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | This is free and unencumbered software released into the public domain.
2 |
3 | Anyone is free to copy, modify, publish, use, compile, sell, or
4 | distribute this software, either in source code form or as a compiled
5 | binary, for any purpose, commercial or non-commercial, and by any
6 | means.
7 |
8 | In jurisdictions that recognize copyright laws, the author or authors
9 | of this software dedicate any and all copyright interest in the
10 | software to the public domain. We make this dedication for the benefit
11 | of the public at large and to the detriment of our heirs and
12 | successors. We intend this dedication to be an overt act of
13 | relinquishment in perpetuity of all present and future rights to this
14 | software under copyright law.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22 | OTHER DEALINGS IN THE SOFTWARE.
23 |
24 | For more information, please refer to
25 |
--------------------------------------------------------------------------------
/client/karma.conf.js:
--------------------------------------------------------------------------------
1 | // Karma configuration file, see link for more information
2 | // https://karma-runner.github.io/1.0/config/configuration-file.html
3 |
4 | module.exports = function (config) {
5 | config.set({
6 | basePath: '',
7 | frameworks: ['jasmine', '@angular-devkit/build-angular'],
8 | plugins: [
9 | require('karma-jasmine'),
10 | require('karma-chrome-launcher'),
11 | require('karma-jasmine-html-reporter'),
12 | require('karma-coverage'),
13 | require('@angular-devkit/build-angular/plugins/karma')
14 | ],
15 | client: {
16 | jasmine: {
17 | // you can add configuration options for Jasmine here
18 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
19 | // for example, you can disable the random execution with `random: false`
20 | // or set a specific seed with `seed: 4321`
21 | failSpecWithNoExpectations: true,
22 | },
23 | clearContext: false // leave Jasmine Spec Runner output visible in browser
24 | },
25 | jasmineHtmlReporter: {
26 | suppressAll: true // removes the duplicated traces
27 | },
28 | coverageReporter: {
29 | dir: require('path').join(__dirname, './coverage/form-testing'),
30 | subdir: '.',
31 | reporters: [
32 | { type: 'html' },
33 | { type: 'text-summary' }
34 | ]
35 | },
36 | reporters: ['progress', 'kjhtml'],
37 | port: 9876,
38 | colors: true,
39 | logLevel: config.LOG_INFO,
40 | autoWatch: true,
41 | browsers: ['Chrome'],
42 | singleRun: false,
43 | restartOnFileChange: true
44 | });
45 | };
46 |
--------------------------------------------------------------------------------
/client/src/app/services/signup.service.ts:
--------------------------------------------------------------------------------
1 | import { HttpClient } from '@angular/common/http';
2 | import { Injectable } from '@angular/core';
3 | import { Observable } from 'rxjs';
4 | import { map } from 'rxjs/operators';
5 | import { environment } from 'src/environments/environment';
6 |
7 | export interface PasswordStrength {
8 | score: number;
9 | warning: string;
10 | suggestions: string[];
11 | }
12 |
13 | export type Plan = 'personal' | 'business' | 'non-profit';
14 |
15 | export interface SignupData {
16 | plan: Plan;
17 | username: string;
18 | email: string;
19 | password: string;
20 | address: {
21 | name: string;
22 | addressLine1?: string;
23 | addressLine2: string;
24 | city: string;
25 | postcode: string;
26 | region?: string;
27 | country: string;
28 | };
29 | tos: boolean;
30 | }
31 |
32 | @Injectable({
33 | providedIn: 'root',
34 | })
35 | export class SignupService {
36 | constructor(private http: HttpClient) {}
37 |
38 | public isUsernameTaken(username: string): Observable {
39 | return this.post<{ usernameTaken: boolean }>('/username-taken', {
40 | username,
41 | }).pipe(map((result) => result.usernameTaken));
42 | }
43 |
44 | public isEmailTaken(email: string): Observable {
45 | return this.post<{ emailTaken: boolean }>('/email-taken', { email }).pipe(
46 | map((result) => result.emailTaken),
47 | );
48 | }
49 |
50 | public getPasswordStrength(password: string): Observable {
51 | return this.post('/password-strength', {
52 | password,
53 | });
54 | }
55 |
56 | public signup(data: SignupData): Observable<{ success: true }> {
57 | return this.post<{ success: true }>('/signup', data);
58 | }
59 |
60 | private post(path: string, data: any): Observable {
61 | return this.http.post(`${environment.signupServiceUrl}${path}`, data);
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "form-testing",
3 | "version": "0.0.0",
4 | "scripts": {
5 | "ng": "ng",
6 | "start": "ng serve",
7 | "build": "ng build",
8 | "test": "ng test",
9 | "lint": "ng lint",
10 | "a11y": "start-server-and-test start http-get://localhost:4200/ pa11y-ci",
11 | "pa11y-ci": "pa11y-ci",
12 | "deploy": "ng deploy --base-href=/angular-form-testing/"
13 | },
14 | "private": true,
15 | "license": "Unlicense",
16 | "author": "Mathias Schäfer (https://molily.de)",
17 | "dependencies": {
18 | "@angular/animations": "^15.1.2",
19 | "@angular/common": "^15.1.2",
20 | "@angular/compiler": "^15.1.2",
21 | "@angular/core": "^15.1.2",
22 | "@angular/forms": "^15.1.2",
23 | "@angular/platform-browser": "^15.1.2",
24 | "@angular/platform-browser-dynamic": "^15.1.2",
25 | "@angular/router": "^15.1.2",
26 | "rxjs": "~7.8.0",
27 | "tslib": "^2.5.0",
28 | "zone.js": "~0.12.0"
29 | },
30 | "devDependencies": {
31 | "@angular-devkit/build-angular": "^15.1.3",
32 | "@angular-eslint/builder": "15.2.0",
33 | "@angular-eslint/eslint-plugin": "15.2.0",
34 | "@angular-eslint/eslint-plugin-template": "15.2.0",
35 | "@angular-eslint/schematics": "15.2.0",
36 | "@angular-eslint/template-parser": "15.2.0",
37 | "@angular/cli": "^15.1.3",
38 | "@angular/compiler-cli": "^15.1.2",
39 | "@types/jasmine": "~4.3.1",
40 | "@typescript-eslint/eslint-plugin": "^5.49.0",
41 | "@typescript-eslint/parser": "^5.49.0",
42 | "angular-cli-ghpages": "^1.0.5",
43 | "eslint": "^8.32.0",
44 | "jasmine-core": "~4.5.0",
45 | "jasmine-spec-reporter": "~7.0.0",
46 | "karma": "~6.4.1",
47 | "karma-chrome-launcher": "~3.1.1",
48 | "karma-coverage": "~2.2.0",
49 | "karma-jasmine": "~5.1.0",
50 | "karma-jasmine-html-reporter": "^2.0.0",
51 | "pa11y-ci": "^3.0.1",
52 | "start-server-and-test": "^1.15.3",
53 | "typescript": "~4.9.4"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/client/src/app/components/control-errors/control-errors.component.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Component,
3 | ContentChild,
4 | Input,
5 | OnDestroy,
6 | OnInit,
7 | Optional,
8 | TemplateRef,
9 | } from '@angular/core';
10 | import {
11 | AbstractControl,
12 | ControlContainer,
13 | ValidationErrors,
14 | } from '@angular/forms';
15 |
16 | import { Subscription } from 'rxjs';
17 | import { startWith } from 'rxjs/operators';
18 | import { findFormControl } from 'src/app/util/findFormControl';
19 |
20 | interface TemplateContext {
21 | $implicit: ValidationErrors;
22 | }
23 |
24 | @Component({
25 | selector: 'app-control-errors',
26 | templateUrl: './control-errors.component.html',
27 | styleUrls: ['./control-errors.component.scss'],
28 | })
29 | export class ControlErrorsComponent implements OnInit, OnDestroy {
30 | @Input()
31 | public control?: AbstractControl;
32 |
33 | @Input()
34 | public controlName?: string;
35 |
36 | public internalControl?: AbstractControl;
37 |
38 | @ContentChild(TemplateRef)
39 | public template: TemplateRef | null = null;
40 |
41 | public templateContext: TemplateContext = {
42 | $implicit: {},
43 | };
44 |
45 | private subscription?: Subscription;
46 |
47 | constructor(
48 | @Optional()
49 | private controlContainer?: ControlContainer,
50 | ) {}
51 |
52 | public ngOnInit(): void {
53 | const control = findFormControl(
54 | this.control,
55 | this.controlName,
56 | this.controlContainer,
57 | );
58 | this.internalControl = control;
59 |
60 | this.subscription = control.statusChanges.pipe(startWith('PENDING')).subscribe(() => {
61 | this.updateTemplateContext();
62 | });
63 | }
64 |
65 | private updateTemplateContext(): void {
66 | if (this.internalControl && this.internalControl.errors) {
67 | this.templateContext = {
68 | $implicit: this.internalControl.errors,
69 | };
70 | }
71 | }
72 |
73 | public ngOnDestroy(): void {
74 | this.subscription?.unsubscribe();
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/client/src/app/directives/error-message.directive.ts:
--------------------------------------------------------------------------------
1 | import { Directive, HostBinding, Input, OnInit, Optional } from '@angular/core';
2 | import { AbstractControl, ControlContainer } from '@angular/forms';
3 |
4 | import { findFormControl } from '../util/findFormControl';
5 |
6 | /**
7 | * Directive that sets the `aria-invalid` and `aria-errormessage` attributes
8 | * when the form control is invalid and touched or dirty.
9 | *
10 | * https://w3c.github.io/aria/#aria-invalid
11 | * https://w3c.github.io/aria/#aria-errormessage
12 | *
13 | * Expects that the element either has a `formControl` or `formControlName` input.
14 | *
15 | * Expects the id of the element that contains the error messages.
16 | *
17 | * Usage examples:
18 | *
19 | *
20 | *
21 | * …
22 | */
23 | @Directive({
24 | selector: '[appErrorMessage]',
25 | })
26 | export class ErrorMessageDirective implements OnInit {
27 | @HostBinding('attr.aria-invalid')
28 | get ariaInvalid(): true | null {
29 | return this.isActive() ? true : null;
30 | }
31 |
32 | @HostBinding('attr.aria-errormessage')
33 | get ariaErrormessage(): string | null {
34 | return this.isActive() && this.appErrorMessage ? this.appErrorMessage : null;
35 | }
36 |
37 | @Input()
38 | public appErrorMessage?: string;
39 |
40 | @Input()
41 | public formControl?: AbstractControl;
42 |
43 | @Input()
44 | public formControlName?: string;
45 |
46 | private control?: AbstractControl;
47 |
48 | constructor(@Optional() private controlContainer?: ControlContainer) {}
49 |
50 | public ngOnInit(): void {
51 | this.control = findFormControl(
52 | this.formControl,
53 | this.formControlName,
54 | this.controlContainer,
55 | );
56 | }
57 |
58 | /**
59 | * Whether link to the errors is established.
60 | */
61 | private isActive(): boolean {
62 | const { control } = this;
63 | return control !== undefined && control.invalid && (control.touched || control.dirty);
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/client/angular.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
3 | "version": 1,
4 | "newProjectRoot": "projects",
5 | "projects": {
6 | "form-testing": {
7 | "projectType": "application",
8 | "schematics": {
9 | "@schematics/angular:component": {
10 | "style": "scss"
11 | },
12 | "@schematics/angular:application": {
13 | "strict": true
14 | }
15 | },
16 | "root": "",
17 | "sourceRoot": "src",
18 | "prefix": "app",
19 | "architect": {
20 | "build": {
21 | "builder": "@angular-devkit/build-angular:browser",
22 | "options": {
23 | "outputPath": "dist/form-testing",
24 | "index": "src/index.html",
25 | "main": "src/main.ts",
26 | "polyfills": ["zone.js"],
27 | "tsConfig": "tsconfig.app.json",
28 | "assets": ["src/favicon.ico", "src/assets"],
29 | "styles": ["src/styles.scss"],
30 | "scripts": []
31 | },
32 | "configurations": {
33 | "production": {
34 | "budgets": [
35 | {
36 | "type": "initial",
37 | "maximumWarning": "500kb",
38 | "maximumError": "1mb"
39 | },
40 | {
41 | "type": "anyComponentStyle",
42 | "maximumWarning": "2kb",
43 | "maximumError": "4kb"
44 | }
45 | ],
46 | "fileReplacements": [
47 | {
48 | "replace": "src/environments/environment.ts",
49 | "with": "src/environments/environment.prod.ts"
50 | }
51 | ],
52 | "outputHashing": "all"
53 | },
54 | "development": {
55 | "buildOptimizer": false,
56 | "optimization": false,
57 | "vendorChunk": true,
58 | "extractLicenses": false,
59 | "sourceMap": true,
60 | "namedChunks": true
61 | }
62 | },
63 | "defaultConfiguration": "production"
64 | },
65 | "serve": {
66 | "builder": "@angular-devkit/build-angular:dev-server",
67 | "options": {
68 | "proxyConfig": "src/proxy.conf.json"
69 | },
70 | "configurations": {
71 | "production": {
72 | "browserTarget": "form-testing:build:production"
73 | },
74 | "development": {
75 | "browserTarget": "form-testing:build:development"
76 | }
77 | },
78 | "defaultConfiguration": "development"
79 | },
80 | "extract-i18n": {
81 | "builder": "@angular-devkit/build-angular:extract-i18n",
82 | "options": {
83 | "browserTarget": "form-testing:build"
84 | }
85 | },
86 | "test": {
87 | "builder": "@angular-devkit/build-angular:karma",
88 | "options": {
89 | "polyfills": ["zone.js", "zone.js/testing"],
90 | "tsConfig": "tsconfig.spec.json",
91 | "karmaConfig": "karma.conf.js",
92 | "assets": ["src/favicon.ico", "src/assets"],
93 | "styles": ["src/styles.scss"],
94 | "scripts": []
95 | }
96 | },
97 | "lint": {
98 | "builder": "@angular-eslint/builder:lint",
99 | "options": {
100 | "lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
101 | }
102 | },
103 | "deploy": {
104 | "builder": "angular-cli-ghpages:deploy",
105 | "options": {}
106 | }
107 | }
108 | }
109 | },
110 | "cli": {
111 | "schematicCollections": ["@angular-eslint/schematics"]
112 | },
113 | "schematics": {
114 | "@angular-eslint/schematics:application": {
115 | "setParserOptionsProject": true
116 | },
117 | "@angular-eslint/schematics:library": {
118 | "setParserOptionsProject": true
119 | }
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/client/src/app/services/signup.service.spec.ts:
--------------------------------------------------------------------------------
1 | import { HttpErrorResponse } from '@angular/common/http';
2 | import {
3 | HttpClientTestingModule,
4 | HttpTestingController,
5 | } from '@angular/common/http/testing';
6 | import { TestBed } from '@angular/core/testing';
7 |
8 | import { signupData } from '../spec-helpers/signup-data.spec-helper';
9 | import { PasswordStrength, SignupService } from './signup.service';
10 |
11 | const username = 'minnie';
12 | const email = 'minnie@mouse.net';
13 | const password = 'abcdef';
14 |
15 | const passwordStrength: PasswordStrength = {
16 | score: 2,
17 | warning: 'too short',
18 | suggestions: ['try a longer password'],
19 | };
20 |
21 | describe('SignupService', () => {
22 | let service: SignupService;
23 | let controller: HttpTestingController;
24 |
25 | beforeEach(() => {
26 | TestBed.configureTestingModule({
27 | imports: [HttpClientTestingModule],
28 | });
29 | service = TestBed.inject(SignupService);
30 | controller = TestBed.inject(HttpTestingController);
31 | });
32 |
33 | afterEach(() => {
34 | controller.verify();
35 | });
36 |
37 | it('checks if the username is taken', () => {
38 | let result: boolean | undefined;
39 | service.isUsernameTaken(username).subscribe((otherResult) => {
40 | result = otherResult;
41 | });
42 |
43 | const request = controller.expectOne({
44 | method: 'POST',
45 | url: 'http://localhost:4200/api/username-taken',
46 | });
47 | expect(request.request.body).toEqual({ username });
48 | request.flush({ usernameTaken: true });
49 |
50 | expect(result).toBe(true);
51 | });
52 |
53 | it('checks if the email is taken', () => {
54 | let result: boolean | undefined;
55 | service.isEmailTaken(email).subscribe((otherResult) => {
56 | result = otherResult;
57 | });
58 |
59 | const request = controller.expectOne({
60 | method: 'POST',
61 | url: 'http://localhost:4200/api/email-taken',
62 | });
63 | expect(request.request.body).toEqual({ email });
64 | request.flush({ emailTaken: true });
65 |
66 | expect(result).toBe(true);
67 | });
68 |
69 | it('gets the password strength', () => {
70 | let result: PasswordStrength | undefined;
71 | service.getPasswordStrength(password).subscribe((otherResult) => {
72 | result = otherResult;
73 | });
74 |
75 | const request = controller.expectOne({
76 | method: 'POST',
77 | url: 'http://localhost:4200/api/password-strength',
78 | });
79 | expect(request.request.body).toEqual({ password });
80 | request.flush(passwordStrength);
81 |
82 | expect(result).toBe(passwordStrength);
83 | });
84 |
85 | it('signs up', () => {
86 | let result: { success: true } | undefined;
87 | service.signup(signupData).subscribe((otherResult) => {
88 | result = otherResult;
89 | });
90 |
91 | const request = controller.expectOne({
92 | method: 'POST',
93 | url: 'http://localhost:4200/api/signup',
94 | });
95 | expect(request.request.body).toEqual(signupData);
96 | request.flush({ success: true });
97 |
98 | expect(result).toEqual({ success: true });
99 | });
100 |
101 | it('passes the errors through', () => {
102 | const errors: HttpErrorResponse[] = [];
103 | const recordError = (error: HttpErrorResponse) => {
104 | errors.push(error);
105 | };
106 |
107 | service.isUsernameTaken(username).subscribe(fail, recordError, fail);
108 | service.getPasswordStrength(password).subscribe(fail, recordError, fail);
109 | service.signup(signupData).subscribe(fail, recordError, fail);
110 |
111 | const status = 500;
112 | const statusText = 'Internal Server Error';
113 | const errorEvent = new ErrorEvent('API error');
114 |
115 | const requests = controller.match(() => true);
116 | requests.forEach((request) => {
117 | request.error(errorEvent, { status, statusText });
118 | });
119 |
120 | expect(errors.length).toBe(3);
121 | errors.forEach((error) => {
122 | expect(error.error).toBe(errorEvent);
123 | expect(error.status).toBe(status);
124 | expect(error.statusText).toBe(statusText);
125 | });
126 | });
127 | });
128 |
--------------------------------------------------------------------------------
/client/src/styles.scss:
--------------------------------------------------------------------------------
1 | @use 'sass:color';
2 | @use 'app/sass/variables' as v;
3 | @use 'app/sass/mixins' as m;
4 | @use 'app/sass/functions' as f;
5 |
6 | *,
7 | *::before,
8 | *::after {
9 | box-sizing: border-box;
10 | }
11 |
12 | :root {
13 | color-scheme: light dark;
14 | }
15 |
16 | body {
17 | margin: 1rem;
18 | padding: 0;
19 |
20 | font-family: '-apple-system', BlinkMacSystemFont, 'Helvetica Neue', Helvetica,
21 | Arial, sans-serif;
22 | line-height: 1.2;
23 |
24 | background-color: v.$background-color-light;
25 | color: v.$text-color-light;
26 |
27 | @include m.dark-scheme {
28 | background-color: v.$background-color-dark;
29 | color: v.$text-color-dark;
30 | }
31 | }
32 |
33 | h1,
34 | h2,
35 | h3,
36 | ul,
37 | ol,
38 | dl,
39 | p {
40 | margin: 0 0 1rem;
41 | }
42 |
43 | a {
44 | @include m.dark-scheme {
45 | color: #77f;
46 |
47 | &:visited {
48 | color: #bbb;
49 | }
50 | }
51 | }
52 |
53 | button,
54 | input[type='text'],
55 | input[type='number'],
56 | input[type='email'],
57 | input[type='password'],
58 | select {
59 | padding: 0 0.5rem;
60 | font-size: inherit;
61 | font-family: inherit;
62 | color: inherit;
63 | line-height: 2.5;
64 | }
65 |
66 | button {
67 | min-width: 2rem;
68 | border: 0;
69 | padding: 0.5rem;
70 | line-height: 1.2;
71 | background-color: v.$primary-color;
72 | color: #fff;
73 |
74 | &:disabled {
75 | background-color: color.adjust(v.$primary-color, $lightness: -20%, $saturation: -70%);
76 | color: #bbb;
77 | }
78 |
79 | &:focus {
80 | outline: 0;
81 | box-shadow: v.$focus-shadow;
82 | }
83 | }
84 |
85 | input[type='text'],
86 | input[type='number'],
87 | input[type='email'],
88 | input[type='password'],
89 | select {
90 | background-color: v.$field-background-light;
91 | border: 2px solid v.$field-border-light;
92 | border-radius: 1px;
93 |
94 | @include m.dark-scheme {
95 | border-color: v.$field-border-dark;
96 | background-color: v.$field-background-dark;
97 | }
98 |
99 | &:focus {
100 | outline: 0;
101 | border-color: v.$primary-color;
102 | box-shadow: v.$focus-shadow;
103 | }
104 | }
105 |
106 | input[type='checkbox'] {
107 | width: 1.5rem;
108 | height: 1.5rem;
109 |
110 | &:focus {
111 | outline: 0;
112 | box-shadow: v.$focus-shadow;
113 | }
114 | }
115 |
116 | $error-shadow: 0 0 0 4px rgba(220, 20, 60, 0.1) inset;
117 |
118 | input[type='text'].ng-invalid.ng-touched,
119 | input[type='number'].ng-invalid.ng-touched,
120 | input[type='email'].ng-invalid.ng-touched,
121 | input[type='password'].ng-invalid.ng-touched,
122 | select.ng-invalid.ng-touched {
123 | border-color: v.$error-color-light;
124 | box-shadow: $error-shadow;
125 |
126 | @include m.dark-scheme {
127 | border-color: v.$error-color-dark;
128 | }
129 |
130 | &:focus {
131 | border-color: v.$primary-color;
132 | box-shadow: v.$focus-shadow, $error-shadow;
133 | }
134 | }
135 |
136 | select {
137 | -moz-appearance: none;
138 | -webkit-appearance: none;
139 | appearance: none;
140 | color: inherit;
141 | background-position: right 10px center;
142 | background-size: auto 35%;
143 | background-repeat: no-repeat;
144 | $fill-light: f.str-replace('#{v.$field-border-light}', '#', '%23');
145 | background-image: url('data:image/svg+xml,');
146 |
147 | @include m.dark-scheme {
148 | $fill-dark: f.str-replace('#{v.$field-border-dark}', '#', '%23');
149 | background-image: url('data:image/svg+xml,');
150 | }
151 | }
152 |
153 | .error-text {
154 | color: v.$error-color-light;
155 |
156 | @include m.dark-scheme {
157 | color: v.$error-color-dark;
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/client/src/app/components/signup-form/signup-form.component.ts:
--------------------------------------------------------------------------------
1 | import { Component } from '@angular/core';
2 | import {
3 | AbstractControl,
4 | AsyncValidatorFn,
5 | FormGroup,
6 | NonNullableFormBuilder,
7 | Validators,
8 | } from '@angular/forms';
9 |
10 | import {
11 | EMPTY,
12 | merge,
13 | Subject,
14 | timer,
15 | } from 'rxjs';
16 | import {
17 | catchError,
18 | debounceTime,
19 | first,
20 | map,
21 | switchMap,
22 | } from 'rxjs/operators';
23 | import {
24 | PasswordStrength,
25 | Plan,
26 | SignupService,
27 | } from 'src/app/services/signup.service';
28 |
29 | const { email, maxLength, pattern, required, requiredTrue } = Validators;
30 |
31 | /**
32 | * Wait for this time before sending async validation requests to the server.
33 | */
34 | const ASYNC_VALIDATION_DELAY = 1000;
35 |
36 | @Component({
37 | selector: 'app-signup-form',
38 | templateUrl: './signup-form.component.html',
39 | styleUrls: ['./signup-form.component.scss'],
40 | })
41 | export class SignupFormComponent {
42 | public PERSONAL: Plan = 'personal';
43 | public BUSINESS: Plan = 'business';
44 | public NON_PROFIT: Plan = 'non-profit';
45 |
46 | private passwordSubject = new Subject();
47 | private passwordStrengthFromServer$ = this.passwordSubject.pipe(
48 | debounceTime(ASYNC_VALIDATION_DELAY),
49 | switchMap((password) =>
50 | this.signupService.getPasswordStrength(password).pipe(catchError(() => EMPTY)),
51 | ),
52 | );
53 | public passwordStrength$ = merge(
54 | this.passwordSubject.pipe(map(() => null)),
55 | this.passwordStrengthFromServer$,
56 | );
57 |
58 | public showPassword = false;
59 |
60 | public form = this.formBuilder.group({
61 | plan: this.formBuilder.control('personal', required),
62 | username: [
63 | '',
64 | [required, pattern('[a-zA-Z0-9.]+'), maxLength(50)],
65 | (control: AbstractControl) => this.validateUsername(control.value),
66 | ],
67 | email: [
68 | '',
69 | [required, email, maxLength(100)],
70 | (control: AbstractControl) => this.validateEmail(control.value),
71 | ],
72 | password: ['', required, () => this.validatePassword()],
73 | tos: [false, requiredTrue],
74 | address: this.formBuilder.group({
75 | name: ['', required],
76 | addressLine1: [''],
77 | addressLine2: ['', required],
78 | city: ['', required],
79 | postcode: ['', required],
80 | region: [''],
81 | country: ['', required],
82 | }),
83 | });
84 |
85 | public plan = this.form.controls.plan;
86 | public addressLine1 = (this.form.controls.address as FormGroup).controls.addressLine1;
87 |
88 | public passwordStrength?: PasswordStrength;
89 |
90 | public submitProgress: 'idle' | 'success' | 'error' = 'idle';
91 |
92 | constructor(
93 | private signupService: SignupService,
94 | private formBuilder: NonNullableFormBuilder,
95 | ) {
96 | this.plan.valueChanges.subscribe((plan) => {
97 | if (plan !== this.PERSONAL) {
98 | this.addressLine1.setValidators(required);
99 | } else {
100 | this.addressLine1.setValidators(null);
101 | }
102 | this.addressLine1.updateValueAndValidity();
103 | });
104 | }
105 |
106 | public getPasswordStrength(): void {
107 | const password = this.form.controls.password.value;
108 | if (password !== null) {
109 | this.passwordSubject.next(password);
110 | }
111 | }
112 |
113 | private validateUsername(username: string): ReturnType {
114 | return timer(ASYNC_VALIDATION_DELAY).pipe(
115 | switchMap(() => this.signupService.isUsernameTaken(username)),
116 | map((usernameTaken) => (usernameTaken ? { taken: true } : null)),
117 | );
118 | }
119 |
120 | private validateEmail(username: string): ReturnType {
121 | return timer(ASYNC_VALIDATION_DELAY).pipe(
122 | switchMap(() => this.signupService.isEmailTaken(username)),
123 | map((emailTaken) => (emailTaken ? { taken: true } : null)),
124 | );
125 | }
126 |
127 | private validatePassword(): ReturnType {
128 | return this.passwordStrength$.pipe(
129 | first((passwordStrength) => passwordStrength !== null),
130 | map((passwordStrength) =>
131 | passwordStrength && passwordStrength.score < 3 ? { weak: true } : null,
132 | ),
133 | );
134 | }
135 |
136 | public onSubmit(): void {
137 | if (!this.form.valid) return;
138 | this.signupService.signup(this.form.getRawValue()).subscribe({
139 | complete: () => {
140 | this.submitProgress = 'success';
141 | },
142 | error: () => {
143 | this.submitProgress = 'error';
144 | },
145 | });
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/server/index.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const rateLimit = require('express-rate-limit');
3 | const cors = require('cors');
4 | const zxcvbn = require('zxcvbn');
5 |
6 | /**
7 | * Fake service for the form example. This service does nothing useful
8 | * and just simulates input validation and signup. Do not use it in production.
9 | */
10 |
11 | const PORT = process.env.PORT || 3000;
12 |
13 | /**
14 | * Regular expression for validating an email address.
15 | * Taken from Angular:
16 | * https://github.com/angular/angular/blob/43b4940c9d595c542a00795976bc3168dd0ca5af/packages/forms/src/validators.ts#L68-L99
17 | * Copyright Google LLC All Rights Reserved.
18 | * MIT-style license: https://angular.io/license
19 | */
20 | const EMAIL_REGEXP =
21 | /^(?=.{1,254}$)(?=.{1,64}@)[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
22 | const USERNAME_REGEXP = /^[a-zA-Z0-9.]+$/;
23 |
24 | /**
25 | * Allowed origins
26 | */
27 | const ALLOWED_ORIGINS = ['https://molily.github.io'];
28 |
29 | /**
30 | * Available plans
31 | */
32 | const PLANS = ['personal', 'business', 'non-profit'];
33 |
34 | /**
35 | * Holds the users in memory.
36 | */
37 | const users = [];
38 |
39 | const isUsernameSyntaxValid = (username) =>
40 | typeof username === 'string' &&
41 | username !== '' &&
42 | username.length <= 50 &&
43 | USERNAME_REGEXP.test(username);
44 |
45 | const isEmailSyntaxValid = (email) =>
46 | typeof email === 'string' &&
47 | email !== '' &&
48 | email.length <= 100 &&
49 | EMAIL_REGEXP.test(email);
50 |
51 | const isPasswordSyntaxValid = (password) =>
52 | typeof password === 'string' && password !== '' && password.length <= 200;
53 |
54 | const isUsernameTaken = (username) => users.some((user) => user.username == username);
55 |
56 | const isEmailTaken = (email) => users.some((user) => user.email == email);
57 |
58 | const app = express();
59 | app.use(express.json());
60 |
61 | // Enable CORS
62 | app.use(cors({ origin: ALLOWED_ORIGINS }));
63 |
64 | // Enable API limiter
65 | const apiLimiter = rateLimit({
66 | windowMs: 15 * 60 * 1000, // 15 minutes
67 | max: 100,
68 | standardHeaders: false,
69 | legacyHeaders: false,
70 | });
71 | app.use(apiLimiter);
72 |
73 | app.get('/', (_req, res) => {
74 | res.send(
75 | 'It worksIt works',
76 | );
77 | });
78 |
79 | app.post('/password-strength', (req, res) => {
80 | const { password } = req.body;
81 | if (!isPasswordSyntaxValid(password)) {
82 | res.sendStatus(400);
83 | return;
84 | }
85 | const result = zxcvbn(password);
86 | res.send({
87 | score: result.score,
88 | warning: result.feedback.warning,
89 | suggestions: result.feedback.suggestions,
90 | });
91 | });
92 |
93 | app.post('/username-taken', (req, res) => {
94 | const { username } = req.body;
95 | if (!isUsernameSyntaxValid(username)) {
96 | res.sendStatus(400);
97 | return;
98 | }
99 | res.send({ usernameTaken: isUsernameTaken(username) });
100 | });
101 |
102 | app.post('/email-taken', (req, res) => {
103 | const { email } = req.body;
104 | if (!isEmailSyntaxValid(email)) {
105 | res.sendStatus(400);
106 | return;
107 | }
108 | res.send({ emailTaken: isEmailTaken(email) });
109 | });
110 |
111 | const isNonEmptyString = (object, property) => {
112 | const value = object[property];
113 | return typeof value === 'string' && value !== '';
114 | };
115 |
116 | const validateSignup = (body) => {
117 | if (!body) {
118 | return { valid: false, error: 'Bad request' };
119 | }
120 | const { plan, username, email, password, address, tos } = body;
121 | const checks = {
122 | plan: () => PLANS.includes(plan),
123 | username: () => isUsernameSyntaxValid(username) && !isUsernameTaken(username),
124 | email: () => isEmailSyntaxValid(email) && !isEmailTaken(email),
125 | password: () => isPasswordSyntaxValid(password) && zxcvbn(password).score >= 3,
126 | address: () => !!body.address,
127 | name: () => isNonEmptyString(address, 'name'),
128 | addressLine1: () =>
129 | plan !== 'personal' ? isNonEmptyString(address, 'addressLine1', address) : true,
130 | addressLine2: () => isNonEmptyString(address, 'addressLine2'),
131 | city: () => isNonEmptyString(address, 'city'),
132 | postcode: () => isNonEmptyString(address, 'postcode'),
133 | country: () => isNonEmptyString(address, 'country'),
134 | tos: () => tos === true,
135 | };
136 | for (const [name, check] of Object.entries(checks)) {
137 | const valid = check();
138 | if (!valid) {
139 | return { valid: false, error: `${name} is invalid` };
140 | }
141 | }
142 | return { valid: true };
143 | };
144 |
145 | app.post('/signup', (req, res) => {
146 | const validationResult = validateSignup(req.body);
147 | if (!validationResult.valid) {
148 | res.status(400).send({ error: validationResult.error });
149 | return;
150 | }
151 | const { username, email, password } = req.body;
152 | users.push({
153 | username,
154 | email,
155 | password,
156 | });
157 | console.log(`Successful signup: ${username}`);
158 | res.send({ success: true });
159 | });
160 |
161 | app.listen(PORT);
162 | console.log('Server running.');
163 |
--------------------------------------------------------------------------------
/client/src/app/components/signup-form/signup-form.component.scss:
--------------------------------------------------------------------------------
1 | @use '../../sass/variables' as v;
2 | @use '../../sass/mixins' as m;
3 |
4 | fieldset {
5 | position: relative;
6 | border-style: solid none none;
7 | border-color: gray;
8 | border-width: 2px;
9 | border-radius: 2px;
10 | margin: 0 0 2rem;
11 | padding: 5rem 0 0;
12 |
13 | @media (min-width: 40rem) {
14 | padding-left: 2rem;
15 | padding-right: 2rem;
16 | }
17 | }
18 |
19 | legend {
20 | position: absolute;
21 | top: 1.5rem;
22 | font-size: 1.4rem;
23 | font-weight: bold;
24 | }
25 |
26 | .field-block {
27 | margin-bottom: 1.5rem;
28 | }
29 |
30 | label {
31 | display: block;
32 | font-weight: bold;
33 | }
34 |
35 | input[type='text'],
36 | input[type='number'],
37 | input[type='email'],
38 | input[type='password'],
39 | select,
40 | button[type='submit'] {
41 | width: 100%;
42 | }
43 |
44 | @media (min-width: 40rem) {
45 | .field-block {
46 | display: flex;
47 | }
48 |
49 | .field-and-label {
50 | margin-right: 2rem;
51 | margin-bottom: 0;
52 | flex: 0 0 20rem;
53 | }
54 |
55 | .field-info {
56 | padding-top: 1.2rem + 0.5rem;
57 | }
58 |
59 | .field-info-checkbox {
60 | padding-top: 0;
61 | }
62 | }
63 |
64 | .label-text {
65 | display: flex;
66 | justify-content: space-between;
67 | padding-bottom: 0.5rem;
68 | }
69 |
70 | .necessity-required,
71 | .necessity-optional {
72 | font-weight: normal;
73 | font-size: 80%;
74 | }
75 |
76 | .necessity-optional {
77 | color: #666;
78 |
79 | @include m.dark-scheme {
80 | color: #aaa;
81 | }
82 | }
83 |
84 | .checkbox-and-label-text {
85 | position: relative;
86 | display: flex;
87 | align-items: flex-start;
88 | }
89 |
90 | input[type='checkbox'] {
91 | margin: 0 1rem;
92 | }
93 |
94 | .checkbox-label-text {
95 | display: inline;
96 | font-weight: normal;
97 | }
98 |
99 | .plans {
100 | padding: 0;
101 | list-style-type: none;
102 |
103 | @media (min-width: 40rem) {
104 | display: flex;
105 | margin: 0 -0.5rem;
106 | }
107 | }
108 |
109 | .plan {
110 | text-align: center;
111 |
112 | @media (min-width: 40rem) {
113 | flex: 1;
114 | margin: 0 0.5rem;
115 | }
116 | }
117 |
118 | .plan + .plan {
119 | margin-top: 1rem;
120 |
121 | @media (min-width: 40rem) {
122 | margin-top: 0;
123 | }
124 | }
125 |
126 | .plan-label {
127 | height: 100%;
128 | }
129 |
130 | .plan-radio {
131 | position: absolute;
132 | left: 0;
133 | top: 0;
134 | height: 1px;
135 | width: 1px;
136 | overflow: hidden;
137 | clip: rect(1px, 1px, 1px, 1px);
138 | }
139 |
140 | .plan-card {
141 | display: flex;
142 | flex-direction: column;
143 | justify-content: center;
144 | border: 2px solid v.$field-border-light;
145 | border-radius: 2px;
146 | background-color: v.$field-background-light;
147 | padding: 1.5rem 1rem;
148 | height: 100%;
149 | transition-property: color, border-color, transform, background-color;
150 | transition-duration: 150ms;
151 | transition-timing-function: ease;
152 | transform: scale(0.965);
153 |
154 | @include m.dark-scheme {
155 | border-color: v.$field-border-dark;
156 | background-color: v.$field-background-dark;
157 | }
158 | }
159 |
160 | .plan-radio:checked + .plan-card {
161 | transform: scale(1);
162 | border-color: v.$field-border-selected-light;
163 | color: v.$field-text-selected-light;
164 | background-color: v.$field-background-selected-light;
165 |
166 | @include m.dark-scheme {
167 | border-color: v.$field-border-selected-dark;
168 | color: v.$field-text-selected-dark;
169 | background-color: v.$field-background-selected-dark;
170 | }
171 | }
172 |
173 | .plan-radio:focus + .plan-card {
174 | border-color: v.$primary-color;
175 | }
176 |
177 | .plan-name {
178 | font-size: 1.5rem;
179 |
180 | @media (min-width: 40rem) {
181 | font-size: 1.6rem;
182 | }
183 | }
184 |
185 | .plan-description {
186 | font-weight: normal;
187 |
188 | @media (min-width: 40rem) {
189 | font-size: 1.2rem;
190 | }
191 |
192 | &:last-child {
193 | margin-bottom: 0;
194 | }
195 | }
196 |
197 | .password-strength-weak {
198 | color: v.$error-color-light;
199 |
200 | @include m.dark-scheme {
201 | color: v.$error-color-dark;
202 | }
203 | }
204 |
205 | .password-strength-fair {
206 | color: orange;
207 | }
208 |
209 | .password-strength-strong {
210 | color: v.$success-color-light;
211 |
212 | @include m.dark-scheme {
213 | color: v.$success-color-dark;
214 | }
215 | }
216 |
217 | button[type='submit'] {
218 | width: 100%;
219 | font-size: 1.2rem;
220 |
221 | @media (min-width: 40rem) {
222 | max-width: 20rem;
223 | }
224 | }
225 |
226 | .form-submit-success,
227 | .form-submit-error {
228 | padding: 1rem;
229 | }
230 |
231 | .form-submit-success {
232 | background-color: palegreen;
233 | color: green;
234 | }
235 |
236 | .form-submit-error {
237 | background-color: blanchedalmond;
238 | }
239 |
240 | /*
241 | Hide and show content accessibly. The content is visually hidden,
242 | but assistive technologies like screen readers still read it.
243 | http://snook.ca/archives/html_and_css/hiding-content-for-accessibility
244 | */
245 | .visually-hidden {
246 | position: absolute;
247 | width: 1px;
248 | height: 1px;
249 | margin: 0;
250 | padding: 0;
251 | overflow: hidden;
252 | clip: rect(0 0 0 0);
253 | border: 0;
254 | white-space: nowrap;
255 | }
256 |
--------------------------------------------------------------------------------
/client/src/app/directives/error-message.directive.spec.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Component,
3 | Type,
4 | } from '@angular/core';
5 | import {
6 | ComponentFixture,
7 | TestBed,
8 | } from '@angular/core/testing';
9 | import {
10 | FormControl,
11 | FormGroup,
12 | ReactiveFormsModule,
13 | Validators,
14 | } from '@angular/forms';
15 |
16 | import {
17 | dispatchFakeEvent,
18 | findEl,
19 | setFieldElementValue,
20 | } from '../spec-helpers/element.spec-helper';
21 | import { ErrorMessageDirective } from './error-message.directive';
22 |
23 | describe('ErrorMessageDirective', () => {
24 | let fixture: ComponentFixture