|
@@ -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 />
|
|
@@ -2,9 +2,11 @@ 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' },
|
| 8 |
{ path: 'home', component: HomePage, title: 'BookManager' },
|
| 9 |
-
...booksPortalRoutes
|
|
|
|
| 10 |
];
|
|
|
|
| 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' },
|
| 9 |
{ path: 'home', component: HomePage, title: 'BookManager' },
|
| 10 |
+
...booksPortalRoutes,
|
| 11 |
+
...booksAdminRoutes
|
| 12 |
];
|
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 35 |
+
</button>
|
| 36 |
+
</form>
|
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 { Book } from '../../shared/book';
|
| 9 |
+
import { BookStore } from '../../shared/book-store';
|
| 10 |
+
import { BookCreatePage } from './book-create-page';
|
| 11 |
+
|
| 12 |
+
describe('BookCreatePage', () => {
|
| 13 |
+
let component: BookCreatePage;
|
| 14 |
+
let fixture: ComponentFixture<BookCreatePage>;
|
| 15 |
+
let bookCreateMock: Mock;
|
| 16 |
+
|
| 17 |
+
const validBook: Required<Book> = {
|
| 18 |
+
isbn: '1234567890123',
|
| 19 |
+
title: 'Test Book',
|
| 20 |
+
subtitle: '',
|
| 21 |
+
authors: ['Test Author'],
|
| 22 |
+
description: 'Test description',
|
| 23 |
+
imageUrl: 'https://example.org/img.jpg',
|
| 24 |
+
createdAt: ''
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
beforeEach(async () => {
|
| 28 |
+
bookCreateMock = vi.fn().mockResolvedValue(validBook);
|
| 29 |
+
|
| 30 |
+
await TestBed.configureTestingModule({
|
| 31 |
+
imports: [BookCreatePage],
|
| 32 |
+
providers: [
|
| 33 |
+
{ provide: BookStore, useValue: { create: bookCreateMock } },
|
| 34 |
+
provideLocationMocks(),
|
| 35 |
+
provideRouter(routes),
|
| 36 |
+
]
|
| 37 |
+
})
|
| 38 |
+
.compileComponents();
|
| 39 |
+
|
| 40 |
+
fixture = TestBed.createComponent(BookCreatePage);
|
| 41 |
+
component = fixture.componentInstance;
|
| 42 |
+
await fixture.whenStable();
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
it('should create', () => {
|
| 46 |
+
expect(component).toBeTruthy();
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
it('should add a new author field', async () => {
|
| 50 |
+
fixture.nativeElement.querySelector('fieldset button').click();
|
| 51 |
+
await fixture.whenStable();
|
| 52 |
+
|
| 53 |
+
const authorInputs = fixture.nativeElement.querySelectorAll('fieldset input[type="text"]');
|
| 54 |
+
expect(authorInputs.length).toBe(2);
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
it('should submit form data', () => {
|
| 58 |
+
component['bookForm']().value.set(validBook);
|
| 59 |
+
component.submitForm();
|
| 60 |
+
|
| 61 |
+
expect(bookCreateMock).toHaveBeenCalledExactlyOnceWith(
|
| 62 |
+
expect.objectContaining({
|
| 63 |
+
...validBook,
|
| 64 |
+
createdAt: expect.stringContaining(new Date().toISOString().slice(0, 10))
|
| 65 |
+
})
|
| 66 |
+
);
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
it('should filter out empty author data', () => {
|
| 70 |
+
component['bookForm'].authors().value.set(
|
| 71 |
+
['', 'Test Author', '']
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
component.submitForm();
|
| 75 |
+
|
| 76 |
+
expect(bookCreateMock).toHaveBeenCalledExactlyOnceWith(
|
| 77 |
+
expect.objectContaining({ authors: ['Test Author'] })
|
| 78 |
+
);
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
it('should navigate to created book', async () => {
|
| 82 |
+
const location = TestBed.inject(Location);
|
| 83 |
+
|
| 84 |
+
component.submitForm();
|
| 85 |
+
await fixture.whenStable();
|
| 86 |
+
|
| 87 |
+
expect(location.path()).toBe('/books/details/1234567890123');
|
| 88 |
+
});
|
| 89 |
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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';
|
| 6 |
+
import { BookStore } from '../../shared/book-store';
|
| 7 |
+
|
| 8 |
+
type BookFormData = Required<Book>;
|
| 9 |
+
|
| 10 |
+
@Component({
|
| 11 |
+
selector: 'app-book-create-page',
|
| 12 |
+
imports: [FormField],
|
| 13 |
+
templateUrl: './book-create-page.html',
|
| 14 |
+
styleUrl: './book-create-page.css'
|
| 15 |
+
})
|
| 16 |
+
export class BookCreatePage {
|
| 17 |
+
#bookStore = inject(BookStore);
|
| 18 |
+
#router = inject(Router);
|
| 19 |
+
|
| 20 |
+
readonly #bookFormData = signal<BookFormData>({
|
| 21 |
+
isbn: '',
|
| 22 |
+
title: '',
|
| 23 |
+
subtitle: '',
|
| 24 |
+
authors: [''],
|
| 25 |
+
description: '',
|
| 26 |
+
imageUrl: '',
|
| 27 |
+
createdAt: '',
|
| 28 |
+
});
|
| 29 |
+
protected readonly bookForm = form(this.#bookFormData);
|
| 30 |
+
|
| 31 |
+
addAuthorField() {
|
| 32 |
+
this.bookForm.authors().value.update((authors) => [...authors, '']);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
submitForm() {
|
| 36 |
+
submit(this.bookForm, async (bookForm) => {
|
| 37 |
+
const formValue = bookForm().value();
|
| 38 |
+
const authors = formValue.authors.filter(author => !!author);
|
| 39 |
+
|
| 40 |
+
const newBook: Book = {
|
| 41 |
+
...formValue,
|
| 42 |
+
authors,
|
| 43 |
+
createdAt: new Date().toISOString()
|
| 44 |
+
};
|
| 45 |
+
|
| 46 |
+
const createdBook = await this.#bookStore.create(newBook);
|
| 47 |
+
await this.#router.navigate(['/books', 'details', createdBook.isbn]);
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
return false; // prevent reload
|
| 51 |
+
}
|
| 52 |
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
{ path: 'admin/create', component: BookCreatePage, title: 'Create Book' }
|
| 8 |
+
];
|
|
@@ -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 |
}
|