Differenzansicht 12-validation
im Vergleich zu 11-forms

Zurück zur Übersicht | ← Vorherige | Nächste → | Demo | Quelltext auf GitHub
src/app/books-admin/book-create-page/book-create-page.html CHANGED
@@ -1,34 +1,99 @@
1
  <h1>Create book</h1>
2
 
3
- <form (submit)="submitForm()">
 
4
  <label for="title">Title</label>
5
- <input type="text" id="title" [formField]="bookForm.title" />
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  <label for="subtitle">Subtitle</label>
8
  <input type="text" id="subtitle" [formField]="bookForm.subtitle" />
9
 
 
10
  <label for="isbn">ISBN</label>
11
- <input type="text" id="isbn" [formField]="bookForm.isbn" />
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  <fieldset>
14
  <legend>Authors</legend>
15
  <button type="button" (click)="addAuthorField()">Add Author</button>
 
16
  <div role="group">
17
  @for (authorField of bookForm.authors; track $index) {
18
  <input
19
  type="text"
20
  [aria-label]="`Author ${$index + 1}`"
21
  [formField]="authorField"
 
 
22
  />
23
  }
 
 
 
 
 
 
 
24
  </div>
 
25
  </fieldset>
26
 
 
27
  <label for="description">Description</label>
28
- <textarea id="description" [formField]="bookForm.description"></textarea>
 
 
 
 
 
 
 
 
 
 
 
 
29
 
 
30
  <label for="imageUrl">Thumbnail URL</label>
31
- <input type="url" id="imageUrl" [formField]="bookForm.imageUrl" />
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  <button type="submit" [aria-busy]="bookForm().submitting()">
34
  Save
 
1
  <h1>Create book</h1>
2
 
3
+ <form (submit)="submitForm()" novalidate>
4
+ @let titleInvalid = isInvalid(bookForm.title);
5
  <label for="title">Title</label>
6
+ <input
7
+ type="text"
8
+ id="title"
9
+ [formField]="bookForm.title"
10
+ [aria-errormessage]="titleInvalid ? 'title-error' : null"
11
+ [aria-invalid]="titleInvalid"
12
+ />
13
+ @if (titleInvalid) {
14
+ <small role="alert" id="title-error">
15
+ @for (e of bookForm.title().errors(); track e.kind) {
16
+ <span>{{ e.message }}</span>
17
+ }
18
+ </small>
19
+ }
20
 
21
  <label for="subtitle">Subtitle</label>
22
  <input type="text" id="subtitle" [formField]="bookForm.subtitle" />
23
 
24
+ @let isbnInvalid = isInvalid(bookForm.isbn);
25
  <label for="isbn">ISBN</label>
26
+ <input
27
+ type="text"
28
+ id="isbn"
29
+ [formField]="bookForm.isbn"
30
+ [aria-errormessage]="isbnInvalid ? 'isbn-error' : null"
31
+ [aria-invalid]="isbnInvalid"
32
+ />
33
+ @if (isbnInvalid) {
34
+ <small role="alert" id="isbn-error">
35
+ @for (e of bookForm.isbn().errors(); track e.kind) {
36
+ <span>{{ e.message }}</span>
37
+ }
38
+ </small>
39
+ }
40
 
41
  <fieldset>
42
  <legend>Authors</legend>
43
  <button type="button" (click)="addAuthorField()">Add Author</button>
44
+ @let authorsInvalid = isInvalid(bookForm.authors);
45
  <div role="group">
46
  @for (authorField of bookForm.authors; track $index) {
47
  <input
48
  type="text"
49
  [aria-label]="`Author ${$index + 1}`"
50
  [formField]="authorField"
51
+ [aria-errormessage]="authorsInvalid ? 'authors-error' : null"
52
+ [aria-invalid]="authorsInvalid"
53
  />
54
  }
55
+ @if (authorsInvalid) {
56
+ <small role="alert" id="authors-error">
57
+ @for (e of bookForm.authors().errors(); track e.kind) {
58
+ <span>{{ e.message }}</span>
59
+ }
60
+ </small>
61
+ }
62
  </div>
63
+
64
  </fieldset>
65
 
