Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ If you would like to propose a challenge, this project is open source, so feel f

## Challenges

Check [all 64 challenges](https://angular-challenges.vercel.app/)
Check [all 65 challenges](https://angular-challenges.vercel.app/)

## Contributors ✨

Expand Down
19 changes: 19 additions & 0 deletions apps/forms/65-signal-form-edition/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# signal-form-edition

> author: thomas-laforge

### Run Application

```bash
npx nx serve forms-signal-form-edition
```

### Run Tests

```bash
npx nx test forms-signal-form-edition
```

### Documentation and Instruction

Challenge documentation is [here](https://angular-challenges.vercel.app/challenges/forms/65-signal-form-edition/).
34 changes: 34 additions & 0 deletions apps/forms/65-signal-form-edition/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.config.mjs';

export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'app',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'app',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];
75 changes: 75 additions & 0 deletions apps/forms/65-signal-form-edition/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{
"name": "forms-signal-form-edition",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/forms/65-signal-form-edition/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular/build:application",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/forms/65-signal-form-edition",
"browser": "apps/forms/65-signal-form-edition/src/main.ts",
"tsConfig": "apps/forms/65-signal-form-edition/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "apps/forms/65-signal-form-edition/public"
}
],
"styles": ["apps/forms/65-signal-form-edition/src/styles.scss"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"continuous": true,
"executor": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "forms-signal-form-edition:build:production"
},
"development": {
"buildTarget": "forms-signal-form-edition:build:development"
}
},
"defaultConfiguration": "development"
},
"lint": {
"executor": "@nx/eslint:lint"
},
"serve-static": {
"continuous": true,
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "forms-signal-form-edition:build",
"staticFilePath": "dist/apps/forms/65-signal-form-edition/browser",
"spa": true
}
}
}
}
Binary file not shown.
96 changes: 96 additions & 0 deletions apps/forms/65-signal-form-edition/src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { page } from 'vitest/browser';
import { AppComponent } from './app.component';
import { appConfig } from './app.config';

