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
@@ -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());
}
}
```