Differenzansicht 13-search
im Vergleich zu 12-validation

Zurück zur Übersicht | ← Vorherige | Nächste → | Demo | Quelltext auf GitHub
src/app/books-portal/books-overview-page/books-overview-page.html CHANGED
@@ -25,7 +25,7 @@
25
  aria-label="Search"
26
  />
27
 
28
- @for (b of filteredBooks(); track b.isbn) {
29
  <app-book-card [book]="b" (like)="addLikedBook($event)" />
30
  }
31
  </div>
 
25
  aria-label="Search"
26
  />
27
 
28
+ @for (b of books.value(); track b.isbn) {
29
  <app-book-card [book]="b" (like)="addLikedBook($event)" />
30
  }
31
  </div>
src/app/books-portal/books-overview-page/books-overview-page.spec.ts CHANGED
@@ -1,23 +1,29 @@
 
1
  import { ComponentFixture, TestBed } from '@angular/core/testing';
2
- import { provideRouter } from '@angular/router';
3
  import { RouterTestingHarness } from '@angular/router/testing';
4
- import { resource } from '@angular/core';
5
 
6
- import { BooksOverviewPage } from './books-overview-page';
7
- import { booksPortalRoutes } from '../books-portal.routes';
8
  import { Book } from '../../shared/book';
9
  import { BookStore } from '../../shared/book-store';
 
 
10
 
