@@ -30,8 +30,12 @@
|
|
30 |
"@angular/forms": "^20.2.0",
|
31 |
"@angular/platform-browser": "^20.2.0",
|
32 |
"@angular/router": "^20.2.0",
|
|
|
|
|
33 |
"rxjs": "~7.8.0",
|
34 |
-
"tslib": "^2.3.0"
|
|
|
|
|
35 |
},
|
36 |
"devDependencies": {
|
37 |
"@angular/build": "^20.2.1",
|
@@ -49,4 +53,4 @@
|
|
49 |
"typescript": "~5.9.2",
|
50 |
"typescript-eslint": "8.40.0"
|
51 |
}
|
52 |
-
}
|
|
|
30 |
"@angular/forms": "^20.2.0",
|
31 |
"@angular/platform-browser": "^20.2.0",
|
32 |
"@angular/router": "^20.2.0",
|
33 |
+
"@picocss/pico": "^2.1.1",
|
34 |
+
"@standard-schema/spec": "^1.0.0",
|
35 |
"rxjs": "~7.8.0",
|
36 |
+
"tslib": "^2.3.0",
|
37 |
+
"valibot": "^1.1.0",
|
38 |
+
"zod": "^3.25.76"
|
39 |
},
|
40 |
"devDependencies": {
|
41 |
"@angular/build": "^20.2.1",
|
|
|
53 |
"typescript": "~5.9.2",
|
54 |
"typescript-eslint": "8.40.0"
|
55 |
}
|
56 |
+
}
|
@@ -1,14 +1,8 @@
|
|
1 |
-
import {
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
import { routes } from './app.routes';
|
6 |
|
7 |
export const appConfig: ApplicationConfig = {
|
8 |
-
providers: [
|
9 |
-
provideBrowserGlobalErrorListeners(),
|
10 |
-
provideZoneChangeDetection({ eventCoalescing: true }),
|
11 |
-
provideRouter(routes, withComponentInputBinding()),
|
12 |
-
provideHttpClient(withFetch())
|
13 |
-
]
|
14 |
};
|
|
|
1 |
+
import {
|
2 |
+
ApplicationConfig,
|
3 |
+
provideZonelessChangeDetection,
|
4 |
+
} from '@angular/core';
|
|
|
5 |
|
6 |
export const appConfig: ApplicationConfig = {
|
7 |
+
providers: [provideZonelessChangeDetection()],
|
|
|
|
|
|
|
|
|
|
|
8 |
};
|
@@ -1,19 +0,0 @@
|
|
1 |
-
<main>
|
2 |
-
<nav>
|
3 |
-
<ul>
|
4 |
-
<li>
|
5 |
-
<a routerLink="/home" routerLinkActive="active"
|
6 |
-
ariaCurrentWhenActive="page">Home</a>
|
7 |
-
</li>
|
8 |
-
<li>
|
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 />
|
19 |
-
</main>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,21 +0,0 @@
|
|
1 |
-
import { Routes } from '@angular/router';
|
2 |
-
|
3 |
-
export const routes: Routes = [
|
4 |
-
{ path: '', redirectTo: 'home', pathMatch: 'full' },
|
5 |
-
{
|
6 |
-
path: 'home',
|
7 |
-
title: 'BookMonkey',
|
8 |
-
loadComponent: () => import('./home-page/home-page')
|
9 |
-
.then(m => m.HomePage)
|
10 |
-
},
|
11 |
-
{
|
12 |
-
path: 'books',
|
13 |
-
loadChildren: () => import('./books-portal/books-portal.routes')
|
14 |
-
.then(m => m.booksPortalRoutes)
|
15 |
-
},
|
16 |
-
{
|
17 |
-
path: 'admin',
|
18 |
-
loadChildren: () => import('./books-admin/books-admin.routes')
|
19 |
-
.then(m => m.booksAdminRoutes)
|
20 |
-
}
|
21 |
-
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,10 +1,256 @@
|
|
1 |
-
import {
|
2 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
3 |
|
4 |
@Component({
|
5 |
selector: 'app-root',
|
6 |
-
imports: [
|
7 |
-
|
8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
9 |
})
|
10 |
-
export class App {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { JsonPipe } from '@angular/common';
|
2 |
+
import { Component, inject, signal } from '@angular/core';
|
3 |
+
import { Control, customError, CustomValidationError, FieldState, form, submit, ValidationError, WithField } from '@angular/forms/signals';
|
4 |
+
|
5 |
+
import { FormErrorComponent } from './form-error.component';
|
6 |
+
import { IdentityForm } from './identity-form.component';
|
7 |
+
import { InputComponent } from './input.component';
|
8 |
+
import { MultiselectComponent } from './multiselect.component';
|
9 |
+
import { RegistrationService } from './registration.service';
|
10 |
+
// import either ng, zod or valibot schema
|
11 |
+
import { formSchema, RegisterFormData } from './schema/ng';
|
12 |
+
|
13 |
+
// import { RegisterFormData, formSchema } from './schema/zod';
|
14 |
+
// import { RegisterFormData, formSchema } from './schema/valibot';
|
15 |
+
|
16 |
+
const initialState: RegisterFormData = {
|
17 |
+
username: '',
|
18 |
+
identity: {
|
19 |
+
gender: '',
|
20 |
+
salutation: '',
|
21 |
+
pronoun: '',
|
22 |
+
},
|
23 |
+
age: 18, // TODO: when input changes it will be a string again :/
|
24 |
+
birthday: new Date(), // TODO: it will be a string (format: yyyy-mm-dd) when input changes
|
25 |
+
password: { pw1: '', pw2: '' },
|
26 |
+
email: [''],
|
27 |
+
newsletter: false,
|
28 |
+
newsletterTopics: ['Angular'],
|
29 |
+
agreeToTermsAndConditions: false,
|
30 |
+
};
|
31 |
|
32 |
@Component({
|
33 |
selector: 'app-root',
|
34 |
+
imports: [
|
35 |
+
Control,
|
36 |
+
JsonPipe,
|
37 |
+
FormErrorComponent,
|
38 |
+
MultiselectComponent,
|
39 |
+
InputComponent,
|
40 |
+
IdentityForm,
|
41 |
+
],
|
42 |
+
template: `<main>
|
43 |
+
<article>
|
44 |
+
<h1>Angular Signal Forms Demo</h1>
|
45 |
+
<p>
|
46 |
+
This application demos the usage of Angulars new signal-based forms.
|
47 |
+
All features are in state <strong>experimental</strong>.
|
48 |
+
</p>
|
49 |
+
<p>
|
50 |
+
<mark>Note:</mark> User "hanswurst" already exists. Use this user to simulate async validation error.
|
51 |
+
</p>
|
52 |
+
</article>
|
53 |
+
<form (submit)="submit($event)">
|
54 |
+
|
55 |
+
<!-- just an experiment: a generic input for the uncommented template below -->
|
56 |
+
<app-input label="Username" [control]="f.username" />
|
57 |
+
<!--
|
58 |
+
<label>Username
|
59 |
+
<input
|
60 |
+
type="text"
|
61 |
+
[control]="f.username"
|
62 |
+
[attr.aria-invalid]="ariaInvalidState(f.username())">
|
63 |
+
@if(f.username().touched() && f.username().errors().length) {
|
64 |
+
<app-form-error [errors]="f.username().errors()" />
|
65 |
+
}
|
66 |
+
</label>
|
67 |
+
-->
|
68 |
+
|
69 |
+
<identity-form [identity]="f.identity" />
|
70 |
+
|
71 |
+
<div class="group-with-gap">
|
72 |
+
<label>Age
|
73 |
+
<input
|
74 |
+
type="number"
|
75 |
+
[control]="f.age"
|
76 |
+
[attr.aria-invalid]="ariaInvalidState(f.age())">
|
77 |
+
@if(f.age().touched() && f.age().errors().length) {
|
78 |
+
<app-form-error [errors]="f.age().errors()" />
|
79 |
+
}
|
80 |
+
</label>
|
81 |
+
<label>Birthday
|
82 |
+
<input
|
83 |
+
type="date"
|
84 |
+
[control]="f.birthday">
|
85 |
+
</label>
|
86 |
+
</div>
|
87 |
+
|
88 |
+
<div class="group-with-gap">
|
89 |
+
<label>Password
|
90 |
+
<input
|
91 |
+
type="password"
|
92 |
+
autocomplete
|
93 |
+
[control]="f.password.pw1"
|
94 |
+
[attr.aria-invalid]="ariaInvalidState(f.password.pw1())">
|
95 |
+
@if(f.password.pw1().touched() && f.password.pw1().errors().length) {
|
96 |
+
<app-form-error [errors]="f.password.pw1().errors()" />
|
97 |
+
}
|
98 |
+
</label>
|
99 |
+
<label>Password Confirmation
|
100 |
+
<input
|
101 |
+
type="password"
|
102 |
+
autocomplete
|
103 |
+
[control]="f.password.pw2"
|
104 |
+
[attr.aria-invalid]="ariaInvalidState(f.password.pw2())">
|
105 |
+
@if(f.password.pw2().touched() && f.password.pw2().errors().length) {
|
106 |
+
<app-form-error [errors]="f.password.pw2().errors()" />
|
107 |
+
}
|
108 |
+
</label>
|
109 |
+
@if(f.password().touched() && f.password().errors().length) {
|
110 |
+
<app-form-error [errors]="f.password().errors()" />
|
111 |
+
}
|
112 |
+
</div>
|
113 |
+
<fieldset>
|
114 |
+
<legend>E-Mail Addresses</legend>
|
115 |
+
<div class="group-with-gap">
|
116 |
+
@for (emailField of f.email; track $index) {
|
117 |
+
<div>
|
118 |
+
<div role="group">
|
119 |
+
<input
|
120 |
+
type="email"
|
121 |
+
[control]="emailField"
|
122 |
+
[attr.aria-label]="'E-Mail ' + $index"
|
123 |
+
[attr.aria-invalid]="ariaInvalidState(emailField())">
|
124 |
+
<button type="button" (click)="removeEmail($index)">
|
125 |
+
-
|
126 |
+
</button>
|
127 |
+
</div>
|
128 |
+
@if(emailField().touched() && emailField().errors().length) {
|
129 |
+
<app-form-error [errors]="emailField().errors()" />
|
130 |
+
}
|
131 |
+
</div>
|
132 |
+
}
|
133 |
+
</div>
|
134 |
+
@if(!f.email[0] || f.email[0]().touched() && f.email().errors().length) {
|
135 |
+
<app-form-error [errors]="f.email().errors()" />
|
136 |
+
}
|
137 |
+
<button type="button" (click)="addEmail($event)">+</button>
|
138 |
+
</fieldset>
|
139 |
+
<label>Subscribe to Newsletter?
|
140 |
+
<input
|
141 |
+
type="checkbox"
|
142 |
+
[control]="f.newsletter">
|
143 |
+
</label>
|
144 |
+
<app-multiselect [control]="f.newsletterTopics" label="Topics (multiple possible):" />
|
145 |
+
@if(f.newsletterTopics().touched() && f.newsletterTopics().errors().length) {
|
146 |
+
<app-form-error [errors]="f.newsletterTopics().errors()" />
|
147 |
+
}
|
148 |
+
<label>I agree to the terms and conditions
|
149 |
+
<input
|
150 |
+
type="checkbox"
|
151 |
+
[attr.aria-invalid]="ariaInvalidState(f.agreeToTermsAndConditions())"
|
152 |
+
[control]="f.agreeToTermsAndConditions">
|
153 |
+
</label>
|
154 |
+
@if(f.agreeToTermsAndConditions().touched() && f.agreeToTermsAndConditions().errors().length) {
|
155 |
+
<app-form-error [errors]="f.agreeToTermsAndConditions().errors()" />
|
156 |
+
}
|
157 |
+
<hr />
|
158 |
+
<app-form-error [errors]="f().errors()" />
|
159 |
+
<div role="group" class="group-with-gap">
|
160 |
+
<button
|
161 |
+
type="submit"
|
162 |
+
[disabled]="f().submitting()"
|
163 |
+
[ariaBusy]="f().submitting()"
|
164 |
+
>
|
165 |
+
Register
|
166 |
+
</button>
|
167 |
+
<button
|
168 |
+
type="reset"
|
169 |
+
(click)="reset()">
|
170 |
+
Reset
|
171 |
+
</button>
|
172 |
+
</div>
|
173 |
+
</form>
|
174 |
+
|
175 |
+
<article>
|
176 |
+
<div role="group" class="group-with-gap">
|
177 |
+
<div>
|
178 |
+
<h2>Form States</h2>
|
179 |
+
<ul>
|
180 |
+
<li>Touched: {{ f().touched() }} </li>
|
181 |
+
<li>Valid: {{ f().valid() }} </li>
|
182 |
+
<li>Invalid: {{ f().invalid() }} </li>
|
183 |
+
<li>Dirty: {{ f().dirty() }} </li>
|
184 |
+
<li>Disabled: {{ f().disabled() }} </li>
|
185 |
+
<li>Pending: {{ f().pending() }}</li>
|
186 |
+
<li>Submitting: {{ f().submitting() }}</li>
|
187 |
+
</ul>
|
188 |
+
</div>
|
189 |
+
<div>
|
190 |
+
<h3>Data Model</h3>
|
191 |
+
<pre>{{ registrationModel() | json }}</pre>
|
192 |
+
</div>
|
193 |
+
</div>
|
194 |
+
</article>
|
195 |
+
</main>`,
|
196 |
})
|
197 |
+
export class App {
|
198 |
+
private readonly registrationService = inject(RegistrationService);
|
199 |
+
protected readonly registrationModel = signal<RegisterFormData>(initialState);
|
200 |
+
|
201 |
+
protected readonly f = form(this.registrationModel, formSchema);
|
202 |
+
|
203 |
+
protected ariaInvalidState(
|
204 |
+
field: FieldState<string | boolean | number>
|
205 |
+
): boolean | undefined {
|
206 |
+
const errors = field.errors();
|
207 |
+
if (!field.touched()) {
|
208 |
+
return undefined;
|
209 |
+
} else {
|
210 |
+
return errors.length > 0 && field.touched();
|
211 |
+
}
|
212 |
+
}
|
213 |
+
|
214 |
+
protected addEmail(e: Event): boolean {
|
215 |
+
this.f.email().value.update((items) => [...items, '']);
|
216 |
+
e.preventDefault();
|
217 |
+
return false;
|
218 |
+
}
|
219 |
+
|
220 |
+
protected removeEmail(removeIndex: number): void {
|
221 |
+
this.f
|
222 |
+
.email()
|
223 |
+
.value.update((items) =>
|
224 |
+
items.filter((_, index) => index !== removeIndex)
|
225 |
+
);
|
226 |
+
}
|
227 |
+
|
228 |
+
protected async submit(e: Event) {
|
229 |
+
e?.preventDefault();
|
230 |
+
|
231 |
+
await submit(this.f, async (form) => {
|
232 |
+
const errors: (WithField<CustomValidationError | ValidationError>)[] = [];
|
233 |
+
|
234 |
+
try {
|
235 |
+
await this.registrationService.registerUser(form().value);
|
236 |
+
} catch (e) {
|
237 |
+
errors.push(customError({
|
238 |
+
field: form,
|
239 |
+
error: {
|
240 |
+
kind: 'serverError',
|
241 |
+
message:
|
242 |
+
'There was an server error, please try again (should work after 3rd try)',
|
243 |
+
},
|
244 |
+
}));
|
245 |
+
}
|
246 |
+
|
247 |
+
setTimeout(() => this.reset(), 3000);
|
248 |
+
return errors;
|
249 |
+
});
|
250 |
+
}
|
251 |
+
|
252 |
+
protected reset() {
|
253 |
+
this.registrationModel.set(initialState);
|
254 |
+
this.f().reset();
|
255 |
+
}
|
256 |
+
}
|
@@ -1,2 +0,0 @@
|
|
1 |
-
<h1>Create book</h1>
|
2 |
-
<app-book-form (submitBook)="createBook($event)" />
|
|
|
|
|
|
@@ -1,23 +0,0 @@
|
|
1 |
-
import { Component, inject } from '@angular/core';
|
2 |
-
import { Router } from '@angular/router';
|
3 |
-
|
4 |
-
import { BookForm } from '../book-form/book-form';
|
5 |
-
import { BookStore } from '../../shared/book-store';
|
6 |
-
import { Book } from '../../shared/book';
|
7 |
-
|
8 |
-
@Component({
|
9 |
-
selector: 'app-book-create-page',
|
10 |
-
imports: [BookForm],
|
11 |
-
templateUrl: './book-create-page.html',
|
12 |
-
styleUrl: './book-create-page.scss'
|
13 |
-
})
|
14 |
-
export class BookCreatePage {
|
15 |
-
#bookStore = inject(BookStore);
|
16 |
-
#router = inject(Router);
|
17 |
-
|
18 |
-
createBook(book: Book) {
|
19 |
-
this.#bookStore.create(book).subscribe(createdBook => {
|
20 |
-
this.#router.navigate(['/books', 'details', createdBook.isbn]);
|
21 |
-
});
|
22 |
-
}
|
23 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,54 +0,0 @@
|
|
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 |
-
/>
|
13 |
-
@if (titleInvalid) {
|
14 |
-
<small id="title-error">The title is invalid.</small>
|
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 |
-
/>
|
29 |
-
@if (isbnInvalid) {
|
30 |
-
<small id="isbn-error">The ISBN is invalid.</small>
|
31 |
-
}
|
32 |
-
|
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,61 +0,0 @@
|
|
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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,8 +0,0 @@
|
|
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: '', redirectTo: 'create', pathMatch: 'full' },
|
7 |
-
{ path: 'create', component: BookCreatePage, title: 'Create Book' }
|
8 |
-
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,18 +0,0 @@
|
|
1 |
-
@let b = book();
|
2 |
-
|
3 |
-
<article class="book-card">
|
4 |
-
<img [src]="b.imageUrl" alt="Cover" />
|
5 |
-
<header>
|
6 |
-
<h2>{{ b.title }}</h2>
|
7 |
-
</header>
|
8 |
-
<div>
|
9 |
-
@if (b.subtitle) {
|
10 |
-
<p role="doc-subtitle">{{ b.subtitle }}</p>
|
11 |
-
}
|
12 |
-
ISBN: {{ b.isbn | isbnFormat }}
|
13 |
-
</div>
|
14 |
-
<footer>
|
15 |
-
<a [routerLink]="['details', b.isbn]">Details</a>
|
16 |
-
<button type="button" class="secondary" (click)="likeBook()">Like</button>
|
17 |
-
</footer>
|
18 |
-
</article>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,20 +0,0 @@
|
|
1 |
-
import { Component, input, output } from '@angular/core';
|
2 |
-
import { RouterLink } from '@angular/router';
|
3 |
-
|
4 |
-
import { Book } from '../../shared/book';
|
5 |
-
import { IsbnFormatPipe } from '../../shared/isbn-format.pipe';
|
6 |
-
|
7 |
-
@Component({
|
8 |
-
selector: 'app-book-card',
|
9 |
-
imports: [RouterLink, IsbnFormatPipe],
|
10 |
-
templateUrl: './book-card.html',
|
11 |
-
styleUrl: './book-card.scss'
|
12 |
-
})
|
13 |
-
export class BookCard {
|
14 |
-
readonly book = input.required<Book>();
|
15 |
-
readonly like = output<Book>();
|
16 |
-
|
17 |
-
likeBook() {
|
18 |
-
this.like.emit(this.book());
|
19 |
-
}
|
20 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,47 +0,0 @@
|
|
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>
|
15 |
-
@if (b.subtitle) {
|
16 |
-
<p role="doc-subtitle">{{ b.subtitle }}</p>
|
17 |
-
}
|
18 |
-
<div class="grid">
|
19 |
-
<div>
|
20 |
-
<h2>Authors</h2>
|
21 |
-
<ul>
|
22 |
-
@for (author of b.authors; track $index) {
|
23 |
-
<li>{{ author }}</li>
|
24 |
-
}
|
25 |
-
</ul>
|
26 |
-
</div>
|
27 |
-
<div>
|
28 |
-
<h2>ISBN</h2>
|
29 |
-
{{ b.isbn | isbnFormat }}
|
30 |
-
</div>
|
31 |
-
<div>
|
32 |
-
<h2>Created at</h2>
|
33 |
-
{{ b.createdAt | date:'medium' }}
|
34 |
-
</div>
|
35 |
-
</div>
|
36 |
-
</header>
|
37 |
-
<p>{{ b.description }}</p>
|
38 |
-
<img [src]="b.imageUrl" alt="Cover" />
|
39 |
-
<footer>
|
40 |
-
<a routerLink="/books">Back to list</a>
|
41 |
-
<a routerLink="/books/details/9783864909467">Angular Book</a>
|
42 |
-
<button type="button" (click)="removeBook(b.isbn)">
|
43 |
-
Delete book
|
44 |
-
</button>
|
45 |
-
</footer>
|
46 |
-
</article>
|
47 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,28 +0,0 @@
|
|
1 |
-
import { Component, inject, input } from '@angular/core';
|
2 |
-
import { DatePipe } from '@angular/common';
|
3 |
-
import { Router, RouterLink } from '@angular/router';
|
4 |
-
|
5 |
-
import { BookStore } from '../../shared/book-store';
|
6 |
-
import { IsbnFormatPipe } from '../../shared/isbn-format.pipe';
|
7 |
-
|
8 |
-
@Component({
|
9 |
-
selector: 'app-book-details-page',
|
10 |
-
imports: [RouterLink, DatePipe, IsbnFormatPipe],
|
11 |
-
templateUrl: './book-details-page.html',
|
12 |
-
styleUrl: './book-details-page.scss'
|
13 |
-
})
|
14 |
-
export class BookDetailsPage {
|
15 |
-
#bookStore = inject(BookStore);
|
16 |
-
#router = inject(Router);
|
17 |
-
|
18 |
-
readonly isbn = input.required<string>();
|
19 |
-
protected book = this.#bookStore.getSingle(this.isbn);
|
20 |
-
|
21 |
-
removeBook(isbn: string) {
|
22 |
-
if (window.confirm('Delete book?')) {
|
23 |
-
this.#bookStore.remove(isbn).subscribe(() => {
|
24 |
-
this.#router.navigateByUrl('/books');
|
25 |
-
});
|
26 |
-
}
|
27 |
-
}
|
28 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,33 +0,0 @@
|
|
1 |
-
<section>
|
2 |
-
<h1>Favorite Books</h1>
|
3 |
-
<button type="button" (click)="clearLikedBooks()">Clear</button>
|
4 |
-
<ul>
|
5 |
-
@for (b of likedBooks(); track b.isbn) {
|
6 |
-
<li>{{ b.title }} ({{ b.isbn }})</li>
|
7 |
-
} @empty {
|
8 |
-
<li>No books liked.</li>
|
9 |
-
}
|
10 |
-
</ul>
|
11 |
-
</section>
|
12 |
-
|
13 |
-
<section>
|
14 |
-
<h1>Books</h1>
|
15 |
-
<button type="button" (click)="books.reload()" [attr.aria-busy]="books.isLoading()">
|
16 |
-
Reload
|
17 |
-
</button>
|
18 |
-
|
19 |
-
<div>
|
20 |
-
<input
|
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>
|
33 |
-
</section>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,47 +0,0 @@
|
|
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';
|
6 |
-
import { BookStore } from '../../shared/book-store';
|
7 |
-
|
8 |
-
@Component({
|
9 |
-
selector: 'app-books-overview-page',
|
10 |
-
imports: [BookCard],
|
11 |
-
templateUrl: './books-overview-page.html',
|
12 |
-
styleUrl: './books-overview-page.scss',
|
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(
|
36 |
-
(b) => b.isbn === newLikedBook.isbn
|
37 |
-
);
|
38 |
-
|
39 |
-
if (!foundBook) {
|
40 |
-
this.likedBooks.update((likedBooks) => [...likedBooks, newLikedBook]);
|
41 |
-
}
|
42 |
-
}
|
43 |
-
|
44 |
-
clearLikedBooks() {
|
45 |
-
this.likedBooks.set([]);
|
46 |
-
}
|
47 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,9 +0,0 @@
|
|
1 |
-
import { Routes } from '@angular/router';
|
2 |
-
|
3 |
-
import { BooksOverviewPage } from './books-overview-page/books-overview-page';
|
4 |
-
import { BookDetailsPage } from './book-details-page/book-details-page';
|
5 |
-
|
6 |
-
export const booksPortalRoutes: Routes = [
|
7 |
-
{ path: '', component: BooksOverviewPage, title: 'Books' },
|
8 |
-
{ path: 'details/:isbn', component: BookDetailsPage, title: 'Book Details' },
|
9 |
-
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Component, input } from '@angular/core';
|
2 |
+
|
3 |
+
import { ValidationError } from '@angular/forms/signals';
|
4 |
+
|
5 |
+
@Component({
|
6 |
+
selector: 'app-form-error',
|
7 |
+
template: `
|
8 |
+
<small>
|
9 |
+
@for(error of errors(); track $index) {
|
10 |
+
{{ error.message}}
|
11 |
+
@if(!$last) {
|
12 |
+
<br/>
|
13 |
+
}
|
14 |
+
}
|
15 |
+
</small>
|
16 |
+
`,
|
17 |
+
styles: `
|
18 |
+
:host {
|
19 |
+
display: block;
|
20 |
+
margin-top: -0.75rem;
|
21 |
+
margin-bottom: 1rem;
|
22 |
+
color: #bb2233;
|
23 |
+
}
|
24 |
+
`,
|
25 |
+
})
|
26 |
+
export class FormErrorComponent {
|
27 |
+
errors = input<readonly ValidationError[]>([]);
|
28 |
+
}
|
@@ -1,25 +0,0 @@
|
|
1 |
-
<h1>Welcome to the BookMonkey!</h1>
|
2 |
-
|
3 |
-
<hr />
|
4 |
-
|
5 |
-
<input type="search" [formControl]="searchControl" placeholder="Search" />
|
6 |
-
|
7 |
-
@if (isLoading()) {
|
8 |
-
<span aria-busy="true">Searching ...</span>
|
9 |
-
}
|
10 |
-
|
11 |
-
@if (results().length) {
|
12 |
-
<section>
|
13 |
-
<h2>Search results</h2>
|
14 |
-
<ul>
|
15 |
-
@for (b of results(); track b.isbn) {
|
16 |
-
<li>
|
17 |
-
<a [routerLink]="['/books', 'details', b.isbn]">
|
18 |
-
{{ b.title }} ({{ b.isbn }})
|
19 |
-
</a>
|
20 |
-
</li>
|
21 |
-
}
|
22 |
-
</ul>
|
23 |
-
</section>
|
24 |
-
}
|
25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,32 +0,0 @@
|
|
1 |
-
import { Component, inject, signal } from '@angular/core';
|
2 |
-
import { RouterLink } from '@angular/router';
|
3 |
-
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
4 |
-
import { toSignal } from '@angular/core/rxjs-interop';
|
5 |
-
import { filter, debounceTime, distinctUntilChanged, switchMap, tap } from 'rxjs';
|
6 |
-
|
7 |
-
import { BookStore } from '../shared/book-store';
|
8 |
-
|
9 |
-
@Component({
|
10 |
-
selector: 'app-home-page',
|
11 |
-
imports: [ReactiveFormsModule, RouterLink],
|
12 |
-
templateUrl: './home-page.html',
|
13 |
-
styleUrl: './home-page.scss'
|
14 |
-
})
|
15 |
-
export class HomePage {
|
16 |
-
#bookStore = inject(BookStore);
|
17 |
-
|
18 |
-
protected searchControl = new FormControl('', { nonNullable: true });
|
19 |
-
protected isLoading = signal(false);
|
20 |
-
|
21 |
-
protected results = toSignal(
|
22 |
-
this.searchControl.valueChanges.pipe(
|
23 |
-
filter(term => term.length >= 3),
|
24 |
-
debounceTime(500),
|
25 |
-
distinctUntilChanged(),
|
26 |
-
tap(() => this.isLoading.set(true)),
|
27 |
-
switchMap(term => this.#bookStore.search(term)),
|
28 |
-
tap(() => this.isLoading.set(false)),
|
29 |
-
),
|
30 |
-
{ initialValue: [] }
|
31 |
-
);
|
32 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Component, input } from '@angular/core';
|
2 |
+
|
3 |
+
import { schema, Field, Control, hidden, disabled } from '@angular/forms/signals';
|
4 |
+
|
5 |
+
@Component({
|
6 |
+
selector: 'identity-form',
|
7 |
+
imports: [Control],
|
8 |
+
template: `
|
9 |
+
<label>Gender
|
10 |
+
<!-- TODO: [control] seems not to work with <select> right now? -->
|
11 |
+
<select name="gender-identity" [control]="identity().gender">
|
12 |
+
<option value="" selected>Please select</option>
|
13 |
+
<option value="male">Male</option>
|
14 |
+
<option value="female">Female</option>
|
15 |
+
<option value="diverse">Diverse</option>
|
16 |
+
</select>
|
17 |
+
</label>
|
18 |
+
|
19 |
+
<div class="group-with-gap">
|
20 |
+
@if (!identity().salutation().disabled()) {
|
21 |
+
<label>Salutation
|
22 |
+
<input
|
23 |
+
type="text"
|
24 |
+
placeholder="e. g. Mx."
|
25 |
+
[control]="identity().salutation">
|
26 |
+
</label>
|
27 |
+
}
|
28 |
+
@if (!identity().pronoun().disabled()) {
|
29 |
+
<label>Pronoun
|
30 |
+
<input
|
31 |
+
type="text"
|
32 |
+
placeholder="e. g. they/them"
|
33 |
+
[control]="identity().pronoun">
|
34 |
+
</label>
|
35 |
+
}
|
36 |
+
</div>
|
37 |
+
|
38 |
+
`,
|
39 |
+
})
|
40 |
+
export class IdentityForm {
|
41 |
+
readonly identity = input.required<Field<GenderIdentity>>();
|
42 |
+
}
|
43 |
+
|
44 |
+
export interface GenderIdentity {
|
45 |
+
gender: '' | 'male' | 'female' | 'diverse';
|
46 |
+
salutation: string; // e. g. "Mx.", "Dr.", etc.
|
47 |
+
pronoun: string; // e. g. "they/them"
|
48 |
+
}
|
49 |
+
|
50 |
+
export const identitySchema = schema<GenderIdentity>((path) => {
|
51 |
+
// TODO: acutally switch to "hidden" once available
|
52 |
+
disabled(path.salutation, ({ valueOf }) => {
|
53 |
+
return !valueOf(path.gender) || valueOf(path.gender) !== 'diverse';
|
54 |
+
});
|
55 |
+
// TODO: acutally switch to "hidden" once available
|
56 |
+
disabled(path.pronoun, ({ valueOf }) => {
|
57 |
+
return !valueOf(path.gender) || valueOf(path.gender) !== 'diverse';
|
58 |
+
});
|
59 |
+
});
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Component, computed, input, model } from '@angular/core';
|
2 |
+
import { FormValueControl, ValidationError } from '@angular/forms/signals';
|
3 |
+
|
4 |
+
import { FormErrorComponent } from './form-error.component';
|
5 |
+
|
6 |
+
@Component({
|
7 |
+
selector: 'app-input',
|
8 |
+
template: `
|
9 |
+
<label>{{ label() }}
|
10 |
+
<input
|
11 |
+
type="text"
|
12 |
+
[value]="value()"
|
13 |
+
[disabled]="disabled()"
|
14 |
+
[readonly]="readonly()"
|
15 |
+
[attr.aria-invalid]="ariaInvalidState()"
|
16 |
+
(input)="changeInput($event)">
|
17 |
+
@if(touched() && errors()) {
|
18 |
+
<app-form-error [errors]="errorsList()" />
|
19 |
+
}
|
20 |
+
</label>
|
21 |
+
`,
|
22 |
+
imports: [FormErrorComponent],
|
23 |
+
})
|
24 |
+
export class InputComponent implements FormValueControl<string> {
|
25 |
+
readonly value = model<string>("");
|
26 |
+
readonly label = input.required<string>();
|
27 |
+
readonly errors = input<readonly ValidationError[]>([]);
|
28 |
+
readonly valid = input<boolean>();
|
29 |
+
readonly disabled = input<boolean>(false);
|
30 |
+
readonly touched = input<boolean>(false);
|
31 |
+
readonly readonly = input<boolean>(false);
|
32 |
+
|
33 |
+
protected readonly errorsList = computed(() => {
|
34 |
+
return this.errors() || [];
|
35 |
+
});
|
36 |
+
|
37 |
+
protected ariaInvalidState(): boolean | undefined {
|
38 |
+
if (!this.touched()) {
|
39 |
+
return undefined;
|
40 |
+
} else {
|
41 |
+
return this.errorsList().length > 0 && this.touched();
|
42 |
+
}
|
43 |
+
}
|
44 |
+
|
45 |
+
changeInput(e: Event) {
|
46 |
+
const value = (e.target as HTMLInputElement).value;
|
47 |
+
this.value.set(value);
|
48 |
+
}
|
49 |
+
}
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Component, effect, input, model } from '@angular/core';
|
2 |
+
import { FormValueControl, ValidationError } from '@angular/forms/signals';
|
3 |
+
|
4 |
+
@Component({
|
5 |
+
selector: 'app-multiselect',
|
6 |
+
template: `
|
7 |
+
<details class="dropdown" [ariaDisabled]="disabled()">
|
8 |
+
<summary>
|
9 |
+
{{ label() }}
|
10 |
+
</summary>
|
11 |
+
@if(!(disabled())) {
|
12 |
+
<ul>
|
13 |
+
@for(topic of allTopics; track $index) {
|
14 |
+
<li>
|
15 |
+
<label>
|
16 |
+
<input
|
17 |
+
type="checkbox"
|
18 |
+
[name]="topic"
|
19 |
+
[checked]="value().includes(topic)"
|
20 |
+
(input)="changeInput(topic, $event)" />
|
21 |
+
{{ topic }}
|
22 |
+
</label>
|
23 |
+
</li>
|
24 |
+
}
|
25 |
+
</ul>
|
26 |
+
}
|
27 |
+
</details>
|
28 |
+
`,
|
29 |
+
styles: `
|
30 |
+
[aria-disabled=true] {
|
31 |
+
cursor: not-allowed;
|
32 |
+
opacity: 0.5;
|
33 |
+
pointer-events: none;
|
34 |
+
}
|
35 |
+
`,
|
36 |
+
})
|
37 |
+
export class MultiselectComponent implements FormValueControl<string[]> {
|
38 |
+
readonly allTopics = ['Angular', 'Vue', 'React'];
|
39 |
+
readonly value = model<string[]>([]);
|
40 |
+
readonly label = input.required<string>();
|
41 |
+
readonly errors = input<readonly ValidationError[]>([]);
|
42 |
+
readonly disabled = input<boolean>(false);
|
43 |
+
|
44 |
+
changeInput(topic: string, e: Event) {
|
45 |
+
const checked = (e.target as HTMLInputElement).checked;
|
46 |
+
const isInModel = this.value().includes(topic) && checked;
|
47 |
+
if (!isInModel && checked) {
|
48 |
+
this.value.update((current) => [...current, topic]);
|
49 |
+
return;
|
50 |
+
}
|
51 |
+
if (!checked) {
|
52 |
+
this.value.update((current) => current.filter((t) => t !== topic));
|
53 |
+
}
|
54 |
+
}
|
55 |
+
|
56 |
+
constructor() {
|
57 |
+
effect(() => {
|
58 |
+
if (this.disabled()) {
|
59 |
+
this.value.set([]);
|
60 |
+
}
|
61 |
+
});
|
62 |
+
}
|
63 |
+
}
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { Injectable } from '@angular/core';
|
2 |
+
|
3 |
+
@Injectable({
|
4 |
+
providedIn: 'root',
|
5 |
+
})
|
6 |
+
export class RegistrationService {
|
7 |
+
private submitErrorCnt = 0;
|
8 |
+
|
9 |
+
registerUser(registrationData: Record<string, any>) {
|
10 |
+
this.submitErrorCnt++;
|
11 |
+
return new Promise((resolve, reject) => {
|
12 |
+
window.setTimeout(() => {
|
13 |
+
if (this.submitErrorCnt < 3) {
|
14 |
+
reject();
|
15 |
+
} else {
|
16 |
+
resolve(registrationData);
|
17 |
+
}
|
18 |
+
}, 2000);
|
19 |
+
});
|
20 |
+
}
|
21 |
+
|
22 |
+
checkUserExists(username: string) {
|
23 |
+
return new Promise<boolean>((resolve) => {
|
24 |
+
setTimeout(() => {
|
25 |
+
resolve(username === 'hanswurst');
|
26 |
+
}, 500);
|
27 |
+
});
|
28 |
+
}
|
29 |
+
}
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { apply, pattern, schema } from '@angular/forms/signals';
|
2 |
+
import { applyEach, applyWhen, customError, disabled, email, maxLength, min, minLength, required, validate, validateAsync, validateTree } from '@angular/forms/signals';
|
3 |
+
|
4 |
+
import { GenderIdentity, identitySchema } from '../identity-form.component';
|
5 |
+
import { confirmationPasswordValidator, vaidateUsername as validateUsername } from '../validators';
|
6 |
+
|
7 |
+
export interface RegisterFormData {
|
8 |
+
username: string;
|
9 |
+
identity: GenderIdentity;
|
10 |
+
age: number;
|
11 |
+
birthday: Date;
|
12 |
+
password: { pw1: string; pw2: string };
|
13 |
+
email: string[];
|
14 |
+
newsletter: boolean;
|
15 |
+
newsletterTopics: string[];
|
16 |
+
agreeToTermsAndConditions: boolean;
|
17 |
+
}
|
18 |
+
|
19 |
+
export const formSchema = schema<RegisterFormData>((fieldPath) => {
|
20 |
+
// username is required and must be between 3 and 12 characters long
|
21 |
+
required(fieldPath.username, { message: 'Username is required'});
|
22 |
+
minLength(fieldPath.username, 3, { message: 'A username must be at least 3 characters long'});
|
23 |
+
maxLength(fieldPath.username, 12, { message: 'A username can be max. 12 characters long'});
|
24 |
+
validateAsync(fieldPath.username, validateUsername);
|
25 |
+
|
26 |
+
apply(fieldPath.identity, identitySchema);
|
27 |
+
|
28 |
+
min(fieldPath.age, 18, { message: 'You must be >=18 years old'});
|
29 |
+
|
30 |
+
// at least one email and each email must match format
|
31 |
+
validate(fieldPath.email, ({ value }) =>
|
32 |
+
!value().some((e) => e)
|
33 |
+
? customError({
|
34 |
+
kind: 'atLeastOneEmail',
|
35 |
+
message: 'At least one E-Mail address must be added',
|
36 |
+
})
|
37 |
+
: undefined
|
38 |
+
);
|
39 |
+
applyEach(fieldPath.email, (emailPath) => {
|
40 |
+
email(emailPath, { message: 'E-Mail format is invalid'});
|
41 |
+
});
|
42 |
+
|
43 |
+
// passwords are required and must match
|
44 |
+
required(fieldPath.password.pw1, { message: 'A password is required' });
|
45 |
+
required(fieldPath.password.pw2, { message: 'A password confirmation is required'});
|
46 |
+
minLength(fieldPath.password.pw1, 8, { message: 'A password must be at least 8 characters long'});
|
47 |
+
pattern(
|
48 |
+
fieldPath.password.pw1,
|
49 |
+
new RegExp('^.*[!@#$%^&*(),.?":{}|<>\\[\\]\\\\/~`_+=;\'\\-].*$'),
|
50 |
+
{ message: 'The passwort must contain at least one special character'}
|
51 |
+
);
|
52 |
+
validateTree(
|
53 |
+
fieldPath.password,
|
54 |
+
confirmationPasswordValidator(fieldPath.password)
|
55 |
+
);
|
56 |
+
|
57 |
+
// error(
|
58 |
+
// fieldPath.agreeToTermsAndConditions,
|
59 |
+
// ({ value }) => !value(),
|
60 |
+
// 'You must agree to the terms and conditions'
|
61 |
+
// );
|
62 |
+
|
63 |
+
applyWhen(
|
64 |
+
fieldPath,
|
65 |
+
({ value }) => value().newsletter,
|
66 |
+
(fieldPathWhenTrue) => {
|
67 |
+
validate(fieldPathWhenTrue.newsletterTopics, ({ value }) =>
|
68 |
+
!value().length
|
69 |
+
? customError({
|
70 |
+
kind: 'noTopicSelected',
|
71 |
+
message: 'Select at least one newsletter topic',
|
72 |
+
})
|
73 |
+
: undefined
|
74 |
+
);
|
75 |
+
}
|
76 |
+
);
|
77 |
+
|
78 |
+
disabled(
|
79 |
+
fieldPath.newsletterTopics,
|
80 |
+
({ valueOf }) => !valueOf(fieldPath.newsletter)
|
81 |
+
);
|
82 |
+
});
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import {
|
2 |
+
object,
|
3 |
+
string,
|
4 |
+
minLength,
|
5 |
+
minValue,
|
6 |
+
number,
|
7 |
+
maxLength,
|
8 |
+
email,
|
9 |
+
array,
|
10 |
+
regex,
|
11 |
+
boolean,
|
12 |
+
pipe,
|
13 |
+
forward,
|
14 |
+
partialCheck,
|
15 |
+
InferInput,
|
16 |
+
date,
|
17 |
+
} from 'valibot';
|
18 |
+
import { schema } from '@angular/forms/signals';
|
19 |
+
import { validateStandardSchema } from '@angular/forms/standard_schema';
|
20 |
+
|
21 |
+
export const valibotSchema = pipe(
|
22 |
+
object({
|
23 |
+
username: pipe(
|
24 |
+
string('Username is required'),
|
25 |
+
minLength(3, 'A username must be at least 3 characters long'),
|
26 |
+
maxLength(12, 'A username can be max. 12 characters long')
|
27 |
+
),
|
28 |
+
age: pipe(number(), minValue(18, 'You must be >=18 years old')),
|
29 |
+
birthday: date(),
|
30 |
+
email: pipe(
|
31 |
+
array(pipe(string(), email('E-Mail format is invalid'))),
|
32 |
+
minLength(1, 'At least one E-Mail address must be added')
|
33 |
+
),
|
34 |
+
password: pipe(
|
35 |
+
object({
|
36 |
+
pw1: pipe(
|
37 |
+
string('A password is required'),
|
38 |
+
minLength(8, 'A password must be at least 8 characters long'),
|
39 |
+
regex(
|
40 |
+
/^.*[!@#$%^&*(),.?":{}|<>\[\]\\/~`_+=;'\-].*$/,
|
41 |
+
'The password must contain at least one special character'
|
42 |
+
)
|
43 |
+
),
|
44 |
+
pw2: string('A password confirmation is required'),
|
45 |
+
}),
|
46 |
+
forward(
|
47 |
+
partialCheck(
|
48 |
+
[['pw1'], ['pw2']],
|
49 |
+
(input) => input.pw1 === input.pw2,
|
50 |
+
'Passwords do not match'
|
51 |
+
),
|
52 |
+
['pw2']
|
53 |
+
)
|
54 |
+
),
|
55 |
+
newsletter: boolean(),
|
56 |
+
newsletterTopics: array(string()),
|
57 |
+
agreeToTermsAndConditions: boolean(
|
58 |
+
'You must agree to the terms and conditions'
|
59 |
+
),
|
60 |
+
}),
|
61 |
+
forward(
|
62 |
+
partialCheck(
|
63 |
+
[['newsletter'], ['newsletterTopics']],
|
64 |
+
(data) => {
|
65 |
+
return data.newsletter && data.newsletterTopics.length !== 0;
|
66 |
+
},
|
67 |
+
'Select at least one newsletter topic'
|
68 |
+
),
|
69 |
+
['newsletterTopics']
|
70 |
+
)
|
71 |
+
);
|
72 |
+
|
73 |
+
export type RegisterFormData = InferInput<typeof valibotSchema>;
|
74 |
+
|
75 |
+
export const formSchema = schema<RegisterFormData>((fieldPath) => {
|
76 |
+
validateStandardSchema(fieldPath, valibotSchema);
|
77 |
+
});
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import * as z from 'zod';
|
2 |
+
import { schema } from '@angular/forms/signals';
|
3 |
+
import { validateStandardSchema } from '@angular/forms/standard_schema';
|
4 |
+
|
5 |
+
export interface RegisterFormData {
|
6 |
+
username: string;
|
7 |
+
age: number;
|
8 |
+
birthday: Date,
|
9 |
+
password: { pw1: string; pw2: string };
|
10 |
+
email: string[];
|
11 |
+
newsletter: boolean;
|
12 |
+
newsletterTopics: string[];
|
13 |
+
agreeToTermsAndConditions: boolean;
|
14 |
+
}
|
15 |
+
|
16 |
+
const zodSchema = z
|
17 |
+
.object({
|
18 |
+
username: z
|
19 |
+
.string({ message: 'Username is required' })
|
20 |
+
|
21 |
+
.min(3, {
|
22 |
+
message: 'A username must be at least 3 characters long',
|
23 |
+
})
|
24 |
+
.max(12, { message: 'A username can be max. 12 characters long' }),
|
25 |
+
age: z.number().min(18, {
|
26 |
+
message: 'You must be >=18 years old',
|
27 |
+
}),
|
28 |
+
birthday: z.date(),
|
29 |
+
email: z
|
30 |
+
.array(z.string().email({ message: 'E-Mail format is invalid' }))
|
31 |
+
.refine((emails) => emails.length > 0, {
|
32 |
+
message: 'At least one E-Mail address must be added',
|
33 |
+
}),
|
34 |
+
password: z
|
35 |
+
.object({
|
36 |
+
pw1: z
|
37 |
+
.string({ message: 'A password is required' })
|
38 |
+
.min(8, {
|
39 |
+
message: 'A password must be at least 8 characters long',
|
40 |
+
})
|
41 |
+
.regex(/^.*[!@#$%^&*(),.?":{}|<>\[\]\\/~`_+=;'\-].*$/, {
|
42 |
+
message: 'The password must contain at least one special character',
|
43 |
+
}),
|
44 |
+
pw2: z.string({
|
45 |
+
message: 'A password confirmation is required',
|
46 |
+
}),
|
47 |
+
})
|
48 |
+
.refine((data: { pw1: string; pw2: string }) => data.pw1 === data.pw2, {
|
49 |
+
message: 'Passwords do not match',
|
50 |
+
path: ['pw2'],
|
51 |
+
}),
|
52 |
+
newsletter: z.boolean(),
|
53 |
+
newsletterTopics: z.array(z.string()),
|
54 |
+
agreeToTermsAndConditions: z.boolean().refine((val: boolean) => val, {
|
55 |
+
message: 'You must agree to the terms and conditions',
|
56 |
+
}),
|
57 |
+
})
|
58 |
+
.superRefine((data: RegisterFormData, ctx: any) => {
|
59 |
+
if (data.newsletter && data.newsletterTopics.length === 0) {
|
60 |
+
ctx.addIssue({
|
61 |
+
path: ['newsletterTopics'],
|
62 |
+
code: 'noTopicSelected',
|
63 |
+
message: 'Select at least one newsletter topic',
|
64 |
+
});
|
65 |
+
}
|
66 |
+
});
|
67 |
+
|
68 |
+
export const formSchema = schema<RegisterFormData>((fieldPath) => {
|
69 |
+
validateStandardSchema(fieldPath, zodSchema);
|
70 |
+
});
|
@@ -1,44 +0,0 @@
|
|
1 |
-
import { inject, Injectable, Signal } from '@angular/core';
|
2 |
-
import { HttpClient, httpResource, HttpResourceRef } from '@angular/common/http';
|
3 |
-
import { Observable } from 'rxjs';
|
4 |
-
|
5 |
-
import { Book } from './book';
|
6 |
-
|
7 |
-
@Injectable({
|
8 |
-
providedIn: 'root'
|
9 |
-
})
|
10 |
-
export class BookStore {
|
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 |
-
}
|
23 |
-
|
24 |
-
getSingle(isbn: Signal<string>): HttpResourceRef<Book | undefined> {
|
25 |
-
return httpResource<Book>(
|
26 |
-
() => `${this.#apiUrl}/books/${isbn()}`
|
27 |
-
);
|
28 |
-
}
|
29 |
-
|
30 |
-
remove(isbn: string): Observable<void> {
|
31 |
-
return this.#http.delete<void>(`${this.#apiUrl}/books/${isbn}`);
|
32 |
-
}
|
33 |
-
|
34 |
-
create(book: Book): Observable<Book> {
|
35 |
-
return this.#http.post<Book>(`${this.#apiUrl}/books`, book);
|
36 |
-
}
|
37 |
-
|
38 |
-
search(searchTerm: string): Observable<Book[]> {
|
39 |
-
return this.#http.get<Book[]>(
|
40 |
-
`${this.#apiUrl}/books`,
|
41 |
-
{ params: { search: searchTerm } }
|
42 |
-
);
|
43 |
-
}
|
44 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,9 +0,0 @@
|
|
1 |
-
export interface Book {
|
2 |
-
isbn: string;
|
3 |
-
title: string;
|
4 |
-
subtitle?: string;
|
5 |
-
authors: string[];
|
6 |
-
description: string;
|
7 |
-
imageUrl: string;
|
8 |
-
createdAt: string;
|
9 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,23 +0,0 @@
|
|
1 |
-
import { Pipe, PipeTransform } from '@angular/core';
|
2 |
-
|
3 |
-
@Pipe({
|
4 |
-
name: 'isbnFormat'
|
5 |
-
})
|
6 |
-
export class IsbnFormatPipe implements PipeTransform {
|
7 |
-
transform(value: string): string {
|
8 |
-
if (value.length !== 13) {
|
9 |
-
return value;
|
10 |
-
}
|
11 |
-
|
12 |
-
// Beispiel: 978-1-2345-6789-0
|
13 |
-
const parts = [
|
14 |
-
value.slice(0, 3), // Präfix (978 oder 979)
|
15 |
-
value.slice(3, 4), // Gruppe (Sprache/Region)
|
16 |
-
value.slice(4, 8), // Herausgeber
|
17 |
-
value.slice(8, 12), // Titel
|
18 |
-
value.slice(12) // Prüfziffer
|
19 |
-
];
|
20 |
-
|
21 |
-
return parts.join('-');
|
22 |
-
}
|
23 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import { inject, resource, Signal } from '@angular/core';
|
2 |
+
import { AsyncValidatorOptions, customError, FieldPath, TreeValidator, ValidationError } from '@angular/forms/signals';
|
3 |
+
|
4 |
+
import { RegistrationService } from './registration.service';
|
5 |
+
|
6 |
+
export function confirmationPasswordValidator(
|
7 |
+
path: FieldPath<{ pw1: string; pw2: string }>
|
8 |
+
): TreeValidator<{ pw1: string; pw2: string }> {
|
9 |
+
return ({ valueOf, fieldOf }) => {
|
10 |
+
return valueOf(path.pw2) === valueOf(path.pw1)
|
11 |
+
? []
|
12 |
+
: [
|
13 |
+
customError({
|
14 |
+
field: fieldOf(path.pw2),
|
15 |
+
kind: 'confirmationPassword',
|
16 |
+
message:
|
17 |
+
'The entered password must match with the one specified in "Password" field',
|
18 |
+
}),
|
19 |
+
];
|
20 |
+
};
|
21 |
+
}
|
22 |
+
|
23 |
+
export const vaidateUsername: AsyncValidatorOptions<string, string, boolean> = {
|
24 |
+
// Reactive params
|
25 |
+
params: ({ value }) => value(),
|
26 |
+
// Factory creating a resource
|
27 |
+
factory: (params: Signal<string | undefined>) => {
|
28 |
+
const registrationService = inject(RegistrationService);
|
29 |
+
return resource({
|
30 |
+
params,
|
31 |
+
loader: async ({ params }) => {
|
32 |
+
return await registrationService.checkUserExists(params);
|
33 |
+
},
|
34 |
+
});
|
35 |
+
},
|
36 |
+
// Maps resource to error
|
37 |
+
errors: (result) => {
|
38 |
+
return result
|
39 |
+
? [
|
40 |
+
customError({
|
41 |
+
kind: 'userExists',
|
42 |
+
message: 'The username you entered was already taken',
|
43 |
+
}),
|
44 |
+
]
|
45 |
+
: undefined;
|
46 |
+
},
|
47 |
+
};
|
@@ -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/signals": ["./experimental"],
|
9 |
+
"@angular/forms/standard_schema": ["./experimental/src/api/validators/standard_schema"]
|
10 |
+
},
|
11 |
"strict": true,
|
12 |
"noImplicitOverride": true,
|
13 |
"noPropertyAccessFromIndexSignature": true,
|