Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
283 changes: 133 additions & 150 deletions apps/forms/64-form-array/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,68 +6,43 @@ import {
WritableSignal,
} from '@angular/core';
import {
AbstractControl,
FormArray,
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { ContactFormComponent } from './contact-form.component';

type ContactFormGroup = FormGroup<{
firstname: FormControl<string>;
lastname: FormControl<string>;
relation: FormControl<string>;
email: FormControl<string>;
}>;

type EmailFormGroup = FormGroup<{
type: FormControl<string>;
email: FormControl<string>;
}>;
applyEach,
email,
form,
FormField,
FormRoot,
required,
SchemaPathTree,
validate,
} from '@angular/forms/signals';

type RegistrationForm = {
name: FormControl<string>;
pseudo: FormControl<string>;
contacts: FormArray<ContactFormGroup>;
emails: FormArray<EmailFormGroup>;
};

type RegistrationValue = {
name: string;
pseudo: string;
contacts: Array<{
firstname: string;
lastname: string;
relation: string;
email: string;
}>;
emails: Array<{
type: string;
email: string;
}>;
};
import { ContactFormComponent } from './contact-form.component';
import { Contact, Email, Registration } from './types';

export const minLengthArray = (min: number) => {
return (c: AbstractControl) => {
if (c.value.length >= min) return null;
function ContactSchema(item: SchemaPathTree<Contact>) {
required(item.firstName, { message: 'This field is required' });
required(item.lastname, { message: 'This field is required' });
required(item.relation, { message: 'This field is required' });
required(item.email, { message: 'Email is required' });
email(item.email, { message: 'Enter a valid email' });
}

return { MinLengthArray: true };
};
};
function EmailSchema(item: SchemaPathTree<Email>) {
required(item.type, { message: 'This field is required' });
required(item.email, { message: 'Email is required' });
email(item.email, { message: 'Enter a valid email' });
}