66
+ @let descriptionInvalid = isInvalid(bookForm.description);
67
  <label for="description">Description</label>
68
+ <textarea
69
+ id="description"
70
+ [formField]="bookForm.description"
71
+ [aria-errormessage]="descriptionInvalid ? 'description-error' : null"
72
+ [aria-invalid]="descriptionInvalid">
73
+ </textarea>
74
+ @if (descriptionInvalid) {
75
+ <small role="alert" id="description-error">
76
+ @for (e of bookForm.description().errors(); track e.kind) {
77
+ <span>{{ e.message }}</span>
78
+ }
79
+ </small>
80
+ }
81
 
82
+ @let imageUrlInvalid = isInvalid(bookForm.imageUrl);
83
  <label for="imageUrl">Thumbnail URL</label>
84
+ <input
85
+ type="url"
86
+ id="imageUrl"
87
+ [formField]="bookForm.imageUrl"
88
+ [aria-errormessage]="imageUrlInvalid ? 'image-url-error' : null"
89
+ [aria-invalid]="imageUrlInvalid" />
90
+ @if (imageUrlInvalid) {
91
+ <small role="alert" id="image-url-error">
92
+ @for (e of bookForm.imageUrl().errors(); track e.kind) {
93
+ <span>{{ e.message }}</span>
94
+ }
95
+ </small>
96
+ }
97
 
98
  <button type="submit" [aria-busy]="bookForm().submitting()">
99
  Save
src/app/books-admin/book-create-page/book-create-page.spec.ts CHANGED
@@ -66,7 +66,13 @@ describe('BookCreatePage', () => {
66
  );
67
  });
