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