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
continuous-integration/drone/pr Build is passing
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user