@Component({
selector: 'app-root',
imports: [ReactiveFormsModule, JsonPipe, ContactFormComponent],
imports: [JsonPipe, ContactFormComponent, FormField, FormRoot],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<main class="min-h-screen bg-slate-50 text-slate-900">
<div class="mx-auto max-w-5xl px-6 py-12">
<h1 class="mb-6 text-3xl font-semibold">Registration form</h1>
<form
[formGroup]="form"
(ngSubmit)="onSubmit()"
[formRoot]="registrationForm"
class="space-y-8 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<section class="space-y-4">
<h2 class="text-xl font-semibold">Profile</h2>
Expand All @@ -78,12 +53,14 @@ export const minLengthArray = (min: number) => {
<input
class="input"
type="text"
formControlName="name"
required
aria-required="true" />
[formField]="registrationForm.name" />
<span class="hint">
@if (showError(form.controls.name)) {
This field is required
@if (
registrationForm.name().invalid() &&
(registrationForm.name().touched() ||
registrationForm.name().dirty())
) {
{{ registrationForm.name().errors()[0].message }}
}
</span>
</label>
Expand All @@ -93,12 +70,14 @@ export const minLengthArray = (min: number) => {
<input
class="input"
type="text"
formControlName="pseudo"
required
aria-required="true" />
[formField]="registrationForm.pseudo" />
<span class="hint">
@if (showError(form.controls.pseudo)) {
This field is required
@if (
registrationForm.pseudo().invalid() &&
(registrationForm.pseudo().touched() ||
registrationForm.pseudo().dirty())
) {
{{ registrationForm.pseudo().errors()[0]?.message }}
}
</span>
</label>
Expand All @@ -116,17 +95,23 @@ export const minLengthArray = (min: number) => {
</button>
</div>

<div formArrayName="contacts" class="space-y-4">
@for (contact of contacts.controls; track $index) {
<div class="space-y-4">
@for (contact of registrationForm.contacts; track contact) {
<app-contact-form
[group]="contact"
[index]="$index"
(remove)="removeContact($index)"></app-contact-form>
}
</div>

@if (contacts.invalid && (contacts.touched || contacts.dirty)) {
<p class="hint">At least one contact is required.</p>
@if (
registrationForm.contacts().invalid() &&
(registrationForm.contacts().touched() ||
registrationForm.contacts().dirty())
) {
<p class="hint">
{{ registrationForm.contacts().errors()[0]?.message }}
</p>
}
</section>

Expand All @@ -138,8 +123,8 @@ export const minLengthArray = (min: number) => {
</button>
</div>

<div formArrayName="emails" class="space-y-4">
@for (email of emails.controls; track $index) {
<div class="space-y-4">
@for (emailField of registrationForm.emails; track emailField) {
<div
class="rounded-lg border border-slate-200 bg-slate-50/40 p-4"
data-testid="email-item">
Expand All @@ -156,39 +141,44 @@ export const minLengthArray = (min: number) => {
</button>
</div>

<div
class="mt-4 grid gap-4 sm:grid-cols-2"
[formGroupName]="$index">
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<label
class="flex flex-col gap-1 text-sm font-medium text-slate-700">
Type
<select class="input" formControlName="type">
<select class="input" [formField]="emailField.type">
<option value="personal">Personal</option>
<option value="professional">Professional</option>
<option value="other">Other</option>
</select>
<span class="hint">
@if (showError(email.controls.type)) {
This field is required
@if (
emailField.type().invalid() &&
(emailField.type().touched() ||
emailField.type().dirty())
) {
{{ emailField.type().errors()[0]?.message }}
}
</span>
</label>

<label
class="flex flex-col gap-1 text-sm font-medium text-slate-700">
Email
<input
class="input"
type="email"
formControlName="email"
required
aria-required="true" />
[formField]="emailField.email" />
<span class="hint">
@if (showError(email.controls.email)) {
@if (email.controls.email.hasError('required')) {
Email is required
}
@if (email.controls.email.hasError('email')) {
Enter a valid email
@if (
emailField.email().invalid() &&
(emailField.email().touched() ||
emailField.email().dirty())
) {
@for (
err of emailField.email().errors();
track err.kind
) {
{{ err.message }}
}
}
</span>
Expand All @@ -202,8 +192,12 @@ export const minLengthArray = (min: number) => {
<div
class="flex flex-wrap items-center justify-between gap-4 border-t border-slate-200 pt-4">
<div class="text-sm text-slate-600">
<span [class.text-rose-600]="form.invalid">
{{ form.invalid ? 'Form incomplete' : 'Ready to submit' }}
<span [class.text-rose-600]="registrationForm().invalid()">
{{
registrationForm().invalid()
? 'Form incomplete'
: 'Ready to submit'
}}
</span>
</div>
<button type="submit" class="btn-primary">Submit</button>
Expand Down Expand Up @@ -246,87 +240,76 @@ export const minLengthArray = (min: number) => {
],
})
export class AppComponent {
readonly contacts = new FormArray<ContactFormGroup>([], {
validators: [minLengthArray(1)],
readonly formModel = signal<Registration>({
name: '',
pseudo: '',
contacts: [],
emails: [],
});

readonly emails = new FormArray<EmailFormGroup>([]);
readonly registrationForm = form(
this.formModel,
(schemePath) => {
required(schemePath.name, { message: 'This field is required' });
required(schemePath.pseudo, { message: 'This field is required' });

readonly form = new FormGroup<RegistrationForm>({
name: new FormControl('', {
nonNullable: true,
validators: [Validators.required],
}),
pseudo: new FormControl('', {
nonNullable: true,
validators: [Validators.required],
}),
contacts: this.contacts,
emails: this.emails,
});
applyEach(schemePath.contacts, ContactSchema);
applyEach(schemePath.emails, EmailSchema);

validate(schemePath.contacts, ({ value }) => {
if (value().length >= 1) return null;

submittedData: WritableSignal<RegistrationValue | null> = signal(null);
return {
kind: 'MinLengthArray',
message: 'At least one contact is required.',
};
});
},
{
submission: {
action: async () => {
return this.submittedData.set(this.formModel());
},
},
},
);

submittedData: WritableSignal<Registration | null> = signal(null);

addContact(): void {
this.contacts.push(this.createContactGroup());
this.formModel.update((model) => ({
...model,
contacts: [
...model.contacts,
{ firstName: '', lastname: '', relation: '', email: '' },
],
}));
}

removeContact(index: number): void {
this.contacts.removeAt(index);
this.formModel.update((model) => ({
...model,
contacts: [
...model.contacts.slice(0, index),
...model.contacts.slice(index + 1),
],
}));
}

addEmail(): void {
this.emails.push(this.createEmailFormGroup());
this.formModel.update((model) => ({
...model,
emails: [...model.emails, { type: 'personal', email: '' }],
}));
}

removeEmail(index: number): void {
this.emails.removeAt(index);
}

onSubmit(): void {
this.form.markAllAsTouched();
if (this.form.invalid) {
return;
}

this.submittedData.set(this.form.getRawValue());
}

showError(control: FormControl<string>): boolean {
return control.invalid && (control.touched || control.dirty);
}

private createContactGroup(): ContactFormGroup {
return new FormGroup({
firstname: new FormControl('', {
nonNullable: true,
validators: [Validators.required],
}),
lastname: new FormControl('', {
nonNullable: true,
validators: [Validators.required],
}),
relation: new FormControl('', {
nonNullable: true,
validators: [Validators.required],
}),
email: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.email],
}),
});
}

private createEmailFormGroup(): EmailFormGroup {
return new FormGroup({
type: new FormControl('personal', {
nonNullable: true,
validators: [Validators.required],
}),
email: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.email],
}),
});
this.formModel.update((model) => ({
...model,
emails: [
...model.emails.slice(0, index),
...model.emails.slice(index + 1),
],
}));
}
}
Loading
Loading