|
| 1 | +import { Component, inject, resource, signal } from '@angular/core'; |
| 2 | +import { apply, applyEach, applyWhen, debounce, disabled, email, Field, FieldTree, form, maxLength, metadata, min, minLength, pattern, required, schema, submit, validate, validateAsync, validateTree, ValidationError, WithField } from '@angular/forms/signals'; |
| 3 | + |
| 4 | +import { BackButton } from '../back-button/back-button'; |
| 5 | +import { DebugOutput } from '../debug-output/debug-output'; |
| 6 | +import { FormFieldInfo } from '../form-field-info/form-field-info'; |
| 7 | +import { FIELD_INFO } from '../form-props'; |
| 8 | +import { FieldAriaAttributes } from '../field-aria-attributes'; |
| 9 | +import { GenderIdentity, IdentityForm, identitySchema, initialGenderIdentityState } from '../identity-form/identity-form'; |
| 10 | +import { Multiselect } from '../multiselect/multiselect'; |
| 11 | +import { RegistrationService } from '../registration-service'; |
| 12 | + |
| 13 | +export interface RegisterFormData { |
| 14 | + username: string; |
| 15 | + identity: GenderIdentity; |
| 16 | + age: number; |
| 17 | + password: { pw1: string; pw2: string }; |
| 18 | + email: string[]; |
| 19 | + newsletter: boolean; |
| 20 | + newsletterTopics: string[]; |
| 21 | + agreeToTermsAndConditions: boolean; |
| 22 | +} |
| 23 | + |
| 24 | +const initialState: RegisterFormData = { |
| 25 | + username: '', |
| 26 | + identity: initialGenderIdentityState, |
| 27 | + age: 18, |
| 28 | + password: { pw1: '', pw2: '' }, |
| 29 | + email: [''], |
| 30 | + newsletter: true, |
| 31 | + newsletterTopics: ['Angular'], |
| 32 | + agreeToTermsAndConditions: false, |
| 33 | +}; |
| 34 | + |
| 35 | +export const formSchema = schema<RegisterFormData>((schemaPath) => { |
| 36 | + // Username validation |
| 37 | + required(schemaPath.username, { message: 'Username is required' }); |
| 38 | + minLength(schemaPath.username, 3, { message: 'A username must be at least 3 characters long' }); |
| 39 | + maxLength(schemaPath.username, 12, { message: 'A username can be max. 12 characters long' }); |
| 40 | + debounce(schemaPath.username, 500); |
| 41 | + validateAsync(schemaPath.username, { |
| 42 | + // Reactive params |
| 43 | + params: (ctx) => ctx.value(), |
| 44 | + // Factory creating a resource |
| 45 | + factory: (params) => { |
| 46 | + const registrationService = inject(RegistrationService); |
| 47 | + return resource({ |
| 48 | + params, |
| 49 | + loader: async ({ params }) => { |
| 50 | + return await registrationService.checkUserExists(params); |
| 51 | + }, |
| 52 | + }); |
| 53 | + }, |
| 54 | + // Maps resource to error |
| 55 | + onSuccess: (result) => { |
| 56 | + return result |
| 57 | + ? { |
| 58 | + kind: 'userExists', |
| 59 | + message: 'The username you entered was already taken', |
| 60 | + } |
| 61 | + : undefined; |
| 62 | + }, |
| 63 | + onError: () => undefined |
| 64 | + }); |
| 65 | + metadata(schemaPath.username, FIELD_INFO, () => "A username must consists of 3-12 characters.") |
| 66 | + |
| 67 | + // Age validation |
| 68 | + min(schemaPath.age, 18, { message: 'You must be >=18 years old.' }); |
| 69 | + |
| 70 | + // Terms and conditions |
| 71 | + required(schemaPath.agreeToTermsAndConditions, { |
| 72 | + message: 'You must agree to the terms and conditions.', |
| 73 | + }); |
| 74 | + |
| 75 | + // E-Mail validation |
| 76 | + validate(schemaPath.email, (ctx) => |
| 77 | + !ctx.value().some((e) => e) |
| 78 | + ? { |
| 79 | + kind: 'atLeastOneEmail', |
| 80 | + message: 'At least one E-Mail address must be added', |
| 81 | + } |
| 82 | + : undefined |
| 83 | + ); |
| 84 | + applyEach(schemaPath.email, (emailPath) => { |
| 85 | + email(emailPath, { message: 'E-Mail format is invalid' }); |
| 86 | + }); |
| 87 | + metadata(schemaPath.email, FIELD_INFO, () => "Please enter at least one valid E-Mail address") |
| 88 | + |
| 89 | + // Password validation |
| 90 | + required(schemaPath.password.pw1, { message: 'A password is required' }); |
| 91 | + required(schemaPath.password.pw2, { |
| 92 | + message: 'A password confirmation is required', |
| 93 | + }); |
| 94 | + minLength(schemaPath.password.pw1, 8, { |
| 95 | + message: 'A password must be at least 8 characters long', |
| 96 | + }); |
| 97 | + pattern( |
| 98 | + schemaPath.password.pw1, |
| 99 | + new RegExp('^.*[!@#$%^&*(),.?":{}|<>\\[\\]\\\\/~`_+=;\'\\-].*$'), |
| 100 | + { message: 'The passwort must contain at least one special character' } |
| 101 | + ); |
| 102 | + validateTree(schemaPath.password, (ctx) => { |
| 103 | + return ctx.value().pw2 === ctx.value().pw1 |
| 104 | + ? undefined |
| 105 | + : { |
| 106 | + field: ctx.field.pw2, // assign the error to the second password field |
| 107 | + kind: 'confirmationPassword', |
| 108 | + message: 'The entered password must match with the one specified in "Password" field', |
| 109 | + }; |
| 110 | + }); |
| 111 | + metadata(schemaPath.password, FIELD_INFO, () => "Please enter a password with min 8 characters and a special character.") |
| 112 | + |
| 113 | + // Newsletter validation |
| 114 | + applyWhen( |
| 115 | + schemaPath, |
| 116 | + (ctx) => ctx.value().newsletter, |
| 117 | + (schemaPathWhenTrue) => { |
| 118 | + validate(schemaPathWhenTrue.newsletterTopics, (ctx) => |
| 119 | + !ctx.value().length |
| 120 | + ? { |
| 121 | + kind: 'noTopicSelected', |
| 122 | + message: 'Select at least one newsletter topic', |
| 123 | + } |
| 124 | + : undefined |
| 125 | + ); |
| 126 | + } |
| 127 | + ); |
| 128 | + |
| 129 | + // Disable newsletter topics when newsletter is unchecked |
| 130 | + disabled(schemaPath.newsletterTopics, (ctx) => !ctx.valueOf(schemaPath.newsletter)); |
| 131 | + |
| 132 | + // apply child schema for identity checks |
| 133 | + apply(schemaPath.identity, identitySchema); |
| 134 | +}); |
| 135 | + |
| 136 | +@Component({ |
| 137 | + selector: 'app-registration-form-4', |
| 138 | + imports: [BackButton, Field, DebugOutput, FormFieldInfo, IdentityForm, Multiselect, FieldAriaAttributes], |
| 139 | + templateUrl: './registration-form-4.html', |
| 140 | + styleUrl: './registration-form-4.scss', |
| 141 | + // Also possible: set SignalFormsConfig only for local component: |
| 142 | + // providers: [ |
| 143 | + // provideSignalFormsConfig(signalFormsConfig) |
| 144 | + // ] |
| 145 | +}) |
| 146 | +export class RegistrationForm4 { |
| 147 | + readonly #registrationService = inject(RegistrationService); |
| 148 | + protected readonly registrationModel = signal<RegisterFormData>(initialState); |
| 149 | + |
| 150 | + protected readonly registrationForm = form(this.registrationModel, formSchema); |
| 151 | + |
| 152 | + protected addEmail(): void { |
| 153 | + this.registrationForm.email().value.update((items) => [...items, '']); |
| 154 | + } |
| 155 | + |
| 156 | + protected removeEmail(removeIndex: number): void { |
| 157 | + this.registrationForm |
| 158 | + .email() |
| 159 | + .value.update((items) => items.filter((_, index) => index !== removeIndex)); |
| 160 | + } |
| 161 | + |
| 162 | + protected submitForm() { |
| 163 | + // validate when submitting and assign possible errors for matching field for showing in the UI |
| 164 | + submit(this.registrationForm, async (form) => { |
| 165 | + const errors: WithField<ValidationError>[] = []; |
| 166 | + |
| 167 | + try { |
| 168 | + await this.#registrationService.registerUser(form().value); |
| 169 | + setTimeout(() => this.resetForm(), 3000); |
| 170 | + } catch (e) { |
| 171 | + errors.push( |
| 172 | + { |
| 173 | + field: form, |
| 174 | + kind: 'serverError', |
| 175 | + message: 'There was an server error, please try again (should work after 3rd try)', |
| 176 | + } |
| 177 | + ); |
| 178 | + } |
| 179 | + |
| 180 | + return errors; |
| 181 | + }); |
| 182 | + |
| 183 | + // Prevent reloading (default browser behavior) |
| 184 | + return false; |
| 185 | + } |
| 186 | + |
| 187 | + // Reset form |
| 188 | + protected resetForm() { |
| 189 | + this.registrationModel.set(initialState); |
| 190 | + this.registrationForm().reset(); |
| 191 | + } |
| 192 | +} |
0 commit comments