From aa00d4b41c512280ccc77864495d1e82ac130b5f Mon Sep 17 00:00:00 2001 From: Romain Date: Mon, 30 Mar 2026 16:18:24 +0200 Subject: [PATCH 1/2] fix: craft ng --- .../64-form-array/src/app/app.component.ts | 380 +++++++++--------- .../src/app/contact-form.component.ts | 106 ++--- package.json | 3 +- pnpm-lock.yaml | 15 + 4 files changed, 277 insertions(+), 227 deletions(-) diff --git a/apps/forms/64-form-array/src/app/app.component.ts b/apps/forms/64-form-array/src/app/app.component.ts index f6133d3df..a7c10d955 100644 --- a/apps/forms/64-form-array/src/app/app.component.ts +++ b/apps/forms/64-form-array/src/app/app.component.ts @@ -1,104 +1,98 @@ import { JsonPipe } from '@angular/common'; +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { FormField } from '@angular/forms/signals'; import { - ChangeDetectionStrategy, - Component, - signal, - WritableSignal, -} from '@angular/core'; -import { - AbstractControl, - FormArray, - FormControl, - FormGroup, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; + cEmail, + cMinLength, + cRequired, + insertForm, + insertFormAttributes, + insertFormSubmit, + insertNoopTypingAnchor, + insertSelectFormTree, + mutation, + state, + ValidatedFormValue, +} from '@craft-ng/core'; import { ContactFormComponent } from './contact-form.component'; -type ContactFormGroup = FormGroup<{ - firstname: FormControl; - lastname: FormControl; - relation: FormControl; - email: FormControl; -}>; - -type EmailFormGroup = FormGroup<{ - type: FormControl; - email: FormControl; -}>; +type Contact = { + firstname: string; + lastname: string; + relation: string; + email: string; +}; -type RegistrationForm = { - name: FormControl; - pseudo: FormControl; - contacts: FormArray; - emails: FormArray; +type Email = { + type: string; + email: string; }; -type RegistrationValue = { +type Registration = { name: string; pseudo: string; - contacts: Array<{ - firstname: string; - lastname: string; - relation: string; - email: string; - }>; - emails: Array<{ - type: string; - email: string; - }>; + contacts: Contact[]; + emails: Email[]; }; -export const minLengthArray = (min: number) => { - return (c: AbstractControl) => { - if (c.value.length >= min) return null; - - return { MinLengthArray: true }; - }; -}; +// 😅 I may add some helpers +export type ContactField = ReturnType< + ReturnType< + ReturnType< + ReturnType< + InstanceType['registration']['form'] + >['selectContacts'] + > + >['items'] +>[number]; @Component({ selector: 'app-root', - imports: [ReactiveFormsModule, JsonPipe, ContactFormComponent], + imports: [ReactiveFormsModule, JsonPipe, ContactFormComponent, FormField], changeDetection: ChangeDetectionStrategy.OnPush, template: `

Registration form

+ @let form = registration.form();

Profile

+ @let nameField = form.selectName(); + @let pseudoField = form.selectPseudo(); @@ -106,40 +100,47 @@ export const minLengthArray = (min: number) => {
+ @let contacts = form.selectContacts();

Contacts

-
- @for (contact of contacts.controls; track $index) { +
+ @for (contact of contacts().items(); track $index) { + (remove)="contacts().remove($index)"> }
- - @if (contacts.invalid && (contacts.touched || contacts.dirty)) { -

At least one contact is required.

+ @for( exception of contacts().visibleExceptions().list; track exception.code) { + @let code = exception.code; + @switch(code) { + @case('minLength') { +

At least one contact is required.

+ } + @default never; + } }
+ @let emails = form.selectEmails();

Emails

-
-
- @for (email of emails.controls; track $index) { +
+ @for (email of emails().items(); track $index) {
@@ -151,47 +152,51 @@ export const minLengthArray = (min: number) => { type="button" class="btn-danger" aria-label="Remove email {{ $index + 1 }}" - (click)="removeEmail($index)"> + (click)="emails().remove($index)"> Remove
+ class="mt-4 grid gap-4 sm:grid-cols-2"> + @let relativeField = email().selectType(); + @let emailField = email().selectEmail();
@@ -202,21 +207,21 @@ export const minLengthArray = (min: number) => {
- - {{ form.invalid ? 'Form incomplete' : 'Ready to submit' }} + + {{ registration.form().invalid() ? 'Form incomplete' : 'Ready to submit' }}
- +
- @if (submittedData()) { + @if (save.safeValue()) {

Submitted data

{{ submittedData() | json }}
{{ save.safeValue() | json }}
} @@ -246,87 +251,104 @@ export const minLengthArray = (min: number) => { ], }) export class AppComponent { - readonly contacts = new FormArray([], { - validators: [minLengthArray(1)], + protected readonly save = mutation({ + method: (registration: ValidatedFormValue) => registration, + loader: async ({ params: registration }) => registration, }); - readonly emails = new FormArray([]); - - readonly form = new FormGroup({ - name: new FormControl('', { - nonNullable: true, - validators: [Validators.required], - }), - pseudo: new FormControl('', { - nonNullable: true, - validators: [Validators.required], - }), - contacts: this.contacts, - emails: this.emails, - }); - - submittedData: WritableSignal = signal(null); - - addContact(): void { - this.contacts.push(this.createContactGroup()); - } - - removeContact(index: number): void { - this.contacts.removeAt(index); - } - - addEmail(): void { - this.emails.push(this.createEmailFormGroup()); - } - - 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): 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], - }), - }); - } + readonly registration = state( + { + name: '', + pseudo: '', + contacts: [], + emails: [], + } satisfies Registration as Registration, + insertForm( + insertFormSubmit(this.save), + insertSelectFormTree( + 'name', + insertNoopTypingAnchor, + insertFormAttributes(() => ({ validators: [cRequired()] })), + ), + insertSelectFormTree( + 'pseudo', + insertNoopTypingAnchor, + insertFormAttributes(() => ({ validators: [cRequired()] })), + ), + insertSelectFormTree( + 'contacts', + insertNoopTypingAnchor, + ({ update }) => ({ + add: () => + update((contacts) => [ + ...contacts, + { firstname: '', lastname: '', relation: '', email: '' }, + ]), + remove: (index: number) => + update((contacts) => contacts.filter((_, i) => i !== index)), + }), + insertFormAttributes(() => ({ + validators: [cMinLength({ minLength: 1 })], + })), + insertSelectFormTree( + 'contact', + insertSelectFormTree( + 'firstname', + insertNoopTypingAnchor, + insertFormAttributes(() => ({ + validators: [cRequired()], + })), + ), + insertSelectFormTree( + 'lastname', + insertNoopTypingAnchor, + insertFormAttributes(() => ({ + validators: [cRequired()], + })), + ), + insertSelectFormTree( + 'email', + insertNoopTypingAnchor, + insertFormAttributes(() => ({ + validators: [cRequired(), cEmail()], + })), + ), + insertSelectFormTree( + 'relation', + insertNoopTypingAnchor, + insertFormAttributes(() => ({ + validators: [cRequired()], + })), + ), + ), + ), + insertSelectFormTree( + 'emails', + ({ update }) => ({ + add: () => + update((emails) => [...emails, { email: '', type: 'personal' }]), + remove: (index: number) => + update((contacts) => contacts.filter((_, i) => i !== index)), + }), + insertSelectFormTree( + 'email', + insertNoopTypingAnchor, + insertSelectFormTree( + 'email', + insertNoopTypingAnchor, + insertFormAttributes(() => ({ + validators: [cRequired(), cEmail()], + })), + ), + insertSelectFormTree( + 'type', + insertNoopTypingAnchor, + insertFormAttributes(() => ({ + validators: [cRequired()], + })), + ), + ), + ), + ), + ); } diff --git a/apps/forms/64-form-array/src/app/contact-form.component.ts b/apps/forms/64-form-array/src/app/contact-form.component.ts index eaf27cd02..1116696c1 100644 --- a/apps/forms/64-form-array/src/app/contact-form.component.ts +++ b/apps/forms/64-form-array/src/app/contact-form.component.ts @@ -1,21 +1,13 @@ import { Component, input, output } from '@angular/core'; -import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; - -type ContactFormGroup = FormGroup<{ - firstname: FormControl; - lastname: FormControl; - relation: FormControl; - email: FormControl; -}>; - +import { FormField } from '@angular/forms/signals'; +import { ContactField } from './app.component'; @Component({ selector: 'app-contact-form', - imports: [ReactiveFormsModule], + imports: [FormField], template: `
+ data-testid="contact-item">

Contact {{ index() + 1 }} @@ -30,63 +22,87 @@ type ContactFormGroup = FormGroup<{

+ @let firstNameField = this.field()().selectFirstname(); + @let lastNameField = this.field()().selectLastname(); + @let relationField = this.field()().selectRelation(); + @let emailField = this.field()().selectEmail();