feat: Implement tasks feature using NGRX signals and remove the old counter store, alongside general project configuration and skill documentation updates.
continuous-integration/drone/pr Build is passing

This commit is contained in:
Dennis Hundertmark
2026-03-08 09:50:17 +01:00
parent 2184971175
commit 9d13cc652a
47 changed files with 15272 additions and 14144 deletions
+200 -189
View File
@@ -12,60 +12,68 @@ Build type-safe, reactive forms using Angular's Signal Forms API. Signal Forms p
## Basic Setup
```typescript
import { Component, signal } from "@angular/core";
import { form, FormField, required, email } from "@angular/forms/signals";
import { Component, signal } from '@angular/core';
import { form, FormField, required, email } from '@angular/forms/signals';
interface LoginData {
email: string;
password: string;
email: string;
password: string;
}
@Component({
selector: "app-login",
imports: [FormField],
template: `
<form (submit)="onSubmit($event)">
<label>
Email
<input type="email" [formField]="loginForm.email" />
</label>
@if (loginForm.email().touched() && loginForm.email().invalid()) {
<p class="error">{{ loginForm.email().errors()[0].message }}</p>
}
selector: 'app-login',
imports: [FormField],
template: `
<form (submit)="onSubmit($event)">
<label>
Email
<input
type="email"
[formField]="loginForm.email" />
</label>
@if (loginForm.email().touched() && loginForm.email().invalid()) {
<p class="error">{{ loginForm.email().errors()[0].message }}</p>
}
<label>
Password
<input type="password" [formField]="loginForm.password" />
</label>
@if (loginForm.password().touched() && loginForm.password().invalid()) {
<p class="error">{{ loginForm.password().errors()[0].message }}</p>
}
<label>
Password
<input
type="password"
[formField]="loginForm.password" />
</label>
@if (loginForm.password().touched() && loginForm.password().invalid()) {
<p class="error">{{ loginForm.password().errors()[0].message }}</p>
}
<button type="submit" [disabled]="loginForm().invalid()">Login</button>
</form>
`,
<button
type="submit"
[disabled]="loginForm().invalid()">
Login
</button>
</form>
`,
})
export class Login {
// Form model - a writable signal
loginModel = signal<LoginData>({
email: "",
password: "",
});
// Form model - a writable signal
loginModel = signal<LoginData>({
email: '',
password: '',
});
// Create form with validation schema
loginForm = form(this.loginModel, (schemaPath) => {
required(schemaPath.email, { message: "Email is required" });
email(schemaPath.email, { message: "Enter a valid email address" });
required(schemaPath.password, { message: "Password is required" });
});
// Create form with validation schema
loginForm = form(this.loginModel, (schemaPath) => {
required(schemaPath.email, { message: 'Email is required' });
email(schemaPath.email, { message: 'Enter a valid email address' });
required(schemaPath.password, { message: 'Password is required' });
});
onSubmit(event: Event) {
event.preventDefault();
if (this.loginForm().valid()) {
const credentials = this.loginModel();
console.log("Submitting:", credentials);
onSubmit(event: Event) {
event.preventDefault();
if (this.loginForm().valid()) {
const credentials = this.loginModel();
console.log('Submitting:', credentials);
}
}
}
}
```
@@ -76,24 +84,24 @@ Form models are writable signals that serve as the single source of truth:
```typescript
// Define interface for type safety
interface UserProfile {
name: string;
email: string;
age: number | null;
preferences: {
newsletter: boolean;
theme: "light" | "dark";
};
name: string;
email: string;
age: number | null;
preferences: {
newsletter: boolean;
theme: 'light' | 'dark';
};
}
// Create model signal with initial values
const userModel = signal<UserProfile>({
name: "",
email: "",
age: null,
preferences: {
newsletter: false,
theme: "light",
},
name: '',
email: '',
age: null,
preferences: {
newsletter: false,
theme: 'light',
},
});
// Create form from model
@@ -120,14 +128,14 @@ const theme = this.userForm.preferences.theme().value();
```typescript
// Replace entire model
this.userModel.set({
name: "Alice",
email: "alice@example.com",
age: 30,
preferences: { newsletter: true, theme: "dark" },
name: 'Alice',
email: 'alice@example.com',
age: 30,
preferences: { newsletter: true, theme: 'dark' },
});
// Update single field
this.userForm.name().value.set("Bob");
this.userForm.name().value.set('Bob');
this.userForm.age().value.update((age) => (age ?? 0) + 1);
```
@@ -177,36 +185,27 @@ this.form().dirty();
### Built-in Validators
```typescript
import {
form,
required,
email,
min,
max,
minLength,
maxLength,
pattern,
} from "@angular/forms/signals";
import { form, required, email, min, max, minLength, maxLength, pattern } from '@angular/forms/signals';
const userForm = form(this.userModel, (schemaPath) => {
// Required field
required(schemaPath.name, { message: "Name is required" });
// Required field
required(schemaPath.name, { message: 'Name is required' });
// Email format
email(schemaPath.email, { message: "Invalid email" });
// Email format
email(schemaPath.email, { message: 'Invalid email' });
// Numeric range
min(schemaPath.age, 18, { message: "Must be 18+" });
max(schemaPath.age, 120, { message: "Invalid age" });
// Numeric range
min(schemaPath.age, 18, { message: 'Must be 18+' });
max(schemaPath.age, 120, { message: 'Invalid age' });
// String/array length
minLength(schemaPath.password, 8, { message: "Min 8 characters" });
maxLength(schemaPath.bio, 500, { message: "Max 500 characters" });
// String/array length
minLength(schemaPath.password, 8, { message: 'Min 8 characters' });
maxLength(schemaPath.bio, 500, { message: 'Max 500 characters' });
// Regex pattern
pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, {
message: "Format: 555-123-4567",
});
// Regex pattern
pattern(schemaPath.phone, /^\d{3}-\d{3}-\d{4}$/, {
message: 'Format: 555-123-4567',
});
});
```
@@ -214,26 +213,26 @@ const userForm = form(this.userModel, (schemaPath) => {
```typescript
const orderForm = form(this.orderModel, (schemaPath) => {
required(schemaPath.promoCode, {
message: "Promo code required for discounts",
when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),
});
required(schemaPath.promoCode, {
message: 'Promo code required for discounts',
when: ({ valueOf }) => valueOf(schemaPath.applyDiscount),
});
});
```
### Custom Validators
```typescript
import { validate } from "@angular/forms/signals";
import { validate } from '@angular/forms/signals';
const signupForm = form(this.signupModel, (schemaPath) => {
// Custom validation logic
validate(schemaPath.username, ({ value }) => {
if (value().includes(" ")) {
return { kind: "noSpaces", message: "Username cannot contain spaces" };
}
return null;
});
// Custom validation logic
validate(schemaPath.username, ({ value }) => {
if (value().includes(' ')) {
return { kind: 'noSpaces', message: 'Username cannot contain spaces' };
}
return null;
});
});
```
@@ -241,38 +240,38 @@ const signupForm = form(this.signupModel, (schemaPath) => {
```typescript
const passwordForm = form(this.passwordModel, (schemaPath) => {
required(schemaPath.password);
required(schemaPath.confirmPassword);
required(schemaPath.password);
required(schemaPath.confirmPassword);
// Compare fields
validate(schemaPath.confirmPassword, ({ value, valueOf }) => {
if (value() !== valueOf(schemaPath.password)) {
return { kind: "mismatch", message: "Passwords do not match" };
}
return null;
});
// Compare fields
validate(schemaPath.confirmPassword, ({ value, valueOf }) => {
if (value() !== valueOf(schemaPath.password)) {
return { kind: 'mismatch', message: 'Passwords do not match' };
}
return null;
});
});
```
### Async Validation
```typescript
import { validateHttp } from "@angular/forms/signals";
import { validateHttp } from '@angular/forms/signals';
const signupForm = form(this.signupModel, (schemaPath) => {
validateHttp(schemaPath.username, {
request: ({ value }) => `/api/check-username?u=${value()}`,
onSuccess: (response: { taken: boolean }) => {
if (response.taken) {
return { kind: "taken", message: "Username already taken" };
}
return null;
},
onError: () => ({
kind: "networkError",
message: "Could not verify username",
}),
});
validateHttp(schemaPath.username, {
request: ({ value }) => `/api/check-username?u=${value()}`,
onSuccess: (response: { taken: boolean }) => {
if (response.taken) {
return { kind: 'taken', message: 'Username already taken' };
}
return null;
},
onError: () => ({
kind: 'networkError',
message: 'Could not verify username',
}),
});
});
```
@@ -281,10 +280,10 @@ const signupForm = form(this.signupModel, (schemaPath) => {
### Hidden Fields
```typescript
import { hidden } from "@angular/forms/signals";
import { hidden } from '@angular/forms/signals';
const profileForm = form(this.profileModel, (schemaPath) => {
hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic));
hidden(schemaPath.publicUrl, ({ valueOf }) => !valueOf(schemaPath.isPublic));
});
```
@@ -297,55 +296,56 @@ const profileForm = form(this.profileModel, (schemaPath) => {
### Disabled Fields
```typescript
import { disabled } from "@angular/forms/signals";
import { disabled } from '@angular/forms/signals';
const orderForm = form(this.orderModel, (schemaPath) => {
disabled(
schemaPath.couponCode,
({ valueOf }) => valueOf(schemaPath.total) < 50,
);
disabled(schemaPath.couponCode, ({ valueOf }) => valueOf(schemaPath.total) < 50);
});
```
### Readonly Fields
```typescript
import { readonly } from "@angular/forms/signals";
import { readonly } from '@angular/forms/signals';
const accountForm = form(this.accountModel, (schemaPath) => {
readonly(schemaPath.username); // Always readonly
readonly(schemaPath.username); // Always readonly
});
```
## Form Submission
```typescript
import { submit } from "@angular/forms/signals";
import { submit } from '@angular/forms/signals';
@Component({
template: `
<form (submit)="onSubmit($event)">
<input [formField]="form.email" />
<input [formField]="form.password" />
<button type="submit" [disabled]="form().invalid()">Submit</button>
</form>
`,
template: `
<form (submit)="onSubmit($event)">
<input [formField]="form.email" />
<input [formField]="form.password" />
<button
type="submit"
[disabled]="form().invalid()">
Submit
</button>
</form>
`,
})
export class Login {
model = signal({ email: "", password: "" });
form = form(this.model, (schemaPath) => {
required(schemaPath.email);
required(schemaPath.password);
});
onSubmit(event: Event) {
event.preventDefault();
// submit() marks all fields touched and runs callback if valid
submit(this.form, async () => {
await this.authService.login(this.model());
model = signal({ email: '', password: '' });
form = form(this.model, (schemaPath) => {
required(schemaPath.email);
required(schemaPath.password);
});
}
onSubmit(event: Event) {
event.preventDefault();
// submit() marks all fields touched and runs callback if valid
submit(this.form, async () => {
await this.authService.login(this.model());
});
}
}
```
@@ -353,46 +353,58 @@ export class Login {
```typescript
interface Order {
items: Array<{ product: string; quantity: number }>;
items: Array<{ product: string; quantity: number }>;
}
@Component({
template: `
@for (item of orderForm.items; track $index; let i = $index) {
<div>
<input [formField]="item.product" placeholder="Product" />
<input [formField]="item.quantity" type="number" />
<button type="button" (click)="removeItem(i)">Remove</button>
</div>
}
<button type="button" (click)="addItem()">Add Item</button>
`,
template: `
@for (item of orderForm.items; track $index; let i = $index) {
<div>
<input
placeholder="Product"
[formField]="item.product" />
<input
type="number"
[formField]="item.quantity" />
<button
type="button"
(click)="removeItem(i)">
Remove
</button>
</div>
}
<button
type="button"
(click)="addItem()">
Add Item
</button>
`,
})
export class Order {
orderModel = signal<Order>({
items: [{ product: "", quantity: 1 }],
});
orderForm = form(this.orderModel, (schemaPath) => {
applyEach(schemaPath.items, (item) => {
required(item.product, { message: "Product required" });
min(item.quantity, 1, { message: "Min quantity is 1" });
orderModel = signal<Order>({
items: [{ product: '', quantity: 1 }],
});
});
addItem() {
this.orderModel.update((m) => ({
...m,
items: [...m.items, { product: "", quantity: 1 }],
}));
}
orderForm = form(this.orderModel, (schemaPath) => {
applyEach(schemaPath.items, (item) => {
required(item.product, { message: 'Product required' });
min(item.quantity, 1, { message: 'Min quantity is 1' });
});
});
removeItem(index: number) {
this.orderModel.update((m) => ({
...m,
items: m.items.filter((_, i) => i !== index),
}));
}
addItem() {
this.orderModel.update((m) => ({
...m,
items: [...m.items, { product: '', quantity: 1 }],
}));
}
removeItem(index: number) {
this.orderModel.update((m) => ({
...m,
items: m.items.filter((_, i) => i !== index),
}));
}
}
```
@@ -403,9 +415,9 @@ export class Order {
@if (form.email().touched() && form.email().invalid()) {
<ul class="errors">
@for (error of form.email().errors(); track error) {
<li>{{ error.message }}</li>
}
@for (error of form.email().errors(); track error) {
<li>{{ error.message }}</li>
}
</ul>
} @if (form.email().pending()) {
<span>Validating...</span>
@@ -416,10 +428,9 @@ export class Order {
```html
<input
[formField]="form.email"
[class.is-invalid]="form.email().touched() && form.email().invalid()"
[class.is-valid]="form.email().touched() && form.email().valid()"
/>
[formField]="form.email"
[class.is-invalid]="form.email().touched() && form.email().invalid()"
[class.is-valid]="form.email().touched() && form.email().valid()" />
```
## Reset Form
@@ -14,40 +14,46 @@
For production applications requiring stability guarantees, use Reactive Forms:
```typescript
import { Component, inject } from "@angular/core";
import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms";
import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
@Component({
selector: "app-login",
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="email" />
@if (
form.controls.email.errors?.["required"] && form.controls.email.touched
) {
<span class="error">Email is required</span>
}
selector: 'app-login',
imports: [ReactiveFormsModule],
template: `
<form
[formGroup]="form"
(ngSubmit)="onSubmit()">
<input formControlName="email" />
@if (form.controls.email.errors?.['required'] && form.controls.email.touched) {
<span class="error">Email is required</span>
}
<input type="password" formControlName="password" />
<input
type="password"
formControlName="password" />
<button type="submit" [disabled]="form.invalid">Login</button>
</form>
`,
<button
type="submit"
[disabled]="form.invalid">
Login
</button>
</form>
`,
})
export class Login {
private fb = inject(FormBuilder);
private fb = inject(FormBuilder);
form = this.fb.group({
email: ["", [Validators.required, Validators.email]],
password: ["", [Validators.required, Validators.minLength(8)]],
});
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
onSubmit() {
if (this.form.valid) {
console.log(this.form.value);
onSubmit() {
if (this.form.valid) {
console.log(this.form.value);
}
}
}
}
```
@@ -56,37 +62,37 @@ export class Login {
### Typed FormControl
```typescript
import { FormControl } from "@angular/forms";
import { FormControl } from '@angular/forms';
// Inferred type: FormControl<string | null>
const name = new FormControl("");
const name = new FormControl('');
// Non-nullable (no reset to null)
const email = new FormControl("", { nonNullable: true });
const email = new FormControl('', { nonNullable: true });
// Type: FormControl<string>
// With validators
const username = new FormControl("", {
nonNullable: true,
validators: [Validators.required, Validators.minLength(3)],
const username = new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.minLength(3)],
});
```
### Typed FormGroup
```typescript
import { FormGroup, FormControl } from "@angular/forms";
import { FormGroup, FormControl } from '@angular/forms';
interface UserForm {
name: FormControl<string>;
email: FormControl<string>;
age: FormControl<number | null>;
name: FormControl<string>;
email: FormControl<string>;
age: FormControl<number | null>;
}
const form = new FormGroup<UserForm>({
name: new FormControl("", { nonNullable: true }),
email: new FormControl("", { nonNullable: true }),
age: new FormControl<number | null>(null),
name: new FormControl('', { nonNullable: true }),
email: new FormControl('', { nonNullable: true }),
age: new FormControl<number | null>(null),
});
// Typed value access
@@ -120,82 +126,104 @@ export class Profile {
```typescript
@Component({
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<input formControlName="name" placeholder="Name" />
imports: [ReactiveFormsModule],
template: `
<form
[formGroup]="form"
(ngSubmit)="onSubmit()">
<input
formControlName="name"
placeholder="Name" />
<div formGroupName="address">
<input formControlName="street" placeholder="Street" />
<input formControlName="city" placeholder="City" />
<input formControlName="zip" placeholder="ZIP" />
</div>
<div formGroupName="address">
<input
formControlName="street"
placeholder="Street" />
<input
formControlName="city"
placeholder="City" />
<input
formControlName="zip"
placeholder="ZIP" />
</div>
<button type="submit">Submit</button>
</form>
`,
<button type="submit">Submit</button>
</form>
`,
})
export class Profile {
private fb = inject(NonNullableFormBuilder);
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
name: ["", Validators.required],
address: this.fb.group({
street: [""],
city: ["", Validators.required],
zip: ["", [Validators.required, Validators.pattern(/^\d{5}$/)]],
}),
});
form = this.fb.group({
name: ['', Validators.required],
address: this.fb.group({
street: [''],
city: ['', Validators.required],
zip: ['', [Validators.required, Validators.pattern(/^\d{5}$/)]],
}),
});
}
```
## Dynamic Forms with FormArray
```typescript
import { FormArray } from "@angular/forms";
import { FormArray } from '@angular/forms';
@Component({
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form">
<div formArrayName="items">
@for (item of items.controls; track $index; let i = $index) {
<div [formGroupName]="i">
<input formControlName="product" placeholder="Product" />
<input formControlName="quantity" type="number" />
<button type="button" (click)="removeItem(i)">Remove</button>
</div>
}
</div>
<button type="button" (click)="addItem()">Add Item</button>
</form>
`,
imports: [ReactiveFormsModule],
template: `
<form [formGroup]="form">
<div formArrayName="items">
@for (item of items.controls; track $index; let i = $index) {
<div [formGroupName]="i">
<input
formControlName="product"
placeholder="Product" />
<input
formControlName="quantity"
type="number" />
<button
type="button"
(click)="removeItem(i)">
Remove
</button>
</div>
}
</div>
<button
type="button"
(click)="addItem()">
Add Item
</button>
</form>
`,
})
export class Order {
private fb = inject(NonNullableFormBuilder);
private fb = inject(NonNullableFormBuilder);
form = this.fb.group({
items: this.fb.array([this.createItem()]),
});
get items() {
return this.form.controls.items;
}
createItem() {
return this.fb.group({
product: ["", Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
form = this.fb.group({
items: this.fb.array([this.createItem()]),
});
}
addItem() {
this.items.push(this.createItem());
}
get items() {
return this.form.controls.items;
}
removeItem(index: number) {
this.items.removeAt(index);
}
createItem() {
return this.fb.group({
product: ['', Validators.required],
quantity: [1, [Validators.required, Validators.min(1)]],
});
}
addItem() {
this.items.push(this.createItem());
}
removeItem(index: number) {
this.items.removeAt(index);
}
}
```
@@ -222,20 +250,20 @@ name: ['', [Validators.required, forbiddenValue('admin')]],
```typescript
export function passwordMatch(): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const password = group.get("password")?.value;
const confirm = group.get("confirmPassword")?.value;
return password === confirm ? null : { passwordMismatch: true };
};
return (group: AbstractControl): ValidationErrors | null => {
const password = group.get('password')?.value;
const confirm = group.get('confirmPassword')?.value;
return password === confirm ? null : { passwordMismatch: true };
};
}
// Usage
form = this.fb.group(
{
password: ["", [Validators.required, Validators.minLength(8)]],
confirmPassword: ["", Validators.required],
},
{ validators: passwordMatch() },
{
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
},
{ validators: passwordMatch() },
);
```
@@ -276,12 +304,12 @@ form.touched; // Control has been focused
form.untouched; // Control never focused
// Update values
form.setValue({ name: "John", email: "john@example.com" }); // Must include all
form.patchValue({ name: "John" }); // Partial update
form.setValue({ name: 'John', email: 'john@example.com' }); // Must include all
form.patchValue({ name: 'John' }); // Partial update
// Reset
form.reset();
form.reset({ name: "Default" });
form.reset({ name: 'Default' });
// Disable/Enable
form.disable();
@@ -299,19 +327,17 @@ form.markAsDirty();
```typescript
// Subscribe to value changes
form.valueChanges.subscribe((value) => {
console.log("Form value:", value);
console.log('Form value:', value);
});
// Single control with debounce
form.controls.email.valueChanges
.pipe(debounceTime(300), distinctUntilChanged())
.subscribe((email) => {
form.controls.email.valueChanges.pipe(debounceTime(300), distinctUntilChanged()).subscribe((email) => {
this.validateEmail(email);
});
});
// Status changes
form.statusChanges.subscribe((status) => {
console.log("Form status:", status); // VALID, INVALID, PENDING
console.log('Form status:', status); // VALID, INVALID, PENDING
});
```
@@ -319,33 +345,33 @@ form.statusChanges.subscribe((status) => {
```typescript
import {
ValueChangeEvent,
StatusChangeEvent,
PristineChangeEvent,
TouchedChangeEvent,
FormSubmittedEvent,
FormResetEvent,
} from "@angular/forms";
ValueChangeEvent,
StatusChangeEvent,
PristineChangeEvent,
TouchedChangeEvent,
FormSubmittedEvent,
FormResetEvent,
} from '@angular/forms';
form.events.subscribe((event) => {
if (event instanceof ValueChangeEvent) {
console.log("Value changed:", event.value);
}
if (event instanceof StatusChangeEvent) {
console.log("Status changed:", event.status);
}
if (event instanceof PristineChangeEvent) {
console.log("Pristine changed:", event.pristine);
}
if (event instanceof TouchedChangeEvent) {
console.log("Touched changed:", event.touched);
}
if (event instanceof FormSubmittedEvent) {
console.log("Form submitted");
}
if (event instanceof FormResetEvent) {
console.log("Form reset");
}
if (event instanceof ValueChangeEvent) {
console.log('Value changed:', event.value);
}
if (event instanceof StatusChangeEvent) {
console.log('Status changed:', event.status);
}
if (event instanceof PristineChangeEvent) {
console.log('Pristine changed:', event.pristine);
}
if (event instanceof TouchedChangeEvent) {
console.log('Touched changed:', event.touched);
}
if (event instanceof FormSubmittedEvent) {
console.log('Form submitted');
}
if (event instanceof FormResetEvent) {
console.log('Form reset');
}
});
```
@@ -353,27 +379,27 @@ form.events.subscribe((event) => {
```typescript
@Component({
template: `
<input formControlName="email" />
template: `
<input formControlName="email" />
@if (form.controls.email.invalid && form.controls.email.touched) {
<div class="errors">
@if (form.controls.email.errors?.["required"]) {
<span>Email is required</span>
@if (form.controls.email.invalid && form.controls.email.touched) {
<div class="errors">
@if (form.controls.email.errors?.['required']) {
<span>Email is required</span>
}
@if (form.controls.email.errors?.['email']) {
<span>Invalid email format</span>
}
</div>
}
@if (form.controls.email.errors?.["email"]) {
<span>Invalid email format</span>
}
</div>
}
`,
`,
})
export class Form {
// Helper for cleaner templates
hasError(controlName: string, errorKey: string): boolean {
const control = this.form.get(controlName);
return (control?.hasError(errorKey) && control?.touched) || false;
}
// Helper for cleaner templates
hasError(controlName: string, errorKey: string): boolean {
const control = this.form.get(controlName);
return (control?.hasError(errorKey) && control?.touched) || false;
}
}
```
@@ -381,33 +407,37 @@ export class Form {
```typescript
@Component({
template: `
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<!-- fields -->
<button type="submit" [disabled]="form.invalid || isSubmitting">
{{ isSubmitting ? "Submitting..." : "Submit" }}
</button>
</form>
`,
template: `
<form
[formGroup]="form"
(ngSubmit)="onSubmit()">
<!-- fields -->
<button
type="submit"
[disabled]="form.invalid || isSubmitting">
{{ isSubmitting ? 'Submitting...' : 'Submit' }}
</button>
</form>
`,
})
export class Form {
isSubmitting = false;
isSubmitting = false;
async onSubmit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
async onSubmit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.isSubmitting = true;
try {
await this.api.submit(this.form.getRawValue());
this.form.reset();
} catch (error) {
// Handle error
} finally {
this.isSubmitting = false;
this.isSubmitting = true;
try {
await this.api.submit(this.form.getRawValue());
this.form.reset();
} catch (error) {
// Handle error
} finally {
this.isSubmitting = false;
}
}
}
}
```
@@ -8,100 +8,94 @@
```typescript
interface Rating {
rating: number;
rating: number;
}
import {
form,
FormField,
FormValueControl,
ValidationError,
WithOptionalField,
} from "@angular/forms/signals";
import { MatIconModule } from "@angular/material/icon";
import { MatError } from "@angular/material/form-field";
import { form, FormField, FormValueControl, ValidationError, WithOptionalField } from '@angular/forms/signals';
import { MatIconModule } from '@angular/material/icon';
import { MatError } from '@angular/material/form-field';
@Component({
selector: "app-rating",
imports: [MatIconModule, MatError],
template: `
<div class="star-rating-container">
@for (star of starArray(); track $index) {
<mat-icon
(click)="rate(star)"
class="star-icon"
[class.readonly]="readonly()"
[class.error]="invalid()"
[class]="{ filled: star <= value() }"
>
{{ getStarIcon(star) }}
</mat-icon>
}
@if (errors().at(0)?.message) {
<mat-error>
{{ errors().at(0)?.message }}
</mat-error>
}
</div>
`,
styles: ``,
selector: 'app-rating',
imports: [MatIconModule, MatError],
template: `
<div class="star-rating-container">
@for (star of starArray(); track $index) {
<mat-icon
class="star-icon"
[class.readonly]="readonly()"
[class.error]="invalid()"
[class]="{ filled: star <= value() }"
(click)="rate(star)">
{{ getStarIcon(star) }}
</mat-icon>
}
@if (errors().at(0)?.message) {
<mat-error>
{{ errors().at(0)?.message }}
</mat-error>
}
</div>
`,
styles: ``,
})
export class Rating implements FormValueControl<number> {
// Required: The value of the control, exposed as a two-way binding.
readonly value = model<number>(0);
// Optional: Bindings for other form control states.
readonly readonly = input<boolean>(false);
readonly invalid = input<boolean>(false);
readonly errors: InputSignal<readonly WithOptionalField<ValidationError>[]> =
input<readonly WithOptionalField<ValidationError>[]>([]);
// Required: The value of the control, exposed as a two-way binding.
readonly value = model<number>(0);
// Optional: Bindings for other form control states.
readonly readonly = input<boolean>(false);
readonly invalid = input<boolean>(false);
readonly errors: InputSignal<readonly WithOptionalField<ValidationError>[]> = input<readonly WithOptionalField<ValidationError>[]>([]);
starArray: Signal<number[]> = signal(
Array(5)
.fill(0)
.map((_, i) => i + 1),
);
starArray: Signal<number[]> = signal(
Array(5)
.fill(0)
.map((_, i) => i + 1),
);
getStarIcon(index: number): string {
const floorRating = Math.floor(this.value());
if (index <= floorRating) {
return "star"; // Full star
} else {
return "star_border"; // Empty star
getStarIcon(index: number): string {
const floorRating = Math.floor(this.value());
if (index <= floorRating) {
return 'star'; // Full star
} else {
return 'star_border'; // Empty star
}
}
}
rate(index: number): void {
if (!this.readonly()) {
this.value.set(index);
rate(index: number): void {
if (!this.readonly()) {
this.value.set(index);
}
}
}
}
import { FormField } from "@angular/forms/signals";
import { FormField } from '@angular/forms/signals';
@Component({
selector: "app-signal-forms",
imports: [FormField, Rating],
template: `
<form autocomplete="off" (submit)="submit($event)">
<div class="form-field">
<app-rating [formField]="ratingForm.rating"> </app-rating>
<!-- print to show the value updation -->
{{ ratingForm.rating().value() }}
</div>
</form>
`,
styles: ``,
selector: 'app-signal-forms',
imports: [FormField, Rating],
template: `
<form
autocomplete="off"
(submit)="submit($event)">
<div class="form-field">
<app-rating [formField]="ratingForm.rating"> </app-rating>
<!-- print to show the value updation -->
{{ ratingForm.rating().value() }}
</div>
</form>
`,
styles: ``,
})
export class SignalForms {
readonly ratingModel = signal<Rating>({
rating: 0,
});
readonly ratingModel = signal<Rating>({
rating: 0,
});
readonly ratingForm = form(this.ratingModel);
readonly ratingForm = form(this.ratingModel);
submit(event: Event): void {
event.preventDefault();
console.log(this.ratingForm.rating().value());
}
submit(event: Event): void {
event.preventDefault();
console.log(this.ratingForm.rating().value());
}
}
```