@@ -28,6 +28,7 @@
|
|
28 |
"@angular/forms": "^20.1.0",
|
29 |
"@angular/platform-browser": "^20.1.0",
|
30 |
"@angular/router": "^20.1.0",
|
|
|
31 |
"rxjs": "~7.8.0",
|
32 |
"tslib": "^2.3.0",
|
33 |
"zone.js": "~0.15.0"
|
@@ -48,4 +49,4 @@
|
|
48 |
"typescript": "~5.8.2",
|
49 |
"typescript-eslint": "8.34.1"
|
50 |
}
|
51 |
-
}
|
|
|
28 |
"@angular/forms": "^20.1.0",
|
29 |
"@angular/platform-browser": "^20.1.0",
|
30 |
"@angular/router": "^20.1.0",
|
31 |
+
"@standard-schema/spec": "^1.0.0",
|
32 |
"rxjs": "~7.8.0",
|
33 |
"tslib": "^2.3.0",
|
34 |
"zone.js": "~0.15.0"
|
|
|
49 |
"typescript": "~5.8.2",
|
50 |
"typescript-eslint": "8.34.1"
|
51 |
}
|
52 |
+
}
|
@@ -1,12 +1,10 @@
|
|
1 |
-
|
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 |
-
[
|
10 |
[attr.aria-describedby]="titleInvalid ? 'title-error' : null"
|
11 |
[attr.aria-invalid]="titleInvalid"
|
12 |
/>
|
@@ -15,14 +13,14 @@
|
|
15 |
}
|
16 |
|
17 |
<label for="subtitle">Subtitle</label>
|
18 |
-
<input type="text" id="subtitle" [
|
19 |
|
20 |
-
@let isbnInvalid = isInvalid(
|
21 |
<label for="isbn">ISBN</label>
|
22 |
<input
|
23 |
type="text"
|
24 |
id="isbn"
|
25 |
-
[
|
26 |
[attr.aria-describedby]="isbnInvalid ? 'isbn-error' : null"
|
27 |
[attr.aria-invalid]="isbnInvalid"
|
28 |
/>
|
@@ -33,22 +31,52 @@
|
|
33 |
<fieldset>
|
34 |
<legend>Authors</legend>
|
35 |
<button type="button" (click)="addAuthorControl()">Add Author</button>
|
|
|
36 |
<div role="group">
|
37 |
-
@for (authorControl of
|
38 |
<input
|
39 |
type="text"
|
40 |
[attr.aria-label]="'Author ' + $index + 1"
|
41 |
-
[
|
|
|
|
|
42 |
/>
|
43 |
}
|
|
|
|
|
|
|
44 |
</div>
|
|
|
45 |
</fieldset>
|
46 |
|
|
|
47 |
<label for="description">Description</label>
|
48 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
49 |
|
|
|
50 |
<label for="imageUrl">Thumbnail URL</label>
|
51 |
-
<input
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
|
53 |
-
<button type="submit" [disabled]="
|
54 |
</form>
|
|
|
1 |
+
<form (submit)="submitForm($event)">
|
2 |
+
@let titleInvalid = isInvalid(f.title());
|
|
|
|
|
3 |
<label for="title">Title</label>
|
4 |
<input
|
5 |
type="text"
|
6 |
id="title"
|
7 |
+
[control]="f.title"
|
8 |
[attr.aria-describedby]="titleInvalid ? 'title-error' : null"
|
9 |
[attr.aria-invalid]="titleInvalid"
|
10 |
/>
|
|
|
13 |
}
|
14 |
|
15 |
<label for="subtitle">Subtitle</label>
|
16 |
+
<input type="text" id="subtitle" [control]="f.subtitle!" />
|
17 |
|
18 |
+
@let isbnInvalid = isInvalid(f.isbn());
|
19 |
<label for="isbn">ISBN</label>
|
20 |
<input
|
21 |
type="text"
|
22 |
id="isbn"
|
23 |
+
[control]="f.isbn"
|
24 |
[attr.aria-describedby]="isbnInvalid ? 'isbn-error' : null"
|
25 |
[attr.aria-invalid]="isbnInvalid"
|
26 |
/>
|
|
|
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 f.authors; track $index) {
|
37 |
<input
|
38 |
type="text"
|
39 |
[attr.aria-label]="'Author ' + $index + 1"
|
40 |
+
[control]="authorControl"
|
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 |
+
<input
|
55 |
+
id="description"
|
56 |
+
[control]="f.description"
|
57 |
+
[attr.aria-describedby]="descriptionInvalid ? 'description-error' : null"
|
58 |
+
[attr.aria-invalid]="descriptionInvalid" />
|
59 |
+
<!-- <textarea
|
60 |
+
id="description"
|
61 |
+
[control]="f.description"
|
62 |
+
[attr.aria-describedby]="descriptionInvalid ? 'description-error' : null"
|
63 |
+
[attr.aria-invalid]="descriptionInvalid">
|
64 |
+
</textarea> -->
|
65 |
+
@if (descriptionInvalid) {
|
66 |
+
<small id="description-error">The description is invalid.</small>
|
67 |
+
}
|
68 |
|
69 |
+
@let imageUrlInvalid = isInvalid(f.imageUrl());
|
70 |
<label for="imageUrl">Thumbnail URL</label>
|
71 |
+
<input
|
72 |
+
type="url"
|
73 |
+
id="imageUrl"
|
74 |
+
[control]="f.imageUrl"
|
75 |
+
[attr.aria-describedby]="imageUrlInvalid ? 'description-error' : null"
|
76 |
+
[attr.aria-invalid]="imageUrlInvalid" />
|
77 |
+
@if (imageUrlInvalid) {
|
78 |
+
<small id="image-url-error">The URL is invalid.</small>
|
79 |
+
}
|
80 |
|
81 |
+
<button type="submit" [disabled]="f().invalid()" [attr.aria-busy]="f().submitting()" >Save</button>
|
82 |
</form>
|
@@ -1,61 +1,65 @@
|
|
1 |
-
import { Component, output } from '@angular/core';
|
2 |
-
import {
|
3 |
|
4 |
import { Book } from '../../shared/book';
|
5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
6 |
@Component({
|
7 |
selector: 'app-book-form',
|
8 |
-
imports: [
|
9 |
templateUrl: './book-form.html',
|
10 |
styleUrl: './book-form.scss'
|
11 |
})
|
12 |
export class BookForm {
|
13 |
readonly submitBook = output<Book>();
|
14 |
|
15 |
-
protected
|
16 |
-
isbn:
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
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.
|
38 |
-
new FormControl('', { nonNullable: true })
|
39 |
-
);
|
40 |
}
|
41 |
|
42 |
-
isInvalid(
|
43 |
-
if (!
|
44 |
return null;
|
45 |
}
|
46 |
-
return
|
47 |
}
|
48 |
|
49 |
-
submitForm() {
|
50 |
-
|
51 |
-
const authors = formValue.authors.filter(author => !!author);
|
52 |
|
53 |
-
|
54 |
-
|
55 |
-
authors
|
56 |
-
createdAt: new Date().toISOString()
|
57 |
-
};
|
58 |
|
59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
60 |
}
|
61 |
}
|
|
|
1 |
+
import { Component, output, signal } from '@angular/core';
|
2 |
+
import { Control, FieldState, form, maxLength, minLength, required, schema, submit, validate, ValidationError } from '@angular/forms/signal-experimental';
|
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 |
+
? ValidationError.custom({ kind: 'atLeastOneAuthor' })
|
14 |
+
: undefined
|
15 |
+
);
|
16 |
+
required(fieldPath.description);
|
17 |
+
required(fieldPath.imageUrl);
|
18 |
+
})
|
19 |
+
|
20 |
@Component({
|
21 |
selector: 'app-book-form',
|
22 |
+
imports: [Control],
|
23 |
templateUrl: './book-form.html',
|
24 |
styleUrl: './book-form.scss'
|
25 |
})
|
26 |
export class BookForm {
|
27 |
readonly submitBook = output<Book>();
|
28 |
|
29 |
+
protected readonly f = form(signal<Book>({
|
30 |
+
isbn: "",
|
31 |
+
title: "",
|
32 |
+
subtitle: "",
|
33 |
+
authors: [""],
|
34 |
+
description: "",
|
35 |
+
imageUrl: "",
|
36 |
+
createdAt: new Date().toISOString(),
|
37 |
+
}), formSchema);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
|
39 |
addAuthorControl() {
|
40 |
+
this.f.authors().value.update((authors) => [...authors, '']);
|
|
|
|
|
41 |
}
|
42 |
|
43 |
+
isInvalid(field: FieldState<string | string[]>) {
|
44 |
+
if (!field.touched()) {
|
45 |
return null;
|
46 |
}
|
47 |
+
return !field.valid() && field.touched();
|
48 |
}
|
49 |
|
50 |
+
async submitForm(e: Event) {
|
51 |
+
e?.preventDefault();
|
|
|
52 |
|
53 |
+
await submit(this.f, async (form) => {
|
54 |
+
const formValue = this.f().value();
|
55 |
+
const authors = this.f.authors().value().filter(author => !!author);
|
|
|
|
|
56 |
|
57 |
+
const newBook: Book = {
|
58 |
+
...formValue,
|
59 |
+
authors,
|
60 |
+
createdAt: new Date().toISOString()
|
61 |
+
};
|
62 |
+
this.submitBook.emit(newBook);
|
63 |
+
});
|
64 |
}
|
65 |
}
|
@@ -4,11 +4,10 @@
|
|
4 |
}
|
5 |
|
6 |
@if (book.isLoading()) {
|
7 |
-
<p aria-busy="true">Loading
|
8 |
}
|
9 |
|
10 |
-
@if (book.
|
11 |
-
@let b = book.value();
|
12 |
<article>
|
13 |
<header>
|
14 |
<h1>{{ b.title }}</h1>
|
|
|
4 |
}
|
5 |
|
6 |
@if (book.isLoading()) {
|
7 |
+
<p aria-busy="true">Loading...</p>
|
8 |
}
|
9 |
|
10 |
+
@if (book.value(); as b) {
|
|
|
11 |
<article>
|
12 |
<header>
|
13 |
<h1>{{ b.title }}</h1>
|
@@ -19,10 +19,6 @@ export class BooksOverviewPage {
|
|
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 |
}
|
|
|
19 |
protected likedBooks = signal<Book[]>([]);
|
20 |
|
21 |
protected filteredBooks = computed(() => {
|
|
|
|
|
|
|
|
|
22 |
if (!this.searchTerm()) {
|
23 |
return this.books.value();
|
24 |
}
|
@@ -3,6 +3,11 @@
|
|
3 |
{
|
4 |
"compileOnSave": false,
|
5 |
"compilerOptions": {
|
|
|
|
|
|
|
|
|
|
|
6 |
"strict": true,
|
7 |
"noImplicitOverride": true,
|
8 |
"noPropertyAccessFromIndexSignature": true,
|
|
|
3 |
{
|
4 |
"compileOnSave": false,
|
5 |
"compilerOptions": {
|
6 |
+
"baseUrl": ".",
|
7 |
+
"paths": {
|
8 |
+
"@angular/forms/signal-experimental": ["./experimental"],
|
9 |
+
"@angular/forms/standard_schema": ["./experimental/src/api/validators/standard_schema"]
|
10 |
+
},
|
11 |
"strict": true,
|
12 |
"noImplicitOverride": true,
|
13 |
"noPropertyAccessFromIndexSignature": true,
|