68
 
 
 
 
 
 
69
  it('should filter out empty author data', () => {
 
70
  component['bookForm'].authors().value.set(
71
  ['', 'Test Author', '']
72
  );
@@ -81,9 +87,59 @@ describe('BookCreatePage', () => {
81
  it('should navigate to created book', async () => {
82
  const location = TestBed.inject(Location);
83
 
 
84
  component.submitForm();
85
  await fixture.whenStable();
86
 
87
  expect(location.path()).toBe('/books/details/1234567890123');
88
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  });
 
66
  );
67
  });
68
 
69
+ it('should not submit form data when form is invalid', () => {
70
+ component.submitForm();
71
+ expect(bookCreateMock).not.toHaveBeenCalled();
72
+ });
73
+
74
  it('should filter out empty author data', () => {
75
+ component['bookForm']().value.set(validBook);
76
  component['bookForm'].authors().value.set(
77
  ['', 'Test Author', '']
78
  );
 
87
  it('should navigate to created book', async () => {
88
  const location = TestBed.inject(Location);
89
 
90
+ component['bookForm']().value.set(validBook);
91
  component.submitForm();
92
  await fixture.whenStable();
93
 
94
  expect(location.path()).toBe('/books/details/1234567890123');
95
  });
96
+
97
+ it('should validate ISBN field', () => {
98
+ const isbnState = component['bookForm'].isbn();
99
+
100
+ // Test required validation
101
+ isbnState.markAsTouched();
102
+ expect(isbnState.errors().length).toBe(1);
103
+ expect(isbnState.errors()[0].kind).toBe('required');
104
+
105
+ // Test minLength validation
106
+ isbnState.value.set('123456789012');
107
+ expect(isbnState.errors().length).toBe(1);
108
+ expect(isbnState.errors()[0].kind).toBe('minLength');
109
+
110
+ // Test maxLength validation
111
+ isbnState.value.set('12345678901234');
112
+ expect(isbnState.errors().length).toBe(1);
113
+ expect(isbnState.errors()[0].kind).toBe('maxLength');
114
+
115
+ // Test valid value
116
+ isbnState.value.set('1234567890123');
117
+ expect(isbnState.errors()).toEqual([]);
118
+ });
119
+
120
+ it('should display an error message for a field and mark it as invalid', async () => {
121
+ const descriptionState = component['bookForm'].description();
122
+ const textareaEl = fixture.nativeElement.querySelector('textarea');
123
+ let textareaMessageEl = fixture.nativeElement.querySelector('#description-error');
124
+
125
+ expect(textareaEl.hasAttribute('aria-errormessage')).toBe(false);
126
+ expect(textareaEl.hasAttribute('aria-invalid')).toBe(false);
127
+ expect(textareaMessageEl).toBeNull();
128
+
129
+ descriptionState.markAsTouched();
130
+ await fixture.whenStable();
131
+
132
+ textareaMessageEl = fixture.nativeElement.querySelector('#description-error');
133
+ expect(textareaEl.getAttribute('aria-errormessage')).toBe('description-error');
134
+ expect(textareaEl.getAttribute('aria-invalid')).toBe('true');
135
+ expect(textareaMessageEl.textContent).toBe('Description is required.');
136
+
137
+ descriptionState.value.set('my description');
138
+ await fixture.whenStable();
139
+
140
+ textareaMessageEl = fixture.nativeElement.querySelector('#description-error');
141
+ expect(textareaEl.hasAttribute('aria-errormessage')).toBe(false);
142
+ expect(textareaEl.getAttribute('aria-invalid')).toBe('false');
143
+ expect(textareaMessageEl).toBeNull();
144
+ });
145
  });
src/app/books-admin/book-create-page/book-create-page.ts CHANGED
@@ -1,12 +1,30 @@
1
  import { Component, inject, signal } from '@angular/core';
2
- import { FormField, form, submit } from '@angular/forms/signals';
3
  import { Router } from '@angular/router';
 
4
 
5
  import { Book } from '../../shared/book';
6
  import { BookStore } from '../../shared/book-store';
7
 
8
  type BookFormData = Required<Book>;
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  @Component({
11
  selector: 'app-book-create-page',
12
  imports: [FormField],
@@ -26,12 +44,19 @@ export class BookCreatePage {
26
  imageUrl: '',
27
  createdAt: '',
28
  });
29
- protected readonly bookForm = form(this.#bookFormData);
30
 
31
  addAuthorField() {
32
  this.bookForm.authors().value.update((authors) => [...authors, '']);
33
  }
34
 
 
 
 
 
 
 
 
35
  submitForm() {
36
  submit(this.bookForm, async (bookForm) => {
37
  const formValue = bookForm().value();
 
1
  import { Component, inject, signal } from '@angular/core';
2
+ import { FormField, FieldTree, form, maxLength, minLength, required, schema, validate, submit } from '@angular/forms/signals';
3
  import { Router } from '@angular/router';
4
+ import { firstValueFrom } from 'rxjs';
5
 
6
  import { Book } from '../../shared/book';
7
  import { BookStore } from '../../shared/book-store';
8
 
9
  type BookFormData = Required<Book>;
10
 
11
+ export const bookFormSchema = schema<BookFormData>((path) => {
12
+ required(path.title, { message: 'Title is required.' });
13
+ required(path.isbn, { message: 'ISBN is required.' });
14
+ minLength(path.isbn, 13, { message: 'ISBN must have 13 digits.' });
15
+ maxLength(path.isbn, 13, { message: 'ISBN must have 13 digits.' });
16
+ validate(path.authors, (ctx) =>
17
+ !ctx.value().some((a) => a)
18
+ ? {
19
+ kind: 'atLeastOneAuthor',
20
+ message: 'At least one author is required.'
21
+ }
22
+ : undefined
23
+ );
24
+ required(path.description, { message: 'Description is required.' });
25
+ required(path.imageUrl, { message: 'URL is required.' });
26
+ });
27
+
28
  @Component({
29
  selector: 'app-book-create-page',
30
  imports: [FormField],
 
44
  imageUrl: '',
45
  createdAt: '',
46
  });
47
+ protected readonly bookForm = form(this.#bookFormData, bookFormSchema);
48
 
49
  addAuthorField() {
50
  this.bookForm.authors().value.update((authors) => [...authors, '']);
51
  }
52
 
53
+ isInvalid(field: FieldTree<unknown>): boolean | null {
54
+ if (!field().touched()) {
55
+ return null;
56
+ }
57
+ return field().invalid();
58
+ }
59
+
60
  submitForm() {
61
  submit(this.bookForm, async (bookForm) => {
62
  const formValue = bookForm().value();