Differenzansicht 11-forms
im Vergleich zu 10-pipes

Zurück zur Übersicht | ← Vorherige | Nächste → | Demo | Quelltext auf GitHub
src/app/app.html CHANGED
@@ -9,6 +9,10 @@
9
  <a routerLink="/books" routerLinkActive="active"
10
  ariaCurrentWhenActive="page">Books</a>
11
  </li>
 
 
 
 
12
  </ul>
13
  </nav>
14
  <router-outlet />
 
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 />
src/app/app.routes.ts CHANGED
@@ -2,6 +2,7 @@ import { Routes } from '@angular/router';
2
 
3
  import { HomePage } from './home-page/home-page';
4
  import { booksPortalRoutes } from './books-portal/books-portal.routes';
 
5
 
6
  export const routes: Routes = [
7
  { path: '', redirectTo: 'home', pathMatch: 'full' },
@@ -10,5 +11,6 @@ export const routes: Routes = [
10
  component: HomePage,
11
  title: 'BookManager'
12
  },
13
- ...booksPortalRoutes
 
14
  ];
 
2
 
3
  import { HomePage } from './home-page/home-page';
4
  import { booksPortalRoutes } from './books-portal/books-portal.routes';
5
+ import { booksAdminRoutes } from './books-admin/books-admin.routes';
6
 
7
  export const routes: Routes = [
8
  { path: '', redirectTo: 'home', pathMatch: 'full' },
 
11
  component: HomePage,
12
  title: 'BookManager'
13
  },
14
+ ...booksPortalRoutes,
15
+ ...booksAdminRoutes
16
  ];
src/app/books-admin/book-create-page/book-create-page.html ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
35
+ </button>
36
+ </form>
src/app/books-admin/book-create-page/book-create-page.spec.ts ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Location } from '@angular/common';
2
+ import { provideLocationMocks } from '@angular/common/testing';
3
+ import { ComponentFixture, TestBed } from '@angular/core/testing';
4
+ import { provideRouter } from '@angular/router';
5
+ import { Mock } from 'vitest';
6
+
7
+ import { routes } from '../../app.routes';
8
+ import { BookStore } from '../../shared/book-store';
9
+ import { BookCreatePage } from './book-create-page';
10
+
11
+ describe('BookCreatePage', () => {
12
+ let component: BookCreatePage;
13
+ let fixture: ComponentFixture<BookCreatePage>;
14
+ let createFn: Mock;
15
+
16
+ const validBook = {
17
+ isbn: '1234567890123',
18
+ title: 'Test Book',
19
+ subtitle: '',
20
+ authors: ['Test Author'],
21
+ description: 'Test description',
22
+ imageUrl: 'https://example.org/img.jpg',
23
+ createdAt: '',
24
+ };
25
+
26
+ beforeEach(async () => {
27
+ createFn = vi.fn().mockResolvedValue(validBook);
28
+
29
+ await TestBed.configureTestingModule({
30
+ imports: [BookCreatePage],
31
+ providers: [
32
+ {
33
+ provide: BookStore,
34
+ useValue: { create: createFn }
35
+ },
36
+ provideLocationMocks(),
37
+ provideRouter(routes),
38
+ ]
39
+ })
40
+ .compileComponents();
41
+
42
+ fixture = TestBed.createComponent(BookCreatePage);
43
+ component = fixture.componentInstance;
44
+ await fixture.whenStable();
45
+ });
46
+
47
+ it('should create', () => {
48
+ expect(component).toBeTruthy();
49
+ });
50
+
51
+ it('should add a new author field', async () => {
52
+ fixture.nativeElement.querySelector('fieldset button').click();
53
+ await fixture.whenStable();
54
+
55
+ const authorInputs = fixture.nativeElement.querySelectorAll('fieldset input[type="text"]');
56
+ expect(authorInputs).toHaveLength(2);
57
+ });
58
+
59
+ it('should submit form data', () => {
60
+ vi.useFakeTimers();
61
+ vi.setSystemTime(new Date('2026-01-15'));
62
+
63
+ component['bookForm']().value.set(validBook);
64
+ const formEl = fixture.nativeElement.querySelector('form');
65
+ formEl.dispatchEvent(new Event('submit'));
66
+
67
+ expect(createFn).toHaveBeenCalledExactlyOnceWith(
68
+ expect.objectContaining({
69
+ ...validBook,
70
+ createdAt: '2026-01-15T00:00:00.000Z'
71
+ })
72
+ );
73
+
74
+ vi.useRealTimers();
75
+ });
76
+
77
+ it('should filter out empty author data', () => {
78
+ component['bookForm'].authors().value.set(
79
+ ['', 'Test Author', '']
80
+ );
81
+
82
+ const formEl = fixture.nativeElement.querySelector('form');
83
+ formEl.dispatchEvent(new Event('submit'));
84
+
85
+ expect(createFn).toHaveBeenCalledExactlyOnceWith(
86
+ expect.objectContaining({ authors: ['Test Author'] })
87
+ );
88
+ });
89
+
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
+ });
src/app/books-admin/book-create-page/book-create-page.ts ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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';
6
+ import { BookStore } from '../../shared/book-store';
7
+
8
+ @Component({
9
+ selector: 'app-book-create-page',
10
+ imports: [FormField, FormRoot],
11
+ templateUrl: './book-create-page.html',
12
+ styleUrl: './book-create-page.css'
13
+ })
14
+ export class BookCreatePage {
15
+ #bookStore = inject(BookStore);
16
+ #router = inject(Router);
17
+
18
+ readonly #bookFormData = signal({
19
+ isbn: '',
20
+ title: '',
21
+ subtitle: '',
22
+ authors: [''],
23
+ description: '',
24
+ imageUrl: '',
25
+ });
26
+
27
+ protected readonly bookForm = form(
28
+ this.#bookFormData,
29
+ (path) => { /* TODO Schema */ },
30
+ {
31
+ submission: {
32
+ action: async (bookForm) => {
33
+ const value = bookForm().value();
34
+ const authors = value.authors.filter(author => !!author);
35
+
36
+ const newBook: Book = {
37
+ ...value,
38
+ authors,
39
+ createdAt: new Date().toISOString()
40
+ };
41
+
42
+ const createdBook = await this.#bookStore.create(newBook);
43
+ await this.#router.navigate(['/books', 'details', createdBook.isbn]);
44
+ }
45
+ }
46
+ }
47
+ );
48
+
49
+ addAuthorField() {
50
+ this.bookForm.authors().value.update((authors) => [...authors, '']);
51
+ }
52
+
53
+ }
src/app/books-admin/books-admin.routes.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
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: 'admin', redirectTo: 'admin/create' },
7
+ {
8
+ path: 'admin/create',
9
+ component: BookCreatePage,
10
+ title: 'Create Book'
11
+ },
12
+ ];
src/app/shared/book-store.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { inject, Injectable } from '@angular/core';
2
  import { HttpClient, httpResource, HttpResourceRef } from '@angular/common/http';
3
- import { Observable } from 'rxjs';
4
 
5
  import { Book } from './book';
6
 
@@ -27,4 +27,10 @@ export class BookStore {
27
  remove(isbn: string): Observable<void> {
28
  return this.#http.delete<void>(`${this.#apiUrl}/books/${isbn}`);
29
  }
 
 
 
 
 
 
30
  }
 
1
  import { inject, Injectable } from '@angular/core';
2
  import { HttpClient, httpResource, HttpResourceRef } from '@angular/common/http';
3
+ import { firstValueFrom, Observable } from 'rxjs';
4
 
5
  import { Book } from './book';
6
 
 
27
  remove(isbn: string): Observable<void> {
28
  return this.#http.delete<void>(`${this.#apiUrl}/books/${isbn}`);
29
  }
30
+
31
+ create(book: Book): Promise<Book> {
32
+ return firstValueFrom(
33
+ this.#http.post<Book>(`${this.#apiUrl}/books`, book)
34
+ );
35
+ }
36
  }