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
230 changes: 139 additions & 91 deletions apps/forms/65-signal-form-edition/src/app/user-form.component.ts
Original file line number Diff line number Diff line change
@@ -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: `
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-xl font-semibold text-gray-800">
{{ id() ? 'Edit User' : 'Add New User' }}
</h2>
@if (id() && userResource.isLoading()) {
@if (id() && user.isLoading()) {
<div class="flex h-32 items-center justify-center">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-indigo-500 border-t-transparent"></div>
</div>
} @else {
<form [formGroup]="userForm" (ngSubmit)="onSubmit()" class="space-y-4">
@let userForm = user.form();
<form class="space-y-4">
<div>
@let firstNameField = userForm.selectFirstname();
<label
for="firstname"
class="block text-sm font-medium text-gray-700">
Expand All @@ -40,16 +53,20 @@ import { FakeBackendService } from './fake-backend.service';
<input
id="firstname"
type="text"
formControlName="firstname"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm" />
@if (
userForm.get('firstname')?.invalid &&
userForm.get('firstname')?.touched
) {
<p class="mt-1 text-xs text-red-500">Firstname is required</p>
[formField]="firstNameField"
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 firstNameField().visibleExceptions().list; track exception.code) {
@switch (exception.code) {
@case ('required') {
<p class="mt-1 text-xs text-red-500">Firstname is required</p>
}
@default never;
}
}

</div>
<div>
@let lastNameField = userForm.selectLastname();
<label
for="lastname"
class="block text-sm font-medium text-gray-700">
Expand All @@ -58,49 +75,71 @@ import { FakeBackendService } from './fake-backend.service';
<input
id="lastname"
type="text"
formControlName="lastname"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm" />
@if (
userForm.get('lastname')?.invalid &&
userForm.get('lastname')?.touched
) {
<p class="mt-1 text-xs text-red-500">Lastname is required</p>
[formField]="lastNameField"
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 lastNameField().visibleExceptions().list; track exception.code) {
@switch (exception.code) {
@case ('required') {
<p class="mt-1 text-xs text-red-500">Lastname is required</p>
}
@default never;
}
}
</div>
<div>
@let ageField = userForm.selectAge();
<label for="age" class="block text-sm font-medium text-gray-700">
Age
</label>
<input
id="age"
type="number"
formControlName="age"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm" />
@if (userForm.get('age')?.invalid && userForm.get('age')?.touched) {
<p class="mt-1 text-xs text-red-500">Age must be positive</p>
[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') {
<p class="mt-1 text-xs text-red-500">Age is required</p>
}
@case ('min') {
<p class="mt-1 text-xs text-red-500">Age must be positive</p>
}
@default never;
}
}
</div>
<div>
@let gradeField = userForm.selectGrade();
<label for="grade" class="block text-sm font-medium text-gray-700">
Grade
</label>
<input
id="grade"
type="number"
formControlName="grade"
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm" />
[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') {
<p class="mt-1 text-xs text-red-500">Grade is required</p>
}
@default never;
}
}
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
(click)="onCancel()"
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
(click)="router.cancel()"
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none">
Cancel
</button>
<button
type="submit"
[disabled]="userForm.invalid"
class="rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50">
type="button"
(click)="userForm.submit()"
class="rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none disabled:opacity-50">
{{ id() ? 'Update' : 'Add' }}
</button>
</div>
Expand All @@ -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<string>();
public readonly id = input<string>();

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<User | Omit<User, 'id'>>) =>
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<Omit<User, 'id'>>(
() =>
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(['/']),
),
}));
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading