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