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
@@ -71,7 +71,13 @@ describe('BookCreatePage', () => {
71
  vi.useRealTimers();
72
  });
73
 
 
 
 
 
 
74
  it('should filter out empty author data', () => {
 
75
  component['bookForm'].authors().value.set(
76
  ['', 'Test Author', '']
77
  );
@@ -86,9 +92,59 @@ describe('BookCreatePage', () => {
86
  it('should navigate to created book', async () => {
87
  const location = TestBed.inject(Location);
88
 
 
89
  component.submitForm();
90
  await fixture.whenStable();
91
 
92
  expect(location.path()).toBe('/books/details/1234567890123');
93
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  });
 
71
  vi.useRealTimers();
72
  });
73
 
74
+ it('should not submit form data when form is invalid', () => {
75
+ component.submitForm();
76
+ expect(createFn).not.toHaveBeenCalled();
77
+ });
78
+
79
  it('should filter out empty author data', () => {
80
+ component['bookForm']().value.set(validBook);
81
  component['bookForm'].authors().value.set(
82
  ['', 'Test Author', '']
83
  );
 
92
  it('should navigate to created book', async () => {
93
  const location = TestBed.inject(Location);
94
 
95
+ component['bookForm']().value.set(validBook);
96
  component.submitForm();
97
  await fixture.whenStable();
98
 
99
  expect(location.path()).toBe('/books/details/1234567890123');
100
  });
101
+
102
+ it('should validate ISBN field', () => {
103
+ const isbnState = component['bookForm'].isbn();
104
+
105
+ // Prüfung: required
106
+ isbnState.markAsTouched();
107
+ expect(isbnState.errors()).toHaveLength(1);
108
+ expect(isbnState.errors()[0].kind).toBe('required');
109
+
110
+ // Prüfung: minLength
111
+ isbnState.value.set('123456789012');
112
+ expect(isbnState.errors()).toHaveLength(1);
113
+ expect(isbnState.errors()[0].kind).toBe('minLength');
114
+
115
+ // Prüfung: maxLength
116
+ isbnState.value.set('12345678901234');
117
+ expect(isbnState.errors()).toHaveLength(1);
118
+ expect(isbnState.errors()[0].kind).toBe('maxLength');
119
+
120
+ // Prüfung: gültiger Wert
121
+ isbnState.value.set('1234567890123');
122
+ expect(isbnState.errors()).toEqual([]);
123
+ });
124
+
125
+ it('should display an error message for a field and mark it as invalid', async () => {
126
+ const descriptionState = component['bookForm'].description();
127
+ const textareaEl = fixture.nativeElement.querySelector('textarea');
128
+ let textareaMessageEl = fixture.nativeElement.querySelector('#description-error');
129
+
130
+ expect(textareaEl.hasAttribute('aria-errormessage')).toBe(false);
131
+ expect(textareaEl.hasAttribute('aria-invalid')).toBe(false);
132
+ expect(textareaMessageEl).toBeNull();
133
+
134
+ descriptionState.markAsTouched();
135
+ await fixture.whenStable();
136
+
137
+ textareaMessageEl = fixture.nativeElement.querySelector('#description-error');
138
+ expect(textareaEl.getAttribute('aria-errormessage')).toBe('description-error');
139
+ expect(textareaEl.getAttribute('aria-invalid')).toBe('true');
140
+ expect(textareaMessageEl.textContent).toBe('Description is required.');
141
+
142
+ descriptionState.value.set('my description');
143
+ await fixture.whenStable();
144
+
145
+ textareaMessageEl = fixture.nativeElement.querySelector('#description-error');
146
+ expect(textareaEl.hasAttribute('aria-errormessage')).toBe(false);
147
+ expect(textareaEl.getAttribute('aria-invalid')).toBe('false');
148
+ expect(textareaMessageEl).toBeNull();
149
+ });
150
  });
src/app/books-admin/book-create-page/book-create-page.ts CHANGED
@@ -1,5 +1,5 @@
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';
@@ -23,12 +23,34 @@ export class BookCreatePage {
23
  description: '',
24
  imageUrl: '',
25
  });
26
- protected readonly bookForm = form(this.#bookFormData);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  addAuthorField() {
29
  this.bookForm.authors().value.update((authors) => [...authors, '']);
30
  }
31
 
 
 
 
 
 
 
 
32
  submitForm() {
33
  submit(this.bookForm, async (bookForm) => {
34
  const formValue = bookForm().value();
 
1
  import { Component, inject, signal } from '@angular/core';
2
+ import { FormField, FieldTree, form, maxLength, minLength, required, validate, submit } from '@angular/forms/signals';
3
  import { Router } from '@angular/router';
4
 
5
  import { Book } from '../../shared/book';
 
23
  description: '',
24
  imageUrl: '',
25
  });
26
+ protected readonly bookForm = form(this.#bookFormData, (path) => {
27
+ required(path.title, { message: 'Title is required.' });
28
+ required(path.isbn, { message: 'ISBN is required.' });
29
+ minLength(path.isbn, 13, { message: 'ISBN must have 13 digits.' });
30
+ maxLength(path.isbn, 13, { message: 'ISBN must have 13 digits.' });
31
+ validate(path.authors, (ctx) =>
32
+ !ctx.value().some((a) => a)
33
+ ? {
34
+ kind: 'atLeastOneAuthor',
35
+ message: 'At least one author is required.'
36
+ }
37
+ : undefined
38
+ );
39
+ required(path.description, { message: 'Description is required.' });
40
+ required(path.imageUrl, { message: 'URL is required.' });
41
+ });
42
 
43
  addAuthorField() {
44
  this.bookForm.authors().value.update((authors) => [...authors, '']);
45
  }
46
 
47
+ isInvalid(field: FieldTree<unknown>): boolean | null {
48
+ if (!field().touched()) {
49
+ return null;
50
+ }
51
+ return field().invalid();
52
+ }
53
+
54
  submitForm() {
55
  submit(this.bookForm, async (bookForm) => {
56
  const formValue = bookForm().value();