Differenzansicht 09-resource
im Vergleich zu 08-http

Zurück zur Übersicht | ← Vorherige | Nächste → | Demo | Quelltext auf GitHub
src/app/books-portal/book-details-page/book-details-page.html CHANGED
@@ -1,4 +1,14 @@
1
- @if (book(); as b) {
 
 
 
 
 
 
 
 
 
 
2
  <article>
3
  <header>
4
  <h1>{{ b.title }}</h1>
 
1
+ @if (book.error()) {
2
+ <p role="alert">Book could not be found.</p>
3
+ <a routerLink="/books">Back to list</a>
4
+ }
5
+
6
+ @if (book.isLoading()) {
7
+ <p aria-busy="true">Loading ...</p>
8
+ }
9
+
10
+ @if (book.hasValue()) {
11
+ @let b = book.value();
12
  <article>
13
  <header>
14
  <h1>{{ b.title }}</h1>
src/app/books-portal/book-details-page/book-details-page.spec.ts CHANGED
@@ -1,9 +1,8 @@
1
  import { Location } from '@angular/common';
2
  import { provideLocationMocks } from '@angular/common/testing';
3
- import { inputBinding, signal, WritableSignal } from '@angular/core';
4
  import { ComponentFixture, TestBed } from '@angular/core/testing';
5
  import { provideRouter, Router } from '@angular/router';
6
- import { of } from 'rxjs';
7
  import { Mock } from 'vitest';
8
 
9
  import { BookStore } from '../../shared/book-store';
@@ -20,7 +19,7 @@ describe('BookDetailsPage', () => {
20
 
21
  beforeEach(async () => {
22
  isbn = signal('12345');
23
- getSingleMock = vi.fn().mockReturnValue(of(testBook));
24
 
25
  await TestBed.configureTestingModule({
26
  imports: [BookDetailsPage],
@@ -29,7 +28,12 @@ describe('BookDetailsPage', () => {
29
  provideLocationMocks(),
30
  {
31
  provide: BookStore,
32
- useValue: { getSingle: getSingleMock }
 
 
 
 
 
33
  }
34
  ]
35
  })
@@ -47,8 +51,10 @@ describe('BookDetailsPage', () => {
47
  });
48
 
49
  it('should load the correct book by ISBN', async () => {
50
- expect(getSingleMock).toHaveBeenCalledExactlyOnceWith('12345');
51
- expect(component['book']()).toEqual(testBook);
 
 
52
  });
53
 
54
  it('should navigate to the details page', async () => {
@@ -62,12 +68,14 @@ describe('BookDetailsPage', () => {
62
 
63
  it('should update the book when ISBN changes', async () => {
64
  const anotherBook = { isbn: '67890', title: 'Test Book 2', authors: [] };
65
- getSingleMock.mockReturnValue(of(anotherBook));
66
 
67
  isbn.set('67890');
68
  await fixture.whenStable();
69
 
70
- expect(getSingleMock).toHaveBeenCalledWith('67890');
71
- expect(component['book']()).toEqual(anotherBook);
 
 
72
  });
73
  });
 
1
  import { Location } from '@angular/common';
2
  import { provideLocationMocks } from '@angular/common/testing';
3
+ import { inputBinding, resource, signal, WritableSignal } from '@angular/core';
4
  import { ComponentFixture, TestBed } from '@angular/core/testing';
5
  import { provideRouter, Router } from '@angular/router';
 
6
  import { Mock } from 'vitest';
7
 
8
  import { BookStore } from '../../shared/book-store';
 
19
 
20
  beforeEach(async () => {
21
  isbn = signal('12345');
22
+ getSingleMock = vi.fn().mockResolvedValue(testBook);
23
 
24
  await TestBed.configureTestingModule({
25
  imports: [BookDetailsPage],
 
28
  provideLocationMocks(),
29
  {
30
  provide: BookStore,
31
+ useFactory: () => ({
32
+ getSingle: (isbn: () => string) => resource({
33
+ params: isbn,
34
+ loader: getSingleMock,
35
+ })
36
+ })
37
  }
38
  ]
39
  })
 
51
  });
52
 
53
  it('should load the correct book by ISBN', async () => {
54
+ expect(getSingleMock).toHaveBeenCalledExactlyOnceWith(
55
+ expect.objectContaining({ params: '12345' })
56
+ );
57
+ expect(component['book'].value()).toEqual(testBook);
58
  });
59
 
60
  it('should navigate to the details page', async () => {
 
68
 
69
  it('should update the book when ISBN changes', async () => {
70
  const anotherBook = { isbn: '67890', title: 'Test Book 2', authors: [] };
71
+ getSingleMock.mockResolvedValue(anotherBook);
72
 
73
  isbn.set('67890');
74
  await fixture.whenStable();
75
 
76
+ expect(getSingleMock).toHaveBeenLastCalledWith(
77
+ expect.objectContaining({ params: '67890' })
78
+ );
79
+ expect(component['book'].value()).toEqual(anotherBook);
80
  });
81
  });
src/app/books-portal/book-details-page/book-details-page.ts CHANGED
@@ -1,7 +1,6 @@
1
- import { Component, effect, inject, input, signal } from '@angular/core';
2
  import { Router, RouterLink } from '@angular/router';
3
 
4
- import { Book } from '../../shared/book';
5
  import { BookStore } from '../../shared/book-store';
6
 
7
  @Component({
@@ -15,15 +14,7 @@ export class BookDetailsPage {
15
  #router = inject(Router);
16
 
17
  readonly isbn = input.required<string>();
18
- protected book = signal<Book | undefined>(undefined);
19
-
20
- constructor() {
21
- effect(() => {
22
- this.#bookStore.getSingle(this.isbn()).subscribe(book => {
23
- this.book.set(book);
24
- });
25
- });
26
- }
27
 
28
  removeBook(isbn: string) {
29
  if (window.confirm('Delete book?')) {
 
1
+ import { Component, inject, input } from '@angular/core';
2
  import { Router, RouterLink } from '@angular/router';
3
 
 
4
  import { BookStore } from '../../shared/book-store';
5
 
6
  @Component({
 
14
  #router = inject(Router);
15
 
16
  readonly isbn = input.required<string>();
17
+ protected book = this.#bookStore.getSingle(() => this.isbn());
 
 
 
 
 
 
 
 
18
 
19
  removeBook(isbn: string) {
20
  if (window.confirm('Delete book?')) {
src/app/books-portal/books-overview-page/books-overview-page.html CHANGED
@@ -12,6 +12,10 @@
12
 
13
  <section>
14
  <h1>Books</h1>
 
 
 
 
15
  <div>
16
  <input
17
  type="search"
 
12
 
13
  <section>
14
  <h1>Books</h1>
15
+ <button type="button" (click)="books.reload()" [aria-busy]="books.isLoading()">
16
+ Reload
17
+ </button>
18
+
19
  <div>
20
  <input
21
  type="search"
src/app/books-portal/books-overview-page/books-overview-page.spec.ts CHANGED
@@ -1,7 +1,7 @@
1
  import { ComponentFixture, TestBed } from '@angular/core/testing';
2
  import { provideRouter } from '@angular/router';
3
  import { RouterTestingHarness } from '@angular/router/testing';
4
- import { of } from 'rxjs';
5
 
6
  import { BooksOverviewPage } from './books-overview-page';
7
  import { booksPortalRoutes } from '../books-portal.routes';
@@ -24,9 +24,11 @@ describe('BooksOverviewPage', () => {
24
  provideRouter(booksPortalRoutes),
25
  {
26
  provide: BookStore,
27
- useValue: {
28
- getAll: () => of(mockBooks)
29
- }
 
 
30
  }
31
  ]
32
  })
@@ -42,7 +44,7 @@ describe('BooksOverviewPage', () => {
42
  });
43
 
44
  it('should have a list of 2 books with correct titles', () => {
45
- const books = component['books']();
46
 
47
  expect(books.length).toBe(2);
48
  expect(books[0].title).toBe('Tierisch gut kochen');
 
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';
 
24
  provideRouter(booksPortalRoutes),
25
  {
26
  provide: BookStore,
27
+ useFactory: () => ({
28
+ getAll: () => resource({
29
+ loader: () => Promise.resolve(mockBooks),
30
+ })
31
+ })
32
  }
33
  ]
34
  })
 
44
  });
45
 
46
  it('should have a list of 2 books with correct titles', () => {
47
+ const books = component['books'].value();
48
 
49
  expect(books.length).toBe(2);
50
  expect(books[0].title).toBe('Tierisch gut kochen');
src/app/books-portal/books-overview-page/books-overview-page.ts CHANGED
@@ -15,24 +15,22 @@ export class BooksOverviewPage {
15
 
16
  protected searchTerm = signal('');
17
 
18
- protected books = signal<Book[]>([]);
19
  protected likedBooks = signal<Book[]>([]);
20
 
21
  protected filteredBooks = computed(() => {
 
 
 
 
22
  if (!this.searchTerm()) {
23
- return this.books();
24
  }
25
 
26
  const term = this.searchTerm().toLowerCase();
27
- return this.books().filter((b) => b.title.toLowerCase().includes(term));
28
  });
29
 
30
- constructor() {
31
- this.#bookStore.getAll().subscribe(books => {
32
- this.books.set(books);
33
- });
34
- }
35
-
36
  addLikedBook(newLikedBook: Book) {
37
  const foundBook = this.likedBooks().find(
38
  (b) => b.isbn === newLikedBook.isbn
 
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(
36
  (b) => b.isbn === newLikedBook.isbn
src/app/shared/book-store.spec.ts CHANGED
@@ -1,13 +1,15 @@
1
- import { HttpErrorResponse } from '@angular/common/http';
2
  import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
 
3
  import { TestBed } from '@angular/core/testing';
4
 
5
- import { Book } from './book';
6
  import { BookStore } from './book-store';
 
7
 
8
  describe('BookStore', () => {
9
  let service: BookStore;
10
  let httpTesting: HttpTestingController;
 
11
 
12
  beforeEach(() => {
13
  TestBed.configureTestingModule({
@@ -15,6 +17,7 @@ describe('BookStore', () => {
15
  });
16
  service = TestBed.inject(BookStore);
17
  httpTesting = TestBed.inject(HttpTestingController);
 
18
  });
19
 
20
  afterEach(() => {
@@ -25,33 +28,43 @@ describe('BookStore', () => {
25
  expect(service).toBeTruthy();
26
  });
27
 
28
- it('should fetch all books from API', () => {
29
  const mockBooks: Partial<Book>[] = [
30
  { isbn: '123', title: 'Book 1' },
31
  { isbn: '456', title: 'Book 2' }
32
  ];
33
 
34
- let receivedBooks: Book[] | undefined;
35
- service.getAll().subscribe(books => receivedBooks = books);
 
 
 
 
36
 
37
  const req = httpTesting.expectOne('https://api1.angular-buch.com/books');
38
  expect(req.request.method).toBe('GET');
39
  req.flush(mockBooks);
40
 
41
- expect(receivedBooks).toEqual(mockBooks);
 
42
  });
43
 
44
- it('should fetch a single book by ISBN', () => {
45
  const mockBook: Partial<Book> = { isbn: '123', title: 'Test Book' };
46
 
47
- let receivedBook: Book | undefined;
48
- service.getSingle('123').subscribe(book => receivedBook = book);
 
 
 
 
49
 
50
  const req = httpTesting.expectOne('https://api1.angular-buch.com/books/123');
51
  expect(req.request.method).toBe('GET');
52
  req.flush(mockBook);
53
 
54
- expect(receivedBook).toEqual(mockBook);
 
55
  });
56
 
57
  it('should delete a book', () => {
@@ -65,15 +78,19 @@ describe('BookStore', () => {
65
  expect(success).toBe(true);
66
  });
67
 
68
- it('should handle server errors', () => {
69
- let errorResponse: HttpErrorResponse | undefined;
70
- service.getAll().subscribe({
71
- error: (err: HttpErrorResponse) => errorResponse = err
72
  });
73
 
 
 
74
  const req = httpTesting.expectOne('https://api1.angular-buch.com/books');
75
  req.flush('Server Error', { status: 500, statusText: 'Internal Server Error' });
76
 
77
- expect(errorResponse?.status).toBe(500);
 
 
78
  });
79
  });
 
1
+ import { HttpErrorResponse, HttpResourceRef } from '@angular/common/http';
2
  import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
3
+ import { ApplicationRef, Injector, runInInjectionContext } from '@angular/core';
4
  import { TestBed } from '@angular/core/testing';
5
 
 
6
  import { BookStore } from './book-store';
7
+ import { Book } from './book';
8
 
9
  describe('BookStore', () => {
10
  let service: BookStore;
11
  let httpTesting: HttpTestingController;
12
+ let injector: Injector;
13
 
14
  beforeEach(() => {
15
  TestBed.configureTestingModule({
 
17
  });
18
  service = TestBed.inject(BookStore);
19
  httpTesting = TestBed.inject(HttpTestingController);
20
+ injector = TestBed.inject(Injector);
21
  });
22
 
23
  afterEach(() => {
 
28
  expect(service).toBeTruthy();
29
  });
30
 
31
+ it('should fetch all books from API', async () => {
32
  const mockBooks: Partial<Book>[] = [
33
  { isbn: '123', title: 'Book 1' },
34
  { isbn: '456', title: 'Book 2' }
35
  ];
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
 
48
+ await TestBed.inject(ApplicationRef).whenStable();
49
+ expect(booksResource.value()).toEqual(mockBooks);
50
  });
51
 
52
+ it('should fetch a single book by ISBN', async () => {
53
  const mockBook: Partial<Book> = { isbn: '123', title: 'Test Book' };
54
 
55
+ let bookResource!: HttpResourceRef<Book | undefined>;
56
+ runInInjectionContext(injector, () => {
57
+ bookResource = service.getSingle(() => '123');
58
+ });
59
+
60
+ TestBed.tick();
61
 
62
  const req = httpTesting.expectOne('https://api1.angular-buch.com/books/123');
63
  expect(req.request.method).toBe('GET');
64
  req.flush(mockBook);
65
 
66
+ await TestBed.inject(ApplicationRef).whenStable();
67
+ expect(bookResource.value()).toEqual(mockBook);
68
  });
69
 
70
  it('should delete a book', () => {
 
78
  expect(success).toBe(true);
79
  });
80
 
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
  });
src/app/shared/book-store.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { inject, Injectable } from '@angular/core';
2
- import { HttpClient } from '@angular/common/http';
3
  import { Observable } from 'rxjs';
4
 
5
  import { Book } from './book';
@@ -11,12 +11,17 @@ export class BookStore {
11
  #http = inject(HttpClient);
12
  #apiUrl = 'https://api1.angular-buch.com';
13
 
14
- getAll(): Observable<Book[]> {
15
- return this.#http.get<Book[]>(`${this.#apiUrl}/books`);
 
 
 
16
  }
17
 
18
- getSingle(isbn: string): Observable<Book> {
19
- return this.#http.get<Book>(`${this.#apiUrl}/books/${isbn}`);
 
 
20
  }
21
 
22
  remove(isbn: string): Observable<void> {
 
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';
 
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
  }
20
 
21
+ getSingle(isbn: () => string): HttpResourceRef<Book | undefined> {
22
+ return httpResource<Book>(
23
+ () => `${this.#apiUrl}/books/${isbn()}`
24
+ );
25
  }
26
 
27
  remove(isbn: string): Observable<void> {