describe('AppComponent', () => {
beforeEach(async () => {
TestBed.configureTestingModule({
providers: appConfig.providers,
});
const router = TestBed.inject(Router);
router.initialNavigation();
TestBed.createComponent(AppComponent);
});

describe('When component is rendered', () => {
it('Then should display the portal title', async () => {
const heading = page.getByRole('heading', {
name: /user management portal/i,
});
await expect.element(heading).toBeInTheDocument();
});

it('Then should display correct information in the user list', async () => {
await expect
.element(page.getByText('Max Mustermann'))
.toBeInTheDocument();
await expect.element(page.getByText('John Doe')).toBeInTheDocument();
await expect.element(page.getByText('Jane Smith')).toBeInTheDocument();
});
});

describe('Given a user wants to add a new user', () => {
it('Then should navigate to add form and create user', async () => {
const addButton = page.getByRole('button', { name: /add user/i }).first();
await addButton.click();

await expect
.element(page.getByRole('heading', { name: /add new user/i }))
.toBeInTheDocument();

await page.getByLabelText(/firstname/i).fill('Antigravity');
await page.getByLabelText(/lastname/i).fill('AI');
await page.getByLabelText(/age/i).fill('1');
await page.getByLabelText(/grade/i).fill('10');

await page.getByRole('button', { name: /add/i }).click();

await expect
.element(page.getByText('Antigravity AI'))
.toBeInTheDocument();
});
});

describe('Given a user wants to edit an existing user', () => {
it('Then should update the user successfully', async () => {
await expect.element(page.getByText('Jane Smith')).toBeInTheDocument();

const editButtons = await page
.getByRole('button', { name: /edit/i })
.all();
// Jane Smith is the 3rd user in list (id 3)
await editButtons[2].click();

await expect
.element(page.getByRole('heading', { name: /edit user/i }))
.toBeInTheDocument();
await expect
.element(page.getByLabelText(/firstname/i))
.toHaveValue('Jane');

await page.getByLabelText(/firstname/i).fill('Janet');
await page.getByRole('button', { name: /update/i }).click();

await expect.element(page.getByText('Janet Smith')).toBeInTheDocument();
await expect
.element(page.getByText('Jane Smith'))
.not.toBeInTheDocument();
});
});

describe('Given a user wants to delete a user', () => {
it('Then should remove the user from the list', async () => {
await expect.element(page.getByText('John Doe')).toBeInTheDocument();

const deleteButtons = await page
.getByRole('button', { name: /delete/i })
.all();
// John Doe is the 2nd user in list
await deleteButtons[1].click();

await expect.element(page.getByText('John Doe')).not.toBeInTheDocument();
});
});
});
22 changes: 22 additions & 0 deletions apps/forms/65-signal-form-edition/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
imports: [RouterOutlet],
selector: 'app-root',
template: `
<div class="min-h-screen bg-gray-50 px-4 py-8 sm:px-6 lg:px-8">
<div class="mx-auto max-w-4xl">
<header class="mb-8 font-serif">
<h1 class="text-3xl font-bold leading-tight text-gray-900">
User Management Portal
</h1>
</header>

<router-outlet />
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent {}
23 changes: 23 additions & 0 deletions apps/forms/65-signal-form-edition/src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { HomeComponent } from './home.component';
import { UserFormComponent } from './user-form.component';

export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(
[
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{ path: 'home', component: HomeComponent },
{ path: 'add', component: UserFormComponent },
{ path: 'edit/:id', component: UserFormComponent },
{ path: '**', redirectTo: 'home' },
],
withComponentInputBinding(),
),
],
};
44 changes: 44 additions & 0 deletions apps/forms/65-signal-form-edition/src/app/fake-backend.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Injectable } from '@angular/core';
import { delay, Observable, of } from 'rxjs';
import { User } from './user.model';

@Injectable({
providedIn: 'root',
})
export class FakeBackendService {
private users: User[] = [
{ id: 1, firstname: 'Max', lastname: 'Mustermann', age: 30, grade: 10 },
{ id: 2, firstname: 'John', lastname: 'Doe', age: 25, grade: 8 },
{ id: 3, firstname: 'Jane', lastname: 'Smith', age: 28, grade: 9 },
];

getUsers(): Observable<User[]> {
return of([...this.users]).pipe(delay(500));
}

getUser(id: number): Observable<User | undefined> {
return of(this.users.find((u) => u.id === id)).pipe(delay(500));
}

addUser(user: Omit<User, 'id'>): Observable<User> {
const newUser = {
...user,
id: Math.max(...this.users.map((u) => u.id), 0) + 1,
};
this.users.push(newUser);
return of(newUser).pipe(delay(500));
}

updateUser(user: User): Observable<User> {
const index = this.users.findIndex((u) => u.id === user.id);
if (index !== -1) {
this.users[index] = user;
}
return of(user).pipe(delay(500));
}

deleteUser(id: number): Observable<void> {
this.users = this.users.filter((u) => u.id !== id);
return of(undefined).pipe(delay(500));
}
}
52 changes: 52 additions & 0 deletions apps/forms/65-signal-form-edition/src/app/home.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { rxResource } from '@angular/core/rxjs-interop';
import { Router } from '@angular/router';
import { FakeBackendService } from './fake-backend.service';
import { UserListComponent } from './user-list.component';
import { User } from './user.model';

@Component({
selector: 'app-home',
imports: [UserListComponent],
template: `
<div class="space-y-6">
@if (usersResource.isLoading()) {
<div
class="flex h-64 items-center justify-center rounded-lg border border-gray-200 bg-white shadow-sm">
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-indigo-500 border-t-transparent"></div>
</div>
} @else {
<app-user-list
[users]="usersResource.value()"
(add)="onAdd()"
(edit)="onEdit($event)"
(delete)="onDelete($event)" />
}
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HomeComponent {
private backend = inject(FakeBackendService);
private router = inject(Router);

usersResource = rxResource({
stream: () => this.backend.getUsers(),
defaultValue: [],
});

onAdd(): void {
this.router.navigate(['/add']);
}

onEdit(user: User): void {
this.router.navigate(['/edit', user.id]);
}

onDelete(id: number): void {
this.backend.deleteUser(id).subscribe(() => {
this.usersResource.reload();
});
}
}
Loading
Loading