diff --git a/apps/forms/64-form-array/eslint.config.mjs b/apps/forms/64-form-array/eslint.config.mjs index 0e557f6b7..f24f72364 100644 --- a/apps/forms/64-form-array/eslint.config.mjs +++ b/apps/forms/64-form-array/eslint.config.mjs @@ -1,5 +1,6 @@ import nx from '@nx/eslint-plugin'; import baseConfig from '../../../eslint.config.mjs'; +const craftRules = require('@craft-ng/dev-tools/eslint-rules'); export default [ ...baseConfig, @@ -7,6 +8,9 @@ export default [ ...nx.configs['flat/angular-template'], { files: ['**/*.ts'], + plugins: { + 'craft-ng': craftRules, + }, rules: { '@angular-eslint/directive-selector': [ 'error', @@ -24,6 +28,13 @@ export default [ style: 'kebab-case', }, ], + 'craft-ng/brand-angular-gen-deps-required': 'error', + 'craft-ng/no-angular-inject': 'error', + 'craft-ng/prefer-craft-http-client': 'error', + 'craft-ng/prefer-craft-service': 'error', + 'craft-ng/prefer-browser-boundaries': 'error', + 'craft-ng/app-start-registry-match': 'error', + 'craft-ng/brand-angular-deps-match': 'error', }, }, { 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..05bb686ed 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 { - ChangeDetectionStrategy, - Component, - signal, - WritableSignal, -} from '@angular/core'; -import { - AbstractControl, - FormArray, - FormControl, - FormGroup, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; + cEmail, + cMinLength, + CraftFieldDirective, + cRequired, + insertForm, + insertFormAttributes, + insertFormSubmit, + insertNoopTypingAnchor, + insertSelectFormTree, + mutation, + state, + ValidatedFormValue, + type TargetFormField, +} 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 }; - }; -}; +export type ContactFormTree = TargetFormField< + InstanceType['registration']['form'], + 'selectContacts.selectContact' +>; @Component({ selector: 'app-root', - imports: [ReactiveFormsModule, JsonPipe, ContactFormComponent], + imports: [ + ReactiveFormsModule, + JsonPipe, + ContactFormComponent, + CraftFieldDirective, + ], 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.hasValue()) {

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..92cb4203a 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 { CraftFieldDirective } from '@craft-ng/core'; +import { ContactFormTree } from './app.component'; @Component({ selector: 'app-contact-form', - imports: [ReactiveFormsModule], + imports: [CraftFieldDirective], 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();