Differenzansicht 12b-sf-registration
im Vergleich zu 15-rxjs

← Zurück zur Übersicht | Demo | Quelltext auf GitHub
package.json CHANGED
@@ -30,8 +30,12 @@
30
  "@angular/forms": "^20.2.0",
31
  "@angular/platform-browser": "^20.2.0",
32
  "@angular/router": "^20.2.0",
 
 
33
  "rxjs": "~7.8.0",
34
- "tslib": "^2.3.0"
 
 
35
  },
36
  "devDependencies": {
37
  "@angular/build": "^20.2.1",
@@ -49,4 +53,4 @@
49
  "typescript": "~5.9.2",
50
  "typescript-eslint": "8.40.0"
51
  }
52
- }
 
30
  "@angular/forms": "^20.2.0",
31
  "@angular/platform-browser": "^20.2.0",
32
  "@angular/router": "^20.2.0",
33
+ "@picocss/pico": "^2.1.1",
34
+ "@standard-schema/spec": "^1.0.0",
35
  "rxjs": "~7.8.0",
36
+ "tslib": "^2.3.0",
37
+ "valibot": "^1.1.0",
38
+ "zod": "^3.25.76"
39
  },
40
  "devDependencies": {
41
  "@angular/build": "^20.2.1",
 
53
  "typescript": "~5.9.2",
54
  "typescript-eslint": "8.40.0"
55
  }
56
+ }
src/app/app.config.ts CHANGED
@@ -1,14 +1,8 @@
1
- import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
2
- import { provideRouter, withComponentInputBinding } from '@angular/router';
3
- import { provideHttpClient, withFetch } from '@angular/common/http';
4
-
5
- import { routes } from './app.routes';
6
 
7
  export const appConfig: ApplicationConfig = {
8
- providers: [
9
- provideBrowserGlobalErrorListeners(),
10
- provideZoneChangeDetection({ eventCoalescing: true }),
11
- provideRouter(routes, withComponentInputBinding()),
12
- provideHttpClient(withFetch())
13
- ]
14
  };
 
1
+ import {
2
+ ApplicationConfig,
3
+ provideZonelessChangeDetection,
4
+ } from '@angular/core';
 
5
 
6
  export const appConfig: ApplicationConfig = {
7
+ providers: [provideZonelessChangeDetection()],
 
 
 
 
 
8
  };
src/app/app.html DELETED
@@ -1,19 +0,0 @@
1
- <main>
2
- <nav>
3
- <ul>
4
- <li>
5
- <a routerLink="/home" routerLinkActive="active"
6
- ariaCurrentWhenActive="page">Home</a>
7
- </li>
8
- <li>
9
- <a routerLink="/books" routerLinkActive="active"
10
- ariaCurrentWhenActive="page">Books</a>
11
- </li>
12
- <li>
13
- <a routerLink="/admin" routerLinkActive="active"
14
- ariaCurrentWhenActive="page">Admin</a>
15
- </li>
16
- </ul>
17
- </nav>
18
- <router-outlet />
19
- </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/app.routes.ts DELETED
@@ -1,21 +0,0 @@
1
- import { Routes } from '@angular/router';
2
-
3
- export const routes: Routes = [
4
- { path: '', redirectTo: 'home', pathMatch: 'full' },
5
- {
6
- path: 'home',
7
- title: 'BookMonkey',
8
- loadComponent: () => import('./home-page/home-page')
9
- .then(m => m.HomePage)
10
- },
11
- {
12
- path: 'books',
13
- loadChildren: () => import('./books-portal/books-portal.routes')
14
- .then(m => m.booksPortalRoutes)
15
- },
16
- {
17
- path: 'admin',
18
- loadChildren: () => import('./books-admin/books-admin.routes')
19
- .then(m => m.booksAdminRoutes)
20
- }
21
- ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/app.ts CHANGED
@@ -1,10 +1,256 @@
1
- import { Component } from '@angular/core';
2
- import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  @Component({
5
  selector: 'app-root',
6
- imports: [RouterOutlet, RouterLink, RouterLinkActive],
7
- templateUrl: './app.html',
8
- styleUrl: './app.scss'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  })
10
- export class App {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { JsonPipe } from '@angular/common';
2
+ import { Component, inject, signal } from '@angular/core';
3
+ import { Control, customError, CustomValidationError, FieldState, form, submit, ValidationError, WithField } from '@angular/forms/signals';
4
+
5
+ import { FormErrorComponent } from './form-error.component';
6
+ import { IdentityForm } from './identity-form.component';
7
+ import { InputComponent } from './input.component';
8
+ import { MultiselectComponent } from './multiselect.component';
9
+ import { RegistrationService } from './registration.service';
10
+ // import either ng, zod or valibot schema
11
+ import { formSchema, RegisterFormData } from './schema/ng';
12
+
13
+ // import { RegisterFormData, formSchema } from './schema/zod';
14
+ // import { RegisterFormData, formSchema } from './schema/valibot';
15
+
16
+ const initialState: RegisterFormData = {
17
+ username: '',
18
+ identity: {
19
+ gender: '',
20
+ salutation: '',
21
+ pronoun: '',
22
+ },
23
+ age: 18, // TODO: when input changes it will be a string again :/
24
+ birthday: new Date(), // TODO: it will be a string (format: yyyy-mm-dd) when input changes
25
+ password: { pw1: '', pw2: '' },
26
+ email: [''],
27
+ newsletter: false,
28
+ newsletterTopics: ['Angular'],
29
+ agreeToTermsAndConditions: false,
30
+ };
31
 
32
  @Component({
33
  selector: 'app-root',
34
+ imports: [
35
+ Control,
36
+ JsonPipe,
37
+ FormErrorComponent,
38
+ MultiselectComponent,
39
+ InputComponent,
40
+ IdentityForm,
41
+ ],
42
+ template: `<main>
43
+ <article>
44
+ <h1>Angular Signal Forms Demo</h1>
45
+ <p>
46
+ This application demos the usage of Angulars new signal-based forms.
47
+ All features are in state <strong>experimental</strong>.
48
+ </p>
49
+ <p>
50
+ <mark>Note:</mark> User "hanswurst" already exists. Use this user to simulate async validation error.
51
+ </p>
52
+ </article>
53
+ <form (submit)="submit($event)">
54
+
55
+ <!-- just an experiment: a generic input for the uncommented template below -->
56
+ <app-input label="Username" [control]="f.username" />
57
+ <!--
58
+ <label>Username
59
+ <input
60
+ type="text"
61
+ [control]="f.username"
62
+ [attr.aria-invalid]="ariaInvalidState(f.username())">
63
+ @if(f.username().touched() && f.username().errors().length) {
64
+ <app-form-error [errors]="f.username().errors()" />
65
+ }
66
+ </label>
67
+ -->
68
+
69
+ <identity-form [identity]="f.identity" />
70
+
71
+ <div class="group-with-gap">
72
+ <label>Age
73
+ <input
74
+ type="number"
75
+ [control]="f.age"
76
+ [attr.aria-invalid]="ariaInvalidState(f.age())">
77
+ @if(f.age().touched() && f.age().errors().length) {
78
+ <app-form-error [errors]="f.age().errors()" />
79
+ }
80
+ </label>
81
+ <label>Birthday
82
+ <input
83
+ type="date"
84
+ [control]="f.birthday">
85
+ </label>
86
+ </div>
87
+
88
+ <div class="group-with-gap">
89
+ <label>Password
90
+ <input
91
+ type="password"
92
+ autocomplete
93
+ [control]="f.password.pw1"
94
+ [attr.aria-invalid]="ariaInvalidState(f.password.pw1())">
95
+ @if(f.password.pw1().touched() && f.password.pw1().errors().length) {
96
+ <app-form-error [errors]="f.password.pw1().errors()" />
97
+ }
98
+ </label>
99
+ <label>Password Confirmation
100
+ <input
101
+ type="password"
102
+ autocomplete
103
+ [control]="f.password.pw2"
104
+ [attr.aria-invalid]="ariaInvalidState(f.password.pw2())">
105
+ @if(f.password.pw2().touched() && f.password.pw2().errors().length) {
106
+ <app-form-error [errors]="f.password.pw2().errors()" />
107
+ }
108
+ </label>
109
+ @if(f.password().touched() && f.password().errors().length) {
110
+ <app-form-error [errors]="f.password().errors()" />
111
+ }
112
+ </div>
113
+ <fieldset>
114
+ <legend>E-Mail Addresses</legend>
115
+ <div class="group-with-gap">
116
+ @for (emailField of f.email; track $index) {
117
+ <div>
118
+ <div role="group">
119
+ <input
120
+ type="email"
121
+ [control]="emailField"
122
+ [attr.aria-label]="'E-Mail ' + $index"
123
+ [attr.aria-invalid]="ariaInvalidState(emailField())">
124
+ <button type="button" (click)="removeEmail($index)">
125
+ -
126
+ </button>
127
+ </div>
128
+ @if(emailField().touched() && emailField().errors().length) {
129
+ <app-form-error [errors]="emailField().errors()" />
130
+ }
131
+ </div>
132
+ }
133
+ </div>
134
+ @if(!f.email[0] || f.email[0]().touched() && f.email().errors().length) {
135
+ <app-form-error [errors]="f.email().errors()" />
136
+ }
137
+ <button type="button" (click)="addEmail($event)">+</button>
138
+ </fieldset>
139
+ <label>Subscribe to Newsletter?
140
+ <input
141
+ type="checkbox"
142
+ [control]="f.newsletter">
143
+ </label>
144
+ <app-multiselect [control]="f.newsletterTopics" label="Topics (multiple possible):" />
145
+ @if(f.newsletterTopics().touched() && f.newsletterTopics().errors().length) {
146
+ <app-form-error [errors]="f.newsletterTopics().errors()" />
147
+ }
148
+ <label>I agree to the terms and conditions
149
+ <input
150
+ type="checkbox"
151
+ [attr.aria-invalid]="ariaInvalidState(f.agreeToTermsAndConditions())"
152
+ [control]="f.agreeToTermsAndConditions">
153
+ </label>
154
+ @if(f.agreeToTermsAndConditions().touched() && f.agreeToTermsAndConditions().errors().length) {
155
+ <app-form-error [errors]="f.agreeToTermsAndConditions().errors()" />
156
+ }
157
+ <hr />
158
+ <app-form-error [errors]="f().errors()" />
159
+ <div role="group" class="group-with-gap">
160
+ <button
161
+ type="submit"
162
+ [disabled]="f().submitting()"
163
+ [ariaBusy]="f().submitting()"
164
+ >
165
+ Register
166
+ </button>
167
+ <button
168
+ type="reset"
169
+ (click)="reset()">
170
+ Reset
171
+ </button>
172
+ </div>
173
+ </form>
174
+
175
+ <article>
176
+ <div role="group" class="group-with-gap">
177
+ <div>
178
+ <h2>Form States</h2>
179
+ <ul>
180
+ <li>Touched: {{ f().touched() }} </li>
181
+ <li>Valid: {{ f().valid() }} </li>
182
+ <li>Invalid: {{ f().invalid() }} </li>
183
+ <li>Dirty: {{ f().dirty() }} </li>
184
+ <li>Disabled: {{ f().disabled() }} </li>
185
+ <li>Pending: {{ f().pending() }}</li>
186
+ <li>Submitting: {{ f().submitting() }}</li>
187
+ </ul>
188
+ </div>
189
+ <div>
190
+ <h3>Data Model</h3>
191
+ <pre>{{ registrationModel() | json }}</pre>
192
+ </div>
193
+ </div>
194
+ </article>
195
+ </main>`,
196
  })
197
+ export class App {
198
+ private readonly registrationService = inject(RegistrationService);
199
+ protected readonly registrationModel = signal<RegisterFormData>(initialState);
200
+
201
+ protected readonly f = form(this.registrationModel, formSchema);
202
+
203
+ protected ariaInvalidState(
204
+ field: FieldState<string | boolean | number>
205
+ ): boolean | undefined {
206
+ const errors = field.errors();
207
+ if (!field.touched()) {
208
+ return undefined;
209
+ } else {
210
+ return errors.length > 0 && field.touched();
211
+ }
212
+ }
213
+
214
+ protected addEmail(e: Event): boolean {
215
+ this.f.email().value.update((items) => [...items, '']);
216
+ e.preventDefault();
217
+ return false;
218
+ }
219
+
220
+ protected removeEmail(removeIndex: number): void {
221
+ this.f
222
+ .email()
223
+ .value.update((items) =>
224
+ items.filter((_, index) => index !== removeIndex)
225
+ );
226
+ }
227
+
228
+ protected async submit(e: Event) {
229
+ e?.preventDefault();
230
+
231
+ await submit(this.f, async (form) => {
232
+ const errors: (WithField<CustomValidationError | ValidationError>)[] = [];
233
+
234
+ try {
235
+ await this.registrationService.registerUser(form().value);
236
+ } catch (e) {
237
+ errors.push(customError({
238
+ field: form,
239
+ error: {
240
+ kind: 'serverError',
241
+ message:
242
+ 'There was an server error, please try again (should work after 3rd try)',
243
+ },
244
+ }));
245
+ }
246
+
247
+ setTimeout(() => this.reset(), 3000);
248
+ return errors;
249
+ });
250
+ }
251
+
252
+ protected reset() {
253
+ this.registrationModel.set(initialState);
254
+ this.f().reset();
255
+ }
256
+ }
src/app/books-admin/book-create-page/book-create-page.html DELETED
@@ -1,2 +0,0 @@
1
- <h1>Create book</h1>
2
- <app-book-form (submitBook)="createBook($event)" />
 
 
 
src/app/books-admin/book-create-page/book-create-page.ts DELETED
@@ -1,23 +0,0 @@
1
- import { Component, inject } from '@angular/core';
2
- import { Router } from '@angular/router';
3
-
4
- import { BookForm } from '../book-form/book-form';
5
- import { BookStore } from '../../shared/book-store';
6
- import { Book } from '../../shared/book';
7
-
8
- @Component({
9
- selector: 'app-book-create-page',
10
- imports: [BookForm],
11
- templateUrl: './book-create-page.html',
12
- styleUrl: './book-create-page.scss'
13
- })
14
- export class BookCreatePage {
15
- #bookStore = inject(BookStore);
16
- #router = inject(Router);
17
-
18
- createBook(book: Book) {
19
- this.#bookStore.create(book).subscribe(createdBook => {
20
- this.#router.navigate(['/books', 'details', createdBook.isbn]);
21
- });
22
- }
23
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/books-admin/book-form/book-form.html DELETED
@@ -1,54 +0,0 @@
1
- @let c = bookForm.controls;
2
-
3
- <form [formGroup]="bookForm" (ngSubmit)="submitForm()">
4
- @let titleInvalid = isInvalid(c.title);
5
- <label for="title">Title</label>
6
- <input
7
- type="text"
8
- id="title"
9
- [formControl]="c.title"
10
- [attr.aria-describedby]="titleInvalid ? 'title-error' : null"
11
- [attr.aria-invalid]="titleInvalid"
12
- />
13
- @if (titleInvalid) {
14
- <small id="title-error">The title is invalid.</small>
15
- }
16
-
17
- <label for="subtitle">Subtitle</label>
18
- <input type="text" id="subtitle" [formControl]="c.subtitle" />
19
-
20
- @let isbnInvalid = isInvalid(c.isbn);
21
- <label for="isbn">ISBN</label>
22
- <input
23
- type="text"
24
- id="isbn"
25
- [formControl]="c.isbn"
26
- [attr.aria-describedby]="isbnInvalid ? 'isbn-error' : null"
27
- [attr.aria-invalid]="isbnInvalid"
28
- />
29
- @if (isbnInvalid) {
30
- <small id="isbn-error">The ISBN is invalid.</small>
31
- }
32
-
33
- <fieldset>
34
- <legend>Authors</legend>
35
- <button type="button" (click)="addAuthorControl()">Add Author</button>
36
- <div role="group">
37
- @for (authorControl of c.authors.controls; track $index) {
38
- <input
39
- type="text"
40
- [attr.aria-label]="'Author ' + $index + 1"
41
- [formControl]="authorControl"
42
- />
43
- }
44
- </div>
45
- </fieldset>
46
-
47
- <label for="description">Description</label>
48
- <textarea id="description" [formControl]="c.description"></textarea>
49
-
50
- <label for="imageUrl">Thumbnail URL</label>
51
- <input type="url" id="imageUrl" [formControl]="c.imageUrl" />
52
-
53
- <button type="submit" [disabled]="bookForm.invalid">Save</button>
54
- </form>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/books-admin/book-form/book-form.ts DELETED
@@ -1,61 +0,0 @@
1
- import { Component, output } from '@angular/core';
2
- import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
3
-
4
- import { Book } from '../../shared/book';
5
-
6
- @Component({
7
- selector: 'app-book-form',
8
- imports: [ReactiveFormsModule],
9
- templateUrl: './book-form.html',
10
- styleUrl: './book-form.scss'
11
- })
12
- export class BookForm {
13
- readonly submitBook = output<Book>();
14
-
15
- protected bookForm = new FormGroup({
16
- isbn: new FormControl('', {
17
- nonNullable: true,
18
- validators: [
19
- Validators.required,
20
- Validators.minLength(13),
21
- Validators.maxLength(13),
22
- ]
23
- }),
24
- title: new FormControl('', {
25
- nonNullable: true,
26
- validators: Validators.required,
27
- }),
28
- subtitle: new FormControl('', { nonNullable: true }),
29
- description: new FormControl('', { nonNullable: true }),
30
- authors: new FormArray([
31
- new FormControl('', { nonNullable: true })
32
- ]),
33
- imageUrl: new FormControl('', { nonNullable: true })
34
- });
35
-
36
- addAuthorControl() {
37
- this.bookForm.controls.authors.push(
38
- new FormControl('', { nonNullable: true })
39
- );
40
- }
41
-
42
- isInvalid(control: FormControl) {
43
- if (!control.touched) {
44
- return null;
45
- }
46
- return control.invalid && control.touched;
47
- }
48
-
49
- submitForm() {
50
- const formValue = this.bookForm.getRawValue();
51
- const authors = formValue.authors.filter(author => !!author);
52
-
53
- const newBook: Book = {
54
- ...formValue,
55
- authors,
56
- createdAt: new Date().toISOString()
57
- };
58
-
59
- this.submitBook.emit(newBook);
60
- }
61
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/books-admin/books-admin.routes.ts DELETED
@@ -1,8 +0,0 @@
1
- import { Routes } from '@angular/router';
2
-
3
- import { BookCreatePage } from './book-create-page/book-create-page';
4
-
5
- export const booksAdminRoutes: Routes = [
6
- { path: '', redirectTo: 'create', pathMatch: 'full' },
7
- { path: 'create', component: BookCreatePage, title: 'Create Book' }
8
- ];
 
 
 
 
 
 
 
 
 
src/app/books-portal/book-card/book-card.html DELETED
@@ -1,18 +0,0 @@
1
- @let b = book();
2
-
3
- <article class="book-card">
4
- <img [src]="b.imageUrl" alt="Cover" />
5
- <header>
6
- <h2>{{ b.title }}</h2>
7
- </header>
8
- <div>
9
- @if (b.subtitle) {
10
- <p role="doc-subtitle">{{ b.subtitle }}</p>
11
- }
12
- ISBN: {{ b.isbn | isbnFormat }}
13
- </div>
14
- <footer>
15
- <a [routerLink]="['details', b.isbn]">Details</a>
16
- <button type="button" class="secondary" (click)="likeBook()">Like</button>
17
- </footer>
18
- </article>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/books-portal/book-card/book-card.ts DELETED
@@ -1,20 +0,0 @@
1
- import { Component, input, output } from '@angular/core';
2
- import { RouterLink } from '@angular/router';
3
-
4
- import { Book } from '../../shared/book';
5
- import { IsbnFormatPipe } from '../../shared/isbn-format.pipe';
6
-
7
- @Component({
8
- selector: 'app-book-card',
9
- imports: [RouterLink, IsbnFormatPipe],
10
- templateUrl: './book-card.html',
11
- styleUrl: './book-card.scss'
12
- })
13
- export class BookCard {
14
- readonly book = input.required<Book>();
15
- readonly like = output<Book>();
16
-
17
- likeBook() {
18
- this.like.emit(this.book());
19
- }
20
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/books-portal/book-details-page/book-details-page.html DELETED
@@ -1,47 +0,0 @@
1
- @if (book.error()) {
2
- <p role="alert">Book could not be found.</p>
3
- <a routerLink="/books">Back to list</a>
4
- }
5
-
6
- @if (book.isLoading()) {
7
- <p aria-busy="true">Loading ...</p>
8
- }
9
-
10
- @if (book.hasValue()) {
11
- @let b = book.value();
12
- <article>
13
- <header>
14
- <h1>{{ b.title }}</h1>
15
- @if (b.subtitle) {
16
- <p role="doc-subtitle">{{ b.subtitle }}</p>
17
- }
18
- <div class="grid">
19
- <div>
20
- <h2>Authors</h2>
21
- <ul>
22
- @for (author of b.authors; track $index) {
23
- <li>{{ author }}</li>
24
- }
25
- </ul>
26
- </div>
27
- <div>
28
- <h2>ISBN</h2>
29
- {{ b.isbn | isbnFormat }}
30
- </div>
31
- <div>
32
- <h2>Created at</h2>
33
- {{ b.createdAt | date:'medium' }}
34
- </div>
35
- </div>
36
- </header>
37
- <p>{{ b.description }}</p>
38
- <img [src]="b.imageUrl" alt="Cover" />
39
- <footer>
40
- <a routerLink="/books">Back to list</a>
41
- <a routerLink="/books/details/9783864909467">Angular Book</a>
42
- <button type="button" (click)="removeBook(b.isbn)">
43
- Delete book
44
- </button>
45
- </footer>
46
- </article>
47
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/books-portal/book-details-page/book-details-page.ts DELETED
@@ -1,28 +0,0 @@
1
- import { Component, inject, input } from '@angular/core';
2
- import { DatePipe } from '@angular/common';
3
- import { Router, RouterLink } from '@angular/router';
4
-
5
- import { BookStore } from '../../shared/book-store';
6
- import { IsbnFormatPipe } from '../../shared/isbn-format.pipe';
7
-
8
- @Component({
9
- selector: 'app-book-details-page',
10
- imports: [RouterLink, DatePipe, IsbnFormatPipe],
11
- templateUrl: './book-details-page.html',
12
- styleUrl: './book-details-page.scss'
13
- })
14
- export class BookDetailsPage {
15
- #bookStore = inject(BookStore);
16
- #router = inject(Router);
17
-
18
- readonly isbn = input.required<string>();
19
- protected book = this.#bookStore.getSingle(this.isbn);
20
-
21
- removeBook(isbn: string) {
22
- if (window.confirm('Delete book?')) {
23
- this.#bookStore.remove(isbn).subscribe(() => {
24
- this.#router.navigateByUrl('/books');
25
- });
26
- }
27
- }
28
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/books-portal/books-overview-page/books-overview-page.html DELETED
@@ -1,33 +0,0 @@
1
- <section>
2
- <h1>Favorite Books</h1>
3
- <button type="button" (click)="clearLikedBooks()">Clear</button>
4
- <ul>
5
- @for (b of likedBooks(); track b.isbn) {
6
- <li>{{ b.title }} ({{ b.isbn }})</li>
7
- } @empty {
8
- <li>No books liked.</li>
9
- }
10
- </ul>
11
- </section>
12
-
13
- <section>
14
- <h1>Books</h1>
15
- <button type="button" (click)="books.reload()" [attr.aria-busy]="books.isLoading()">
16
- Reload
17
- </button>
18
-
19
- <div>
20
- <input
21
- type="search"
22
- #searchInput
23
- (input)="this.searchTerm.set(searchInput.value)"
24
- [value]="this.searchTerm()"
25
- placeholder="Search"
26
- aria-label="Search"
27
- />
28
-
29
- @for (b of books.value(); track b.isbn) {
30
- <app-book-card [book]="b" (like)="addLikedBook($event)" />
31
- }
32
- </div>
33
- </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/books-portal/books-overview-page/books-overview-page.ts DELETED
@@ -1,47 +0,0 @@
1
- import { Component, effect, inject, input, linkedSignal, signal } from '@angular/core';
2
- import { Router } from '@angular/router';
3
-
4
- import { Book } from '../../shared/book';
5
- import { BookCard } from '../book-card/book-card';
6
- import { BookStore } from '../../shared/book-store';
7
-
8
- @Component({
9
- selector: 'app-books-overview-page',
10
- imports: [BookCard],
11
- templateUrl: './books-overview-page.html',
12
- styleUrl: './books-overview-page.scss',
13
- })
14
- export class BooksOverviewPage {
15
- #bookStore = inject(BookStore);
16
- #router = inject(Router);
17
-
18
- readonly search = input<string>();
19
- protected searchTerm = linkedSignal(() => this.search() || '');
20
-
21
- protected books = this.#bookStore.getAll(this.searchTerm);
22
- protected likedBooks = signal<Book[]>([]);
23
-
24
- constructor() {
25
- effect(() => {
26
- this.#router.navigate([], {
27
- queryParams: {
28
- search: this.searchTerm() || null
29
- }
30
- });
31
- });
32
- }
33
-
34
- addLikedBook(newLikedBook: Book) {
35
- const foundBook = this.likedBooks().find(
36
- (b) => b.isbn === newLikedBook.isbn
37
- );
38
-
39
- if (!foundBook) {
40
- this.likedBooks.update((likedBooks) => [...likedBooks, newLikedBook]);
41
- }
42
- }
43
-
44
- clearLikedBooks() {
45
- this.likedBooks.set([]);
46
- }
47
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/books-portal/books-portal.routes.ts DELETED
@@ -1,9 +0,0 @@
1
- import { Routes } from '@angular/router';
2
-
3
- import { BooksOverviewPage } from './books-overview-page/books-overview-page';
4
- import { BookDetailsPage } from './book-details-page/book-details-page';
5
-
6
- export const booksPortalRoutes: Routes = [
7
- { path: '', component: BooksOverviewPage, title: 'Books' },
8
- { path: 'details/:isbn', component: BookDetailsPage, title: 'Book Details' },
9
- ];
 
 
 
 
 
 
 
 
 
 
src/app/form-error.component.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Component, input } from '@angular/core';
2
+
3
+ import { ValidationError } from '@angular/forms/signals';
4
+
5
+ @Component({
6
+ selector: 'app-form-error',
7
+ template: `
8
+ <small>
9
+ @for(error of errors(); track $index) {
10
+ {{ error.message}}
11
+ @if(!$last) {
12
+ <br/>
13
+ }
14
+ }
15
+ </small>
16
+ `,
17
+ styles: `
18
+ :host {
19
+ display: block;
20
+ margin-top: -0.75rem;
21
+ margin-bottom: 1rem;
22
+ color: #bb2233;
23
+ }
24
+ `,
25
+ })
26
+ export class FormErrorComponent {
27
+ errors = input<readonly ValidationError[]>([]);
28
+ }
src/app/home-page/home-page.html DELETED
@@ -1,25 +0,0 @@
1
- <h1>Welcome to the BookMonkey!</h1>
2
-
3
- <hr />
4
-
5
- <input type="search" [formControl]="searchControl" placeholder="Search" />
6
-
7
- @if (isLoading()) {
8
- <span aria-busy="true">Searching ...</span>
9
- }
10
-
11
- @if (results().length) {
12
- <section>
13
- <h2>Search results</h2>
14
- <ul>
15
- @for (b of results(); track b.isbn) {
16
- <li>
17
- <a [routerLink]="['/books', 'details', b.isbn]">
18
- {{ b.title }} ({{ b.isbn }})
19
- </a>
20
- </li>
21
- }
22
- </ul>
23
- </section>
24
- }
25
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/home-page/home-page.ts DELETED
@@ -1,32 +0,0 @@
1
- import { Component, inject, signal } from '@angular/core';
2
- import { RouterLink } from '@angular/router';
3
- import { FormControl, ReactiveFormsModule } from '@angular/forms';
4
- import { toSignal } from '@angular/core/rxjs-interop';
5
- import { filter, debounceTime, distinctUntilChanged, switchMap, tap } from 'rxjs';
6
-
7
- import { BookStore } from '../shared/book-store';
8
-
9
- @Component({
10
- selector: 'app-home-page',
11
- imports: [ReactiveFormsModule, RouterLink],
12
- templateUrl: './home-page.html',
13
- styleUrl: './home-page.scss'
14
- })
15
- export class HomePage {
16
- #bookStore = inject(BookStore);
17
-
18
- protected searchControl = new FormControl('', { nonNullable: true });
19
- protected isLoading = signal(false);
20
-
21
- protected results = toSignal(
22
- this.searchControl.valueChanges.pipe(
23
- filter(term => term.length >= 3),
24
- debounceTime(500),
25
- distinctUntilChanged(),
26
- tap(() => this.isLoading.set(true)),
27
- switchMap(term => this.#bookStore.search(term)),
28
- tap(() => this.isLoading.set(false)),
29
- ),
30
- { initialValue: [] }
31
- );
32
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/identity-form.component.ts ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Component, input } from '@angular/core';
2
+
3
+ import { schema, Field, Control, hidden, disabled } from '@angular/forms/signals';
4
+
5
+ @Component({
6
+ selector: 'identity-form',
7
+ imports: [Control],
8
+ template: `
9
+ <label>Gender
10
+ <!-- TODO: [control] seems not to work with <select> right now? -->
11
+ <select name="gender-identity" [control]="identity().gender">
12
+ <option value="" selected>Please select</option>
13
+ <option value="male">Male</option>
14
+ <option value="female">Female</option>
15
+ <option value="diverse">Diverse</option>
16
+ </select>
17
+ </label>
18
+
19
+ <div class="group-with-gap">
20
+ @if (!identity().salutation().disabled()) {
21
+ <label>Salutation
22
+ <input
23
+ type="text"
24
+ placeholder="e. g. Mx."
25
+ [control]="identity().salutation">
26
+ </label>
27
+ }
28
+ @if (!identity().pronoun().disabled()) {
29
+ <label>Pronoun
30
+ <input
31
+ type="text"
32
+ placeholder="e. g. they/them"
33
+ [control]="identity().pronoun">
34
+ </label>
35
+ }
36
+ </div>
37
+
38
+ `,
39
+ })
40
+ export class IdentityForm {
41
+ readonly identity = input.required<Field<GenderIdentity>>();
42
+ }
43
+
44
+ export interface GenderIdentity {
45
+ gender: '' | 'male' | 'female' | 'diverse';
46
+ salutation: string; // e. g. "Mx.", "Dr.", etc.
47
+ pronoun: string; // e. g. "they/them"
48
+ }
49
+
50
+ export const identitySchema = schema<GenderIdentity>((path) => {
51
+ // TODO: acutally switch to "hidden" once available
52
+ disabled(path.salutation, ({ valueOf }) => {
53
+ return !valueOf(path.gender) || valueOf(path.gender) !== 'diverse';
54
+ });
55
+ // TODO: acutally switch to "hidden" once available
56
+ disabled(path.pronoun, ({ valueOf }) => {
57
+ return !valueOf(path.gender) || valueOf(path.gender) !== 'diverse';
58
+ });
59
+ });
src/app/input.component.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Component, computed, input, model } from '@angular/core';
2
+ import { FormValueControl, ValidationError } from '@angular/forms/signals';
3
+
4
+ import { FormErrorComponent } from './form-error.component';
5
+
6
+ @Component({
7
+ selector: 'app-input',
8
+ template: `
9
+ <label>{{ label() }}
10
+ <input
11
+ type="text"
12
+ [value]="value()"
13
+ [disabled]="disabled()"
14
+ [readonly]="readonly()"
15
+ [attr.aria-invalid]="ariaInvalidState()"
16
+ (input)="changeInput($event)">
17
+ @if(touched() && errors()) {
18
+ <app-form-error [errors]="errorsList()" />
19
+ }
20
+ </label>
21
+ `,
22
+ imports: [FormErrorComponent],
23
+ })
24
+ export class InputComponent implements FormValueControl<string> {
25
+ readonly value = model<string>("");
26
+ readonly label = input.required<string>();
27
+ readonly errors = input<readonly ValidationError[]>([]);
28
+ readonly valid = input<boolean>();
29
+ readonly disabled = input<boolean>(false);
30
+ readonly touched = input<boolean>(false);
31
+ readonly readonly = input<boolean>(false);
32
+
33
+ protected readonly errorsList = computed(() => {
34
+ return this.errors() || [];
35
+ });
36
+
37
+ protected ariaInvalidState(): boolean | undefined {
38
+ if (!this.touched()) {
39
+ return undefined;
40
+ } else {
41
+ return this.errorsList().length > 0 && this.touched();
42
+ }
43
+ }
44
+
45
+ changeInput(e: Event) {
46
+ const value = (e.target as HTMLInputElement).value;
47
+ this.value.set(value);
48
+ }
49
+ }
src/app/multiselect.component.ts ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Component, effect, input, model } from '@angular/core';
2
+ import { FormValueControl, ValidationError } from '@angular/forms/signals';
3
+
4
+ @Component({
5
+ selector: 'app-multiselect',
6
+ template: `
7
+ <details class="dropdown" [ariaDisabled]="disabled()">
8
+ <summary>
9
+ {{ label() }}
10
+ </summary>
11
+ @if(!(disabled())) {
12
+ <ul>
13
+ @for(topic of allTopics; track $index) {
14
+ <li>
15
+ <label>
16
+ <input
17
+ type="checkbox"
18
+ [name]="topic"
19
+ [checked]="value().includes(topic)"
20
+ (input)="changeInput(topic, $event)" />
21
+ {{ topic }}
22
+ </label>
23
+ </li>
24
+ }
25
+ </ul>
26
+ }
27
+ </details>
28
+ `,
29
+ styles: `
30
+ [aria-disabled=true] {
31
+ cursor: not-allowed;
32
+ opacity: 0.5;
33
+ pointer-events: none;
34
+ }
35
+ `,
36
+ })
37
+ export class MultiselectComponent implements FormValueControl<string[]> {
38
+ readonly allTopics = ['Angular', 'Vue', 'React'];
39
+ readonly value = model<string[]>([]);
40
+ readonly label = input.required<string>();
41
+ readonly errors = input<readonly ValidationError[]>([]);
42
+ readonly disabled = input<boolean>(false);
43
+
44
+ changeInput(topic: string, e: Event) {
45
+ const checked = (e.target as HTMLInputElement).checked;
46
+ const isInModel = this.value().includes(topic) && checked;
47
+ if (!isInModel && checked) {
48
+ this.value.update((current) => [...current, topic]);
49
+ return;
50
+ }
51
+ if (!checked) {
52
+ this.value.update((current) => current.filter((t) => t !== topic));
53
+ }
54
+ }
55
+
56
+ constructor() {
57
+ effect(() => {
58
+ if (this.disabled()) {
59
+ this.value.set([]);
60
+ }
61
+ });
62
+ }
63
+ }
src/app/registration.service.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Injectable } from '@angular/core';
2
+
3
+ @Injectable({
4
+ providedIn: 'root',
5
+ })
6
+ export class RegistrationService {
7
+ private submitErrorCnt = 0;
8
+
9
+ registerUser(registrationData: Record<string, any>) {
10
+ this.submitErrorCnt++;
11
+ return new Promise((resolve, reject) => {
12
+ window.setTimeout(() => {
13
+ if (this.submitErrorCnt < 3) {
14
+ reject();
15
+ } else {
16
+ resolve(registrationData);
17
+ }
18
+ }, 2000);
19
+ });
20
+ }
21
+
22
+ checkUserExists(username: string) {
23
+ return new Promise<boolean>((resolve) => {
24
+ setTimeout(() => {
25
+ resolve(username === 'hanswurst');
26
+ }, 500);
27
+ });
28
+ }
29
+ }
src/app/schema/ng.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { apply, pattern, schema } from '@angular/forms/signals';
2
+ import { applyEach, applyWhen, customError, disabled, email, maxLength, min, minLength, required, validate, validateAsync, validateTree } from '@angular/forms/signals';
3
+
4
+ import { GenderIdentity, identitySchema } from '../identity-form.component';
5
+ import { confirmationPasswordValidator, vaidateUsername as validateUsername } from '../validators';
6
+
7
+ export interface RegisterFormData {
8
+ username: string;
9
+ identity: GenderIdentity;
10
+ age: number;
11
+ birthday: Date;
12
+ password: { pw1: string; pw2: string };
13
+ email: string[];
14
+ newsletter: boolean;
15
+ newsletterTopics: string[];
16
+ agreeToTermsAndConditions: boolean;
17
+ }
18
+
19
+ export const formSchema = schema<RegisterFormData>((fieldPath) => {
20
+ // username is required and must be between 3 and 12 characters long
21
+ required(fieldPath.username, { message: 'Username is required'});
22
+ minLength(fieldPath.username, 3, { message: 'A username must be at least 3 characters long'});
23
+ maxLength(fieldPath.username, 12, { message: 'A username can be max. 12 characters long'});
24
+ validateAsync(fieldPath.username, validateUsername);
25
+
26
+ apply(fieldPath.identity, identitySchema);
27
+
28
+ min(fieldPath.age, 18, { message: 'You must be >=18 years old'});
29
+
30
+ // at least one email and each email must match format
31
+ validate(fieldPath.email, ({ value }) =>
32
+ !value().some((e) => e)
33
+ ? customError({
34
+ kind: 'atLeastOneEmail',
35
+ message: 'At least one E-Mail address must be added',
36
+ })
37
+ : undefined
38
+ );
39
+ applyEach(fieldPath.email, (emailPath) => {
40
+ email(emailPath, { message: 'E-Mail format is invalid'});
41
+ });
42
+
43
+ // passwords are required and must match
44
+ required(fieldPath.password.pw1, { message: 'A password is required' });
45
+ required(fieldPath.password.pw2, { message: 'A password confirmation is required'});
46
+ minLength(fieldPath.password.pw1, 8, { message: 'A password must be at least 8 characters long'});
47
+ pattern(
48
+ fieldPath.password.pw1,
49
+ new RegExp('^.*[!@#$%^&*(),.?":{}|<>\\[\\]\\\\/~`_+=;\'\\-].*$'),
50
+ { message: 'The passwort must contain at least one special character'}
51
+ );
52
+ validateTree(
53
+ fieldPath.password,
54
+ confirmationPasswordValidator(fieldPath.password)
55
+ );
56
+
57
+ // error(
58
+ // fieldPath.agreeToTermsAndConditions,
59
+ // ({ value }) => !value(),
60
+ // 'You must agree to the terms and conditions'
61
+ // );
62
+
63
+ applyWhen(
64
+ fieldPath,
65
+ ({ value }) => value().newsletter,
66
+ (fieldPathWhenTrue) => {
67
+ validate(fieldPathWhenTrue.newsletterTopics, ({ value }) =>
68
+ !value().length
69
+ ? customError({
70
+ kind: 'noTopicSelected',
71
+ message: 'Select at least one newsletter topic',
72
+ })
73
+ : undefined
74
+ );
75
+ }
76
+ );
77
+
78
+ disabled(
79
+ fieldPath.newsletterTopics,
80
+ ({ valueOf }) => !valueOf(fieldPath.newsletter)
81
+ );
82
+ });
src/app/schema/valibot.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ object,
3
+ string,
4
+ minLength,
5
+ minValue,
6
+ number,
7
+ maxLength,
8
+ email,
9
+ array,
10
+ regex,
11
+ boolean,
12
+ pipe,
13
+ forward,
14
+ partialCheck,
15
+ InferInput,
16
+ date,
17
+ } from 'valibot';
18
+ import { schema } from '@angular/forms/signals';
19
+ import { validateStandardSchema } from '@angular/forms/standard_schema';
20
+
21
+ export const valibotSchema = pipe(
22
+ object({
23
+ username: pipe(
24
+ string('Username is required'),
25
+ minLength(3, 'A username must be at least 3 characters long'),
26
+ maxLength(12, 'A username can be max. 12 characters long')
27
+ ),
28
+ age: pipe(number(), minValue(18, 'You must be >=18 years old')),
29
+ birthday: date(),
30
+ email: pipe(
31
+ array(pipe(string(), email('E-Mail format is invalid'))),
32
+ minLength(1, 'At least one E-Mail address must be added')
33
+ ),
34
+ password: pipe(
35
+ object({
36
+ pw1: pipe(
37
+ string('A password is required'),
38
+ minLength(8, 'A password must be at least 8 characters long'),
39
+ regex(
40
+ /^.*[!@#$%^&*(),.?":{}|<>\[\]\\/~`_+=;'\-].*$/,
41
+ 'The password must contain at least one special character'
42
+ )
43
+ ),
44
+ pw2: string('A password confirmation is required'),
45
+ }),
46
+ forward(
47
+ partialCheck(
48
+ [['pw1'], ['pw2']],
49
+ (input) => input.pw1 === input.pw2,
50
+ 'Passwords do not match'
51
+ ),
52
+ ['pw2']
53
+ )
54
+ ),
55
+ newsletter: boolean(),
56
+ newsletterTopics: array(string()),
57
+ agreeToTermsAndConditions: boolean(
58
+ 'You must agree to the terms and conditions'
59
+ ),
60
+ }),
61
+ forward(
62
+ partialCheck(
63
+ [['newsletter'], ['newsletterTopics']],
64
+ (data) => {
65
+ return data.newsletter && data.newsletterTopics.length !== 0;
66
+ },
67
+ 'Select at least one newsletter topic'
68
+ ),
69
+ ['newsletterTopics']
70
+ )
71
+ );
72
+
73
+ export type RegisterFormData = InferInput<typeof valibotSchema>;
74
+
75
+ export const formSchema = schema<RegisterFormData>((fieldPath) => {
76
+ validateStandardSchema(fieldPath, valibotSchema);
77
+ });
src/app/schema/zod.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as z from 'zod';
2
+ import { schema } from '@angular/forms/signals';
3
+ import { validateStandardSchema } from '@angular/forms/standard_schema';
4
+
5
+ export interface RegisterFormData {
6
+ username: string;
7
+ age: number;
8
+ birthday: Date,
9
+ password: { pw1: string; pw2: string };
10
+ email: string[];
11
+ newsletter: boolean;
12
+ newsletterTopics: string[];
13
+ agreeToTermsAndConditions: boolean;
14
+ }
15
+
16
+ const zodSchema = z
17
+ .object({
18
+ username: z
19
+ .string({ message: 'Username is required' })
20
+
21
+ .min(3, {
22
+ message: 'A username must be at least 3 characters long',
23
+ })
24
+ .max(12, { message: 'A username can be max. 12 characters long' }),
25
+ age: z.number().min(18, {
26
+ message: 'You must be >=18 years old',
27
+ }),
28
+ birthday: z.date(),
29
+ email: z
30
+ .array(z.string().email({ message: 'E-Mail format is invalid' }))
31
+ .refine((emails) => emails.length > 0, {
32
+ message: 'At least one E-Mail address must be added',
33
+ }),
34
+ password: z
35
+ .object({
36
+ pw1: z
37
+ .string({ message: 'A password is required' })
38
+ .min(8, {
39
+ message: 'A password must be at least 8 characters long',
40
+ })
41
+ .regex(/^.*[!@#$%^&*(),.?":{}|<>\[\]\\/~`_+=;'\-].*$/, {
42
+ message: 'The password must contain at least one special character',
43
+ }),
44
+ pw2: z.string({
45
+ message: 'A password confirmation is required',
46
+ }),
47
+ })
48
+ .refine((data: { pw1: string; pw2: string }) => data.pw1 === data.pw2, {
49
+ message: 'Passwords do not match',
50
+ path: ['pw2'],
51
+ }),
52
+ newsletter: z.boolean(),
53
+ newsletterTopics: z.array(z.string()),
54
+ agreeToTermsAndConditions: z.boolean().refine((val: boolean) => val, {
55
+ message: 'You must agree to the terms and conditions',
56
+ }),
57
+ })
58
+ .superRefine((data: RegisterFormData, ctx: any) => {
59
+ if (data.newsletter && data.newsletterTopics.length === 0) {
60
+ ctx.addIssue({
61
+ path: ['newsletterTopics'],
62
+ code: 'noTopicSelected',
63
+ message: 'Select at least one newsletter topic',
64
+ });
65
+ }
66
+ });
67
+
68
+ export const formSchema = schema<RegisterFormData>((fieldPath) => {
69
+ validateStandardSchema(fieldPath, zodSchema);
70
+ });
src/app/shared/book-store.ts DELETED
@@ -1,44 +0,0 @@
1
- import { inject, Injectable, Signal } from '@angular/core';
2
- import { HttpClient, httpResource, HttpResourceRef } from '@angular/common/http';
3
- import { Observable } from 'rxjs';
4
-
5
- import { Book } from './book';
6
-
7
- @Injectable({
8
- providedIn: 'root'
9
- })
10
- export class BookStore {
11
- #http = inject(HttpClient);
12
- #apiUrl = 'https://api6.angular-buch.com';
13
-
14
- getAll(searchTerm: Signal<string>): HttpResourceRef<Book[]> {
15
- return httpResource<Book[]>(
16
- () => ({
17
- url: `${this.#apiUrl}/books`,
18
- params: { search: searchTerm() }
19
- }),
20
- { defaultValue: [] }
21
- );
22
- }
23
-
24
- getSingle(isbn: Signal<string>): HttpResourceRef<Book | undefined> {
25
- return httpResource<Book>(
26
- () => `${this.#apiUrl}/books/${isbn()}`
27
- );
28
- }
29
-
30
- remove(isbn: string): Observable<void> {
31
- return this.#http.delete<void>(`${this.#apiUrl}/books/${isbn}`);
32
- }
33
-
34
- create(book: Book): Observable<Book> {
35
- return this.#http.post<Book>(`${this.#apiUrl}/books`, book);
36
- }
37
-
38
- search(searchTerm: string): Observable<Book[]> {
39
- return this.#http.get<Book[]>(
40
- `${this.#apiUrl}/books`,
41
- { params: { search: searchTerm } }
42
- );
43
- }
44
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/shared/book.ts DELETED
@@ -1,9 +0,0 @@
1
- export interface Book {
2
- isbn: string;
3
- title: string;
4
- subtitle?: string;
5
- authors: string[];
6
- description: string;
7
- imageUrl: string;
8
- createdAt: string;
9
- }
 
 
 
 
 
 
 
 
 
 
src/app/shared/isbn-format.pipe.ts DELETED
@@ -1,23 +0,0 @@
1
- import { Pipe, PipeTransform } from '@angular/core';
2
-
3
- @Pipe({
4
- name: 'isbnFormat'
5
- })
6
- export class IsbnFormatPipe implements PipeTransform {
7
- transform(value: string): string {
8
- if (value.length !== 13) {
9
- return value;
10
- }
11
-
12
- // Beispiel: 978-1-2345-6789-0
13
- const parts = [
14
- value.slice(0, 3), // Präfix (978 oder 979)
15
- value.slice(3, 4), // Gruppe (Sprache/Region)
16
- value.slice(4, 8), // Herausgeber
17
- value.slice(8, 12), // Titel
18
- value.slice(12) // Prüfziffer
19
- ];
20
-
21
- return parts.join('-');
22
- }
23
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/validators.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { inject, resource, Signal } from '@angular/core';
2
+ import { AsyncValidatorOptions, customError, FieldPath, TreeValidator, ValidationError } from '@angular/forms/signals';
3
+
4
+ import { RegistrationService } from './registration.service';
5
+
6
+ export function confirmationPasswordValidator(
7
+ path: FieldPath<{ pw1: string; pw2: string }>
8
+ ): TreeValidator<{ pw1: string; pw2: string }> {
9
+ return ({ valueOf, fieldOf }) => {
10
+ return valueOf(path.pw2) === valueOf(path.pw1)
11
+ ? []
12
+ : [
13
+ customError({
14
+ field: fieldOf(path.pw2),
15
+ kind: 'confirmationPassword',
16
+ message:
17
+ 'The entered password must match with the one specified in "Password" field',
18
+ }),
19
+ ];
20
+ };
21
+ }
22
+
23
+ export const vaidateUsername: AsyncValidatorOptions<string, string, boolean> = {
24
+ // Reactive params
25
+ params: ({ value }) => value(),
26
+ // Factory creating a resource
27
+ factory: (params: Signal<string | undefined>) => {
28
+ const registrationService = inject(RegistrationService);
29
+ return resource({
30
+ params,
31
+ loader: async ({ params }) => {
32
+ return await registrationService.checkUserExists(params);
33
+ },
34
+ });
35
+ },
36
+ // Maps resource to error
37
+ errors: (result) => {
38
+ return result
39
+ ? [
40
+ customError({
41
+ kind: 'userExists',
42
+ message: 'The username you entered was already taken',
43
+ }),
44
+ ]
45
+ : undefined;
46
+ },
47
+ };
tsconfig.json CHANGED
@@ -3,6 +3,11 @@
3
  {
4
  "compileOnSave": false,
5
  "compilerOptions": {
 
 
 
 
 
6
  "strict": true,
7
  "noImplicitOverride": true,
8
  "noPropertyAccessFromIndexSignature": true,
 
3
  {
4
  "compileOnSave": false,
5
  "compilerOptions": {
6
+ "baseUrl": ".",
7
+ "paths": {
8
+ "@angular/forms/signals": ["./experimental"],
9
+ "@angular/forms/standard_schema": ["./experimental/src/api/validators/standard_schema"]
10
+ },
11
  "strict": true,
12
  "noImplicitOverride": true,
13
  "noPropertyAccessFromIndexSignature": true,