11
  describe('BooksOverviewPage', () => {
12
  let component: BooksOverviewPage;
13
  let fixture: ComponentFixture<BooksOverviewPage>;
 
14
 
 
15
  const mockBooks: Partial<Book>[] = [
16
  { isbn: '1234', title: 'Tierisch gut kochen' },
17
  { isbn: '5678', title: 'Backen mit Affen' }
18
  ];
19
 
20
  beforeEach(async () => {
 
 
 
21
  await TestBed.configureTestingModule({
22
  imports: [BooksOverviewPage],
23
  providers: [
@@ -25,8 +31,9 @@ describe('BooksOverviewPage', () => {
25
  {
26
  provide: BookStore,
27
  useFactory: () => ({
28
- getAll: () => resource({
29
- loader: () => Promise.resolve(mockBooks),
 
30
  })
31
  })
32
  }
@@ -34,7 +41,9 @@ describe('BooksOverviewPage', () => {
34
  })
35
  .compileComponents();
36
 
37
- fixture = TestBed.createComponent(BooksOverviewPage);
 
 
38
  component = fixture.componentInstance;
39
  await fixture.whenStable();
40
  });
@@ -74,41 +83,41 @@ describe('BooksOverviewPage', () => {
74
  expect(bookCardEls[1].textContent).toContain('Backen mit Affen');
75
  });
76
 
77
- it('should display all books if the search term is empty', () => {
78
- component['searchTerm'].set('');
 
79
 
80
- const books = component['filteredBooks']();
81
- expect(books.length).toBe(2);
82
  });
83
 
84
- it('should filter books based on the search term', () => {
85
- component['searchTerm'].set('Affe');
 
 
86
 
87
- const books = component['filteredBooks']();
88
- expect(books.length).toBe(1);
89
- expect(books[0].title).toBe('Backen mit Affen');
90
  });
91
 
92
- it('should filter books ignoring case sensitivity', () => {
93
- component['searchTerm'].set('AFFEN');
94
-
95
- const books = component['filteredBooks']();
96
- expect(books.length).toBe(1);
97
- expect(books[0].title).toBe('Backen mit Affen');
98
- });
99
 
100
- it('should return an empty array if no book matches the search term', () => {
101
- component['searchTerm'].set('unbekannter Titel');
 
102
 
103
- const books = component['filteredBooks']();
104
- expect(books.length).toBe(0);
105
  });
106
 
107
- it('should load the BooksOverviewPage for /books', async () => {
108
- const harness = await RouterTestingHarness.create();
109
- const component = await harness.navigateByUrl('/books', BooksOverviewPage);
110
 
111
- expect(component).toBeTruthy();
112
- expect(document.title).toBe('Books');
 
 
 
 
113
  });
114
  });
 
1
+ import { inputBinding, resource, signal } from '@angular/core';
2
  import { ComponentFixture, TestBed } from '@angular/core/testing';
3
+ import { provideRouter, Router } from '@angular/router';
4
  import { RouterTestingHarness } from '@angular/router/testing';
5
+ import { Mock } from 'vitest';
6
 
 
 
7
  import { Book } from '../../shared/book';
8
  import { BookStore } from '../../shared/book-store';
9
+ import { booksPortalRoutes } from '../books-portal.routes';
10
+ import { BooksOverviewPage } from './books-overview-page';
11
 
12
  describe('BooksOverviewPage', () => {
13
  let component: BooksOverviewPage;
14
  let fixture: ComponentFixture<BooksOverviewPage>;
15
+ let getAllMock: Mock;
16
 
17
+ const searchSignal = signal<string | undefined>(undefined);
18
  const mockBooks: Partial<Book>[] = [
19
  { isbn: '1234', title: 'Tierisch gut kochen' },
20
  { isbn: '5678', title: 'Backen mit Affen' }
21
  ];
22
 
23
  beforeEach(async () => {
24
+ searchSignal.set(undefined);
25
+ getAllMock = vi.fn().mockResolvedValue(mockBooks);
26
+
27
  await TestBed.configureTestingModule({
28
  imports: [BooksOverviewPage],
29
  providers: [
 
31
  {
32
  provide: BookStore,
33
  useFactory: () => ({
34
+ getAll: (searchTerm: () => string) => resource({
35
+ params: searchTerm,
36
+ loader: getAllMock,
37
  })
38
  })
39
  }
 
41
  })
42
  .compileComponents();
43
 
44
+ fixture = TestBed.createComponent(BooksOverviewPage, {
45
+ bindings: [inputBinding('search', searchSignal)]
46
+ });
47
  component = fixture.componentInstance;
48
  await fixture.whenStable();
49
  });
 
83
  expect(bookCardEls[1].textContent).toContain('Backen mit Affen');
84
  });
85
 
86
+ it('should load the BooksOverviewPage for /books', async () => {
87
+ const harness = await RouterTestingHarness.create();
88
+ const component = await harness.navigateByUrl('/books', BooksOverviewPage);
89
 
90
+ expect(component).toBeTruthy();
91
+ expect(document.title).toBe('Books');
92
  });
93
 
94
+ it('should ask service initially for books', async () => {
95
+ expect(getAllMock).toHaveBeenCalledExactlyOnceWith(
96
+ expect.objectContaining({ params: '' })
97
+ );
98
 
99
+ expect(component['searchTerm']()).toBe('');
 
 
100
  });
101
 
102
+ it('should update searchTerm when query param changes', async () => {
103
+ searchSignal.set('Angular');
104
+ await fixture.whenStable();
 
 
 
 
105
 
106
+ expect(getAllMock).toHaveBeenLastCalledWith(
107
+ expect.objectContaining({ params: 'Angular' })
108
+ );
109
 
110
+ expect(component['searchTerm']()).toBe('Angular');
 
111
  });
112
 
113
+ it('should sync searchTerm to URL via Router', async () => {
114
+ const navigateSpy = vi.spyOn(TestBed.inject(Router), 'navigate');
 
115
 
116
+ component['searchTerm'].set('Angular');
117
+ await fixture.whenStable();
118
+
119
+ expect(navigateSpy).toHaveBeenCalledWith([], {
120
+ queryParams: { search: 'Angular' }
121
+ });
122
  });
123
  });
src/app/books-portal/books-overview-page/books-overview-page.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { Component, computed, inject, signal } from '@angular/core';
 
2
 
3
  import { Book } from '../../shared/book';
4
  import { BookCard } from '../book-card/book-card';
@@ -12,24 +13,23 @@ import { BookStore } from '../../shared/book-store';
12
  })
13
  export class BooksOverviewPage {
14
  #bookStore = inject(BookStore);
 
15
 
16
- protected searchTerm = signal('');
 
17
 
18
- protected books = this.#bookStore.getAll();
19
  protected likedBooks = signal<Book[]>([]);
20
 
21
- protected filteredBooks = computed(() => {
22
- if (!this.books.hasValue()) {
23
- return [];
24
- }
25
-
26
- if (!this.searchTerm()) {
27
- return this.books.value();
28
- }
29
-
30
- const term = this.searchTerm().toLowerCase();
31
- return this.books.value().filter((b) => b.title.toLowerCase().includes(term));
32
- });
33
 
34
  addLikedBook(newLikedBook: Book) {
35
  const foundBook = this.likedBooks().find(
 
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';
 
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(
src/app/shared/book-store.spec.ts CHANGED
@@ -36,12 +36,12 @@ describe('BookStore', () => {
36
 
37
  let booksResource!: HttpResourceRef<Book[]>;
38
  runInInjectionContext(injector, () => {
39
- booksResource = service.getAll();
40
  });
41
 
42
  TestBed.tick();
43
 
44
- const req = httpTesting.expectOne('https://api1.angular-buch.com/books');
45
  expect(req.request.method).toBe('GET');
46
  req.flush(mockBooks);
47
 
@@ -81,16 +81,28 @@ describe('BookStore', () => {
81
  it('should handle server errors', async () => {
82
  let booksResource!: HttpResourceRef<Book[]>;
83
  runInInjectionContext(injector, () => {
84
- booksResource = service.getAll();
85
  });
86
 
87
  TestBed.tick();
88
 
89
- const req = httpTesting.expectOne('https://api1.angular-buch.com/books');
90
  req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' });
91
 
92
  await TestBed.inject(ApplicationRef).whenStable();
93
  expect(booksResource.error()).toBeInstanceOf(HttpErrorResponse);
94
  expect((booksResource.error() as HttpErrorResponse).status).toBe(500);
95
  });
 
 
 
 
 
 
 
 
 
 
 
 
96
  });
 
36
 
37
  let booksResource!: HttpResourceRef<Book[]>;
38
  runInInjectionContext(injector, () => {
39
+ booksResource = service.getAll(() => '');
40
  });
41
 
42
  TestBed.tick();
43
 
44
+ const req = httpTesting.expectOne('https://api1.angular-buch.com/books?filter=');
45
  expect(req.request.method).toBe('GET');
46
  req.flush(mockBooks);
47
 
 
81
  it('should handle server errors', async () => {
82
  let booksResource!: HttpResourceRef<Book[]>;
83
  runInInjectionContext(injector, () => {
84
+ booksResource = service.getAll(() => '');
85
  });
86
 
87
  TestBed.tick();
88
 
89
+ const req = httpTesting.expectOne('https://api1.angular-buch.com/books?filter=');
90
  req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' });
91
 
92
  await TestBed.inject(ApplicationRef).whenStable();
93
  expect(booksResource.error()).toBeInstanceOf(HttpErrorResponse);
94
  expect((booksResource.error() as HttpErrorResponse).status).toBe(500);
95
  });
96
+
97
+ it('should include search filter in HTTP request', () => {
98
+ runInInjectionContext(injector, () => {
99
+ service.getAll(() => 'Angular');
100
+ });
101
+
102
+ TestBed.tick();
103
+
104
+ httpTesting
105
+ .expectOne(r => r.params.get('filter') === 'Angular')
106
+ .flush([]);
107
+ });
108
  });
src/app/shared/book-store.ts CHANGED
@@ -11,9 +11,12 @@ export class BookStore {
11
  #http = inject(HttpClient);
12
  #apiUrl = 'https://api1.angular-buch.com';
13
 
14
- getAll(): HttpResourceRef<Book[]> {
15
  return httpResource<Book[]>(
16
- () => `${this.#apiUrl}/books`,
 
 
 
17
  { defaultValue: [] }
18
  );
19
  }
 
11
  #http = inject(HttpClient);
12
  #apiUrl = 'https://api1.angular-buch.com';
13
 
14
+ getAll(searchTerm: () => string): HttpResourceRef<Book[]> {
15
  return httpResource<Book[]>(
16
+ () => ({
17
+ url: `${this.#apiUrl}/books`,
18
+ params: { filter: searchTerm() }
19
+ }),
20
  { defaultValue: [] }
21
  );
22
  }