@@ -30,7 +30,6 @@
|
|
30 |
"@angular/forms": "^20.1.0",
|
31 |
"@angular/platform-browser": "^20.1.0",
|
32 |
"@angular/router": "^20.1.0",
|
33 |
-
"@standard-schema/spec": "^1.0.0",
|
34 |
"rxjs": "~7.8.0",
|
35 |
"tslib": "^2.3.0",
|
36 |
"zone.js": "~0.15.0"
|
@@ -51,4 +50,4 @@
|
|
51 |
"typescript": "~5.8.2",
|
52 |
"typescript-eslint": "8.34.1"
|
53 |
}
|
54 |
-
}
|
|
|
30 |
"@angular/forms": "^20.1.0",
|
31 |
"@angular/platform-browser": "^20.1.0",
|
32 |
"@angular/router": "^20.1.0",
|
|
|
33 |
"rxjs": "~7.8.0",
|
34 |
"tslib": "^2.3.0",
|
35 |
"zone.js": "~0.15.0"
|
|
|
50 |
"typescript": "~5.8.2",
|
51 |
"typescript-eslint": "8.34.1"
|
52 |
}
|
53 |
+
}
|
@@ -1,10 +1,12 @@
|
|
1 |
-
|
2 |
-
|
|
|
|
|
3 |
<label for="title">Title</label>
|
4 |
<input
|
5 |
type="text"
|
6 |
id="title"
|
7 |
-
[
|
8 |
[attr.aria-describedby]="titleInvalid ? 'title-error' : null"
|
9 |
[attr.aria-invalid]="titleInvalid"
|
10 |
/>
|
@@ -13,14 +15,14 @@
|
|
13 |
}
|
14 |
|
15 |
<label for="subtitle">Subtitle</label>
|
16 |
-
<input type="text" id="subtitle" [
|
17 |
|
18 |
-
@let isbnInvalid = isInvalid(
|
19 |
<label for="isbn">ISBN</label>
|
20 |
<input
|
21 |
type="text"
|
22 |
id="isbn"
|
23 |
-
[
|
24 |
[attr.aria-describedby]="isbnInvalid ? 'isbn-error' : null"
|
25 |
[attr.aria-invalid]="isbnInvalid"
|
26 |
/>
|
@@ -31,47 +33,22 @@
|
|
31 |
<fieldset>
|
32 |
<legend>Authors</legend>
|
33 |
<button type="button" (click)="addAuthorControl()">Add Author</button>
|
34 |
-
@let authorsInvalid = isInvalid(f.authors());
|
35 |
<div role="group">
|
36 |
-
@for (authorControl of
|
37 |
<input
|
38 |
type="text"
|
39 |
[attr.aria-label]="'Author ' + $index + 1"
|
40 |
-
[
|
41 |
-
[attr.aria-describedby]="authorsInvalid ? 'authors-error' : null"
|
42 |
-
[attr.aria-invalid]="authorsInvalid"
|
43 |
/>
|
44 |
}
|
45 |
-
@if (authorsInvalid) {
|
46 |
-
<small id="authors-error">At least one author must be added.</small>
|
47 |
-
}
|
48 |
</div>
|
49 |
-
|
50 |
</fieldset>
|
51 |
|
52 |
-
@let descriptionInvalid = isInvalid(f.description());
|
53 |
<label for="description">Description</label>
|
54 |
-
<textarea
|
55 |
-
id="description"
|
56 |
-
[control]="f.description"
|
57 |
-
[attr.aria-describedby]="descriptionInvalid ? 'description-error' : null"
|
58 |
-
[attr.aria-invalid]="descriptionInvalid">
|
59 |
-
</textarea>
|
60 |
-
@if (descriptionInvalid) {
|
61 |
-
<small id="description-error">The description is invalid.</small>
|
62 |
-
}
|
63 |
|
64 |
-
@let imageUrlInvalid = isInvalid(f.imageUrl());
|
65 |
<label for="imageUrl">Thumbnail URL</label>
|
66 |
-
<input
|
67 |
-
type="url"
|
68 |
-
id="imageUrl"
|
69 |
-
[control]="f.imageUrl"
|
70 |
-
[attr.aria-describedby]="imageUrlInvalid ? 'description-error' : null"
|
71 |
-
[attr.aria-invalid]="imageUrlInvalid" />
|
72 |
-
@if (imageUrlInvalid) {
|
73 |
-
<small id="image-url-error">The URL is invalid.</small>
|
74 |
-
}
|
75 |
|
76 |
-
<button type="submit" [disabled]="
|
77 |
</form>
|
|
|
1 |
+
@let c = bookForm.controls;
|
2 |
+
|
3 |
+
<form [formGroup]="bookForm" (ngSubmit)="submitForm()">
|
4 |
+
@let titleInvalid = isInvalid(c.title);
|
5 |
<label for="title">Title</label>
|
6 |
<input
|
7 |
type="text"
|
8 |
id="title"
|
9 |
+
[formControl]="c.title"
|
10 |
[attr.aria-describedby]="titleInvalid ? 'title-error' : null"
|
11 |
[attr.aria-invalid]="titleInvalid"
|
12 |
/>
|
|
|
15 |
}
|
16 |
|
17 |
<label for="subtitle">Subtitle</label>
|
18 |
+
<input type="text" id="subtitle" [formControl]="c.subtitle" />
|
19 |
|
20 |
+
@let isbnInvalid = isInvalid(c.isbn);
|
21 |
<label for="isbn">ISBN</label>
|
22 |
<input
|
23 |
type="text"
|
24 |
id="isbn"
|
25 |
+
[formControl]="c.isbn"
|
26 |
[attr.aria-describedby]="isbnInvalid ? 'isbn-error' : null"
|
27 |
[attr.aria-invalid]="isbnInvalid"
|
28 |
/>
|
|
|
33 |
<fieldset>
|
34 |
<legend>Authors</legend>
|
35 |
<button type="button" (click)="addAuthorControl()">Add Author</button>
|
|
|
36 |
<div role="group">
|
37 |
+
@for (authorControl of c.authors.controls; track $index) {
|
38 |
<input
|
39 |
type="text"
|
40 |
[attr.aria-label]="'Author ' + $index + 1"
|
41 |
+
[formControl]="authorControl"
|
|
|
|
|
42 |
/>
|
43 |
}
|
|
|
|
|
|
|
44 |
</div>
|
|
|
45 |
</fieldset>
|
46 |
|
|
|
47 |
<label for="description">Description</label>
|
48 |
+
<textarea id="description" [formControl]="c.description"></textarea>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
49 |
|
|
|
50 |
<label for="imageUrl">Thumbnail URL</label>
|
51 |
+
<input type="url" id="imageUrl" [formControl]="c.imageUrl" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
|
53 |
+
<button type="submit" [disabled]="bookForm.invalid">Save</button>
|
54 |
</form>
|
@@ -1,65 +1,61 @@
|
|
1 |
-
import { Component, output
|
2 |
-
import {
|
3 |
|
4 |
import { Book } from '../../shared/book';
|
5 |
|
6 |
-
export const formSchema = schema<Book>((fieldPath) => {
|
7 |
-
required(fieldPath.title);
|
8 |
-
required(fieldPath.isbn);
|
9 |
-
minLength(fieldPath.isbn, 13);
|
10 |
-
maxLength(fieldPath.isbn, 13);
|
11 |
-
validate(fieldPath.authors, ({ value }) =>
|
12 |
-
!value().some((a) => a)
|
13 |
-
? customError({ kind: 'atLeastOneAuthor' })
|
14 |
-
: undefined
|
15 |
-
);
|
16 |
-
required(fieldPath.description);
|
17 |
-
required(fieldPath.imageUrl);
|
18 |
-
})
|
19 |
-
|
20 |
@Component({
|
21 |
selector: 'app-book-form',
|
22 |
-
imports: [
|
23 |
templateUrl: './book-form.html',
|
24 |
styleUrl: './book-form.scss'
|
25 |
})
|
26 |
export class BookForm {
|
27 |
readonly submitBook = output<Book>();
|
28 |
|
29 |
-
protected
|
30 |
-
isbn:
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
|
39 |
addAuthorControl() {
|
40 |
-
this.
|
|
|
|
|
41 |
}
|
42 |
|
43 |
-
isInvalid(
|
44 |
-
if (!
|
45 |
return null;
|
46 |
}
|
47 |
-
return
|
48 |
}
|
49 |
|
50 |
-
|
51 |
-
|
|
|
52 |
|
53 |
-
|
54 |
-
|
55 |
-
|
|
|
|
|
56 |
|
57 |
-
|
58 |
-
...formValue,
|
59 |
-
authors,
|
60 |
-
createdAt: new Date().toISOString()
|
61 |
-
};
|
62 |
-
this.submitBook.emit(newBook);
|
63 |
-
});
|
64 |
}
|
65 |
}
|
|
|
1 |
+
import { Component, output } from '@angular/core';
|
2 |
+
import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
3 |
|
4 |
import { Book } from '../../shared/book';
|
5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
@Component({
|
7 |
selector: 'app-book-form',
|
8 |
+
imports: [ReactiveFormsModule],
|
9 |
templateUrl: './book-form.html',
|
10 |
styleUrl: './book-form.scss'
|
11 |
})
|
12 |
export class BookForm {
|
13 |
readonly submitBook = output<Book>();
|
14 |
|
15 |
+
protected bookForm = new FormGroup({
|
16 |
+
isbn: new FormControl('', {
|
17 |
+
nonNullable: true,
|
18 |
+
validators: [
|
19 |
+
Validators.required,
|
20 |
+
Validators.minLength(13),
|
21 |
+
Validators.maxLength(13),
|
22 |
+
]
|
23 |
+
}),
|
24 |
+
title: new FormControl('', {
|
25 |
+
nonNullable: true,
|
26 |
+
validators: Validators.required,
|
27 |
+
}),
|
28 |
+
subtitle: new FormControl('', { nonNullable: true }),
|
29 |
+
description: new FormControl('', { nonNullable: true }),
|
30 |
+
authors: new FormArray([
|
31 |
+
new FormControl('', { nonNullable: true })
|
32 |
+
]),
|
33 |
+
imageUrl: new FormControl('', { nonNullable: true })
|
34 |
+
});
|
35 |
|
36 |
addAuthorControl() {
|
37 |
+
this.bookForm.controls.authors.push(
|
38 |
+
new FormControl('', { nonNullable: true })
|
39 |
+
);
|
40 |
}
|
41 |
|
42 |
+
isInvalid(control: FormControl) {
|
43 |
+
if (!control.touched) {
|
44 |
return null;
|
45 |
}
|
46 |
+
return control.invalid && control.touched;
|
47 |
}
|
48 |
|
49 |
+
submitForm() {
|
50 |
+
const formValue = this.bookForm.getRawValue();
|
51 |
+
const authors = formValue.authors.filter(author => !!author);
|
52 |
|
53 |
+
const newBook: Book = {
|
54 |
+
...formValue,
|
55 |
+
authors,
|
56 |
+
createdAt: new Date().toISOString()
|
57 |
+
};
|
58 |
|
59 |
+
this.submitBook.emit(newBook);
|
|
|
|
|
|
|
|
|
|
|
|
|
60 |
}
|
61 |
}
|
@@ -4,10 +4,11 @@
|
|
4 |
}
|
5 |
|
6 |
@if (book.isLoading()) {
|
7 |
-
<p aria-busy="true">Loading...</p>
|
8 |
}
|
9 |
|
10 |
-
@if (book.
|
|
|
11 |
<article>
|
12 |
<header>
|
13 |
<h1>{{ b.title }}</h1>
|
|
|
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>
|
@@ -21,11 +21,12 @@
|
|
21 |
type="search"
|
22 |
#searchInput
|
23 |
(input)="this.searchTerm.set(searchInput.value)"
|
|
|
24 |
placeholder="Search"
|
25 |
aria-label="Search"
|
26 |
/>
|
27 |
|
28 |
-
@for (b of
|
29 |
<app-book-card [book]="b" (like)="addLikedBook($event)" />
|
30 |
}
|
31 |
</div>
|
|
|
21 |
type="search"
|
22 |
#searchInput
|
23 |
(input)="this.searchTerm.set(searchInput.value)"
|
24 |
+
[value]="this.searchTerm()"
|
25 |
placeholder="Search"
|
26 |
aria-label="Search"
|
27 |
/>
|
28 |
|
29 |
+
@for (b of books.value(); track b.isbn) {
|
30 |
<app-book-card [book]="b" (like)="addLikedBook($event)" />
|
31 |
}
|
32 |
</div>
|
@@ -1,4 +1,5 @@
|
|
1 |
-
import { Component,
|
|
|
2 |
|
3 |
import { Book } from '../../shared/book';
|
4 |
import { BookCard } from '../book-card/book-card';
|
@@ -12,20 +13,23 @@ import { BookStore } from '../../shared/book-store';
|
|
12 |
})
|
13 |
export class BooksOverviewPage {
|
14 |
#bookStore = inject(BookStore);
|
|
|
15 |
|
16 |
-
|
|
|
17 |
|
18 |
-
protected books = this.#bookStore.getAll();
|
19 |
protected likedBooks = signal<Book[]>([]);
|
20 |
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
|
|
29 |
|
30 |
addLikedBook(newLikedBook: Book) {
|
31 |
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(
|
@@ -11,9 +11,12 @@ export class BookStore {
|
|
11 |
#http = inject(HttpClient);
|
12 |
#apiUrl = 'https://api6.angular-buch.com';
|
13 |
|
14 |
-
getAll(): HttpResourceRef<Book[]> {
|
15 |
return httpResource<Book[]>(
|
16 |
-
() =>
|
|
|
|
|
|
|
17 |
{ defaultValue: [] }
|
18 |
);
|
19 |
}
|
|
|
11 |
#http = inject(HttpClient);
|
12 |
#apiUrl = 'https://api6.angular-buch.com';
|
13 |
|
14 |
+
getAll(searchTerm: Signal<string>): HttpResourceRef<Book[]> {
|
15 |
return httpResource<Book[]>(
|
16 |
+
() => ({
|
17 |
+
url: `${this.#apiUrl}/books`,
|
18 |
+
params: { search: searchTerm() }
|
19 |
+
}),
|
20 |
{ defaultValue: [] }
|
21 |
);
|
22 |
}
|
@@ -3,11 +3,6 @@
|
|
3 |
{
|
4 |
"compileOnSave": false,
|
5 |
"compilerOptions": {
|
6 |
-
"baseUrl": ".",
|
7 |
-
"paths": {
|
8 |
-
"@angular/forms/signals": ["./experimental"],
|
9 |
-
"@angular/forms/standard_schema": ["./experimental/src/api/validators/standard_schema"]
|
10 |
-
},
|
11 |
"strict": true,
|
12 |
"noImplicitOverride": true,
|
13 |
"noPropertyAccessFromIndexSignature": true,
|
|
|
3 |
{
|
4 |
"compileOnSave": false,
|
5 |
"compilerOptions": {
|
|
|
|
|
|
|
|
|
|
|
6 |
"strict": true,
|
7 |
"noImplicitOverride": true,
|
8 |
"noPropertyAccessFromIndexSignature": true,
|