diff --git a/apps/forms/65-signal-form-edition/src/app/user-form.component.ts b/apps/forms/65-signal-form-edition/src/app/user-form.component.ts index e9a6f91cb..b9db7aa3d 100644 --- a/apps/forms/65-signal-form-edition/src/app/user-form.component.ts +++ b/apps/forms/65-signal-form-edition/src/app/user-form.component.ts @@ -1,37 +1,50 @@ import { ChangeDetectionStrategy, Component, - effect, + computed, inject, input, } from '@angular/core'; -import { rxResource } from '@angular/core/rxjs-interop'; -import { - FormControl, - FormGroup, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; +import { FormField } from '@angular/forms/signals'; import { Router } from '@angular/router'; -import { of } from 'rxjs'; +import { + afterRecomputation, + cMin, + cRequired, + injectService, + insertForm, + insertFormAttributes, + insertFormSubmit, + insertNoopTypingAnchor, + insertSelectFormTree, + mutation, + query, + state, + toSource, + ValidatedFormValue, +} from '@craft-ng/core'; +import { firstValueFrom } from 'rxjs'; import { FakeBackendService } from './fake-backend.service'; +import { User } from './user.model'; @Component({ selector: 'app-user-form', - imports: [ReactiveFormsModule], + imports: [FormField], template: `

{{ id() ? 'Edit User' : 'Add New User' }}

- @if (id() && userResource.isLoading()) { + @if (id() && user.isLoading()) {
} @else { -
+ @let userForm = user.form(); +
+ @let firstNameField = userForm.selectFirstname();
+ @let lastNameField = userForm.selectLastname();
+ @let ageField = userForm.selectAge(); - @if (userForm.get('age')?.invalid && userForm.get('age')?.touched) { -

Age must be positive

+ [formField]="ageField" + class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none sm:text-sm" /> + @for (exception of ageField().visibleExceptions().list; track exception.code) { + @let code = exception.code; + @switch (code) { + @case ('required') { +

Age is required

+ } + @case ('min') { +

Age must be positive

+ } + @default never; + } }
+ @let gradeField = userForm.selectGrade(); + [formField]="gradeField" + class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 focus:outline-none sm:text-sm" /> + @for (exception of gradeField().visibleExceptions().list; track exception.code) { + @let code = exception.code; + @switch (code) { + @case ('required') { +

Grade is required

+ } + @default never; + } + }
@@ -111,66 +150,75 @@ import { FakeBackendService } from './fake-backend.service'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class UserFormComponent { - private backend = inject(FakeBackendService); - private router = inject(Router); + private readonly backend = inject(FakeBackendService); - id = input(); + public readonly id = input(); - userResource = rxResource({ - params: () => ({ id: this.id() }), - stream: ({ params: { id } }) => { - return id ? this.backend.getUser(Number(id)) : of(undefined); + private readonly userQuery = query({ + params: this.id, + loader: ({ params: id }) => { + return firstValueFrom(this.backend.getUser(Number(id))); }, - defaultValue: undefined, }); - userForm = new FormGroup({ - firstname: new FormControl('', { - nonNullable: true, - validators: [Validators.required], - }), - lastname: new FormControl('', { - nonNullable: true, - validators: [Validators.required], - }), - age: new FormControl(0, { - nonNullable: true, - validators: [Validators.required, Validators.min(0)], - }), - grade: new FormControl(0, { - nonNullable: true, - validators: [Validators.required], - }), + private readonly save = mutation({ + method: (validUser: ValidatedFormValue>) => + validUser, + loader: ({ params: user }) => + firstValueFrom( + 'id' in user + ? this.backend.updateUser(user) + : this.backend.addUser(user), + ), }); - constructor() { - effect(() => { - const userValue = this.userResource.value(); - if (userValue) { - this.userForm.patchValue(userValue); - } else { - this.userForm.reset({ firstname: '', lastname: '', age: 0, grade: 0 }); - } - }); - } - - onSubmit(): void { - if (this.userForm.valid) { - const userValue = this.userResource.value(); - const obs = userValue - ? this.backend.updateUser({ - ...this.userForm.getRawValue(), - id: userValue.id, - }) - : this.backend.addUser(this.userForm.getRawValue()); - - obs.subscribe(() => { - this.router.navigate(['/']); - }); - } - } + protected readonly user = state( + computed>( + () => + this.userQuery.safeValue() ?? { + firstname: '', + lastname: '', + age: 0, + grade: 0, + }, + ), + insertForm( + insertFormSubmit(this.save), + insertSelectFormTree( + 'firstname', + insertNoopTypingAnchor, // avoid typing issue + insertFormAttributes(() => ({ validators: [cRequired()] })), + ), + insertSelectFormTree( + 'lastname', + insertNoopTypingAnchor, // avoid typing issue + insertFormAttributes(() => ({ validators: [cRequired()] })), + ), + insertSelectFormTree( + 'age', + insertNoopTypingAnchor, // avoid typing issue + insertFormAttributes(() => ({ + validators: [cRequired(), cMin({ min: 0 })], + })), + ), + insertSelectFormTree( + 'grade', + insertNoopTypingAnchor, // avoid typing issue + insertFormAttributes(() => ({ validators: [cRequired()] })), + ), + ), + () => ({ + isLoading: this.userQuery.isLoading, + }), + ); - onCancel(): void { - this.router.navigate(['/']); - } + protected readonly router = injectService(Router, ({ navigate }) => ({ + cancel: () => navigate(['/']), + backOnSaveResolved: afterRecomputation( + toSource(this.save.status, { + computed: (status) => status === 'resolved', + }), + (isSaveResolved) => isSaveResolved && navigate(['/']), + ), + })); } diff --git a/package.json b/package.json index 7357e10d6..0595b050a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@angular/platform-server": "21.2.2", "@angular/router": "21.2.2", "@angular/ssr": "21.2.1", + "@craft-ng/core": "^0.1.2", "@ngneat/falso": "7.2.0", "@ngrx/component-store": "21.0.0", "@ngrx/operators": "21.0.0", @@ -76,6 +77,7 @@ "@swc/cli": "0.7.10", "@swc/core": "1.15.8", "@tailwindcss/forms": "^0.5.10", + "@tailwindcss/postcss": "4.2.1", "@testing-library/angular": "19.0.0", "@testing-library/cypress": "10.1.0", "@testing-library/jest-dom": "6.9.1", @@ -108,7 +110,6 @@ "nx": "22.5.4", "playwright": "1.58.2", "postcss": "^8.4.5", - "@tailwindcss/postcss": "4.2.1", "postcss-import": "~14.1.0", "postcss-preset-env": "~7.5.0", "postcss-url": "~10.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4062e68f..b0f63f4bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@angular/ssr': specifier: 21.2.1 version: 21.2.1(cc9abf0163f8bd211eea24f852c0fe92) + '@craft-ng/core': + specifier: ^0.1.2 + version: 0.1.2(@angular/common@21.2.2(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1))(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0)) '@ngneat/falso': specifier: 7.2.0 version: 7.2.0 @@ -1708,6 +1711,12 @@ packages: resolution: {integrity: sha512-PXDQXkAmiMEG162Bqdh9ChML/GJZo6vU+7F03ALKDK8zYc6SuAr47LjG7hGYRqUOz+WK0dU7bQ0xzuqFMdxzeQ==} engines: {node: '>=v14'} + '@craft-ng/core@0.1.2': + resolution: {integrity: sha512-OIlILj+YK7RUUn88yt0FGA2gqPiovtub4EJqnNVAwGjRE+Qy0HX9I33obx5x0CVtWgILD9SL+Nqmue9TStSHrw==} + peerDependencies: + '@angular/common': ^21.0.0 + '@angular/core': ^21.0.0 + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -13264,6 +13273,12 @@ snapshots: dependencies: chalk: 4.1.2 + '@craft-ng/core@0.1.2(@angular/common@21.2.2(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1))(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))': + dependencies: + '@angular/common': 21.2.2(@angular/core@21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0))(rxjs@7.8.1) + '@angular/core': 21.2.2(@angular/compiler@21.2.2)(rxjs@7.8.1)(zone.js@0.16.0) + tslib: 2.8.1 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9