diff --git a/.editorconfig b/.editorconfig index 59d9a3a3..f166060d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,6 +10,7 @@ trim_trailing_whitespace = true [*.ts] quote_type = single +ij_typescript_use_double_quotes = false [*.md] max_line_length = off diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..34c3b360 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,56 @@ + +You are an expert in TypeScript, Angular, and scalable web application development. You write functional, maintainable, performant, and accessible code following Angular and TypeScript best practices. + +## TypeScript Best Practices + +- Use strict type checking +- Prefer type inference when the type is obvious +- Avoid the `any` type; use `unknown` when type is uncertain + +## Angular Best Practices + +- Always use standalone components over NgModules +- Must NOT set `standalone: true` inside Angular decorators. It's the default in Angular v20+. +- Use signals for state management +- Implement lazy loading for feature routes +- Do NOT use the `@HostBinding` and `@HostListener` decorators. Put host bindings inside the `host` object of the `@Component` or `@Directive` decorator instead +- Use `NgOptimizedImage` for all static images. + - `NgOptimizedImage` does not work for inline base64 images. + +## Accessibility Requirements + +- It MUST pass all AXE checks. +- It MUST follow all WCAG AA minimums, including focus management, color contrast, and ARIA attributes. + +### Components + +- Keep components small and focused on a single responsibility +- Use `input()` and `output()` functions instead of decorators +- Use `computed()` for derived state +- Set `changeDetection: ChangeDetectionStrategy.OnPush` in `@Component` decorator +- Prefer inline templates for small components +- Prefer Reactive forms instead of Template-driven ones +- Do NOT use `ngClass`, use `class` bindings instead +- Do NOT use `ngStyle`, use `style` bindings instead +- When using external templates/styles, use paths relative to the component TS file. + +## State Management + +- Use signals for local component state +- Use `computed()` for derived state +- Keep state transformations pure and predictable +- Do NOT use `mutate` on signals, use `update` or `set` instead + +## Templates + +- Keep templates simple and avoid complex logic +- Use native control flow (`@if`, `@for`, `@switch`) instead of `*ngIf`, `*ngFor`, `*ngSwitch` +- Use the async pipe to handle observables +- Do not assume globals like (`new Date()`) are available. +- Do not write arrow functions in templates (they are not supported). + +## Services + +- Design services around a single responsibility +- Use the `providedIn: 'root'` option for singleton services +- Use the `inject()` function instead of constructor injection diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 20e77ca9..b744da89 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -22,5 +22,5 @@ jobs: node-version: '24' cache: 'npm' - run: npm ci - - run: npm run test -- --watch=false --progress=false --browsers=ChromeHeadless + - run: npm run test -- --watch=false - run: npm run test:be \ No newline at end of file diff --git a/.gitignore b/.gitignore index c05f21da..31688422 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ yarn-error.log !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json +!.vscode/mcp.json .history/* # Miscellaneous @@ -36,6 +37,7 @@ yarn-error.log /libpeerconnection.log testem.log /typings +__screenshots__/ # System files .DS_Store diff --git a/README.md b/README.md index 38dbcf41..988d7d4c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Angular Full Stack is a project to easly get started with the latest Angular usi This project uses the [MEAN stack](https://en.wikipedia.org/wiki/MEAN_(software_bundle)): * [**M**ongoose.js](http://www.mongoosejs.com) ([MongoDB](https://www.mongodb.com)): database * [**E**xpress.js](http://expressjs.com): backend framework -* [**A**ngular 2+](https://angular.io): frontend framework +* [**A**ngular](https://angular.io): frontend framework * [**N**ode.js](https://nodejs.org): runtime environment Other tools and technologies used: @@ -15,7 +15,7 @@ Other tools and technologies used: * [Bootstrap](http://www.getbootstrap.com): layout and styles * [Font Awesome](http://fontawesome.com): icons * [JSON Web Token](https://jwt.io): user authentication -* [Angular 2 JWT](https://github.com/auth0/angular2-jwt): JWT helper for Angular 2+ +* [Angular 2 JWT](https://github.com/auth0/angular2-jwt): JWT helper for Angular * [Bcrypt.js](https://github.com/dcodeIO/bcrypt.js): password encryption ## Prerequisites @@ -55,14 +55,14 @@ A window will automatically open at [localhost:4200](http://localhost:4200). Ang 10. Tip: use [pm2](https://pm2.keymetrics.io/) to run the app instead of `npm start`, eg: `pm2 start dist/server/app.js` ## Preview -![Preview](https://raw.githubusercontent.com/DavideViolante/Angular2-Full-Stack/master/demo.gif "Preview") +![Preview](https://raw.githubusercontent.com/DavideViolante/Angular-Full-Stack/master/demo.gif "Preview") ## Please open an issue if * you have any suggestion to improve this project * you noticed any problem or error ## Running tests -Run `ng test` to execute the frontend unit tests via [Karma](https://karma-runner.github.io). +Run `ng test` to execute the frontend unit tests via [Vitest](https://vitest.dev/). Run `npm run test:be` to execute the backend tests via [Jest](https://jestjs.io/) (it requires `mongod` already running). diff --git a/angular.json b/angular.json index 60ba86d2..0087a038 100644 --- a/angular.json +++ b/angular.json @@ -3,7 +3,7 @@ "version": 1, "newProjectRoot": "projects", "projects": { - "angular2-full-stack": { + "angular-full-stack": { "projectType": "application", "schematics": { "@schematics/angular:component": { @@ -18,14 +18,14 @@ "builder": "@angular/build:application", "options": { "outputPath": "dist/public", - "index": "client/index.html", "browser": "client/main.ts", - "polyfills": [ - "zone.js" - ], "tsConfig": "tsconfig.app.json", "inlineStyleLanguage": "scss", "assets": [ + { + "glob": "**/*", + "input": "public" + }, "client/assets/favicon.ico", "client/assets" ], @@ -35,7 +35,12 @@ ], "scripts": [ "node_modules/bootstrap/dist/js/bootstrap.bundle.min.js" - ] + ], + "stylePreprocessorOptions": { + "sass": { + "silenceDeprecations": ["color-functions", "global-builtin", "import", "if-function"] + } + } }, "configurations": { "production": { @@ -65,11 +70,11 @@ "builder": "@angular/build:dev-server", "configurations": { "production": { - "buildTarget": "angular2-full-stack:build:production" + "buildTarget": "angular-full-stack:build:production" }, "development": { "proxyConfig": "proxy.conf.json", - "buildTarget": "angular2-full-stack:build:development" + "buildTarget": "angular-full-stack:build:development" } }, "defaultConfiguration": "development" @@ -78,26 +83,7 @@ "builder": "@angular/build:extract-i18n" }, "test": { - "builder": "@angular/build:karma", - "options": { - "polyfills": [ - "zone.js", - "zone.js/testing" - ], - "tsConfig": "tsconfig.spec.json", - "inlineStyleLanguage": "scss", - "assets": [ - "client/assets/favicon.ico", - "client/assets" - ], - "styles": [ - "node_modules/font-awesome/css/font-awesome.min.css", - "client/styles.scss" - ], - "scripts": [ - "node_modules/bootstrap/dist/js/bootstrap.bundle.min.js" - ] - } + "builder": "@angular/build:unit-test" }, "lint": { "builder": "@angular-eslint/builder:lint", @@ -113,6 +99,7 @@ } }, "cli": { + "packageManager": "npm", "schematicCollections": [ "angular-eslint" ] diff --git a/client/app/about/about.component.spec.ts b/client/app/about/about.component.spec.ts index bca06c9c..9b5cf19c 100644 --- a/client/app/about/about.component.spec.ts +++ b/client/app/about/about.component.spec.ts @@ -1,33 +1,27 @@ -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { AboutComponent } from './about.component'; describe('Component: About', () => { - let component: AboutComponent; let fixture: ComponentFixture; let compiled: HTMLElement; + + beforeEach(async() => { + await TestBed.configureTestingModule({ + imports: [AboutComponent] + }).compileComponents(); - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [ AboutComponent ] - }) - .compileComponents(); - })); - - beforeEach(() => { fixture = TestBed.createComponent(AboutComponent); - component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); compiled = fixture.nativeElement as HTMLElement; }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should create the about component', () => { + const app = fixture.componentInstance; + expect(app).toBeTruthy(); }); - it('should display the page header text', () => { - const header = compiled.querySelector('.card-header'); - expect(header?.textContent).toContain('About'); + it('should render the header', () => { + expect(compiled.querySelector('.card-header')?.textContent).toContain('About'); }); - }); diff --git a/client/app/about/about.component.ts b/client/app/about/about.component.ts index dfe4549f..ccf946df 100644 --- a/client/app/about/about.component.ts +++ b/client/app/about/about.component.ts @@ -4,7 +4,6 @@ import { Component } from '@angular/core'; selector: 'app-about', templateUrl: './about.component.html', styleUrls: ['./about.component.scss'], - standalone: false }) export class AboutComponent { diff --git a/client/app/account/account.component.html b/client/app/account/account.component.html index d6502238..08e757fd 100644 --- a/client/app/account/account.component.html +++ b/client/app/account/account.component.html @@ -1,31 +1,34 @@ - + - + -@if (!isLoading) { +@if (!isLoading()) {

Account settings

-
+
+ [ngModel]="user().username" (ngModelChange)="updateUserField('username', $event)" + placeholder="Username" required>
+ [ngModel]="user().email" (ngModelChange)="updateUserField('email', $event)" + placeholder="Email" required>
- diff --git a/client/app/account/account.component.spec.ts b/client/app/account/account.component.spec.ts index 60bcc3f6..9b76f886 100644 --- a/client/app/account/account.component.spec.ts +++ b/client/app/account/account.component.spec.ts @@ -1,16 +1,16 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; -import { FormsModule } from '@angular/forms'; +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Observable, of } from 'rxjs'; -import { ToastComponent } from '../shared/toast/toast.component'; -import { User } from '../shared/models/user.model'; +import { AccountComponent } from './account.component'; import { AuthService } from '../services/auth.service'; import { UserService } from '../services/user.service'; -import { AccountComponent } from './account.component'; -import { of, Observable } from 'rxjs'; - -class AuthServiceMock { } +import { ToastService } from '../shared/toast/toast.service'; +import { User } from '../shared/models/user.model'; +class AuthServiceMock { + currentUser = signal(new User()); +} class UserServiceMock { mockUser = { username: 'Test user', @@ -23,47 +23,34 @@ class UserServiceMock { } describe('Component: Account', () => { - let component: AccountComponent; let fixture: ComponentFixture; let compiled: HTMLElement; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ FormsModule ], - declarations: [ AccountComponent ], + beforeEach(async() => { + await TestBed.configureTestingModule({ + imports: [AccountComponent], providers: [ - ToastComponent, + ToastService, { provide: AuthService, useClass: AuthServiceMock }, { provide: UserService, useClass: UserServiceMock }, - ], - schemas: [NO_ERRORS_SCHEMA] - }) - .compileComponents(); - })); - - beforeEach(() => { + ] + }).compileComponents(); + fixture = TestBed.createComponent(AccountComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - component.user = { - username: 'Test user', - email: 'test@example.com' - }; - fixture.detectChanges(); compiled = fixture.nativeElement as HTMLElement; + await fixture.whenStable(); }); - it('should create', () => { + it('should create the account component', () => { + const component = fixture.componentInstance; expect(component).toBeTruthy(); }); - it('should display the page header text', () => { - const header = compiled.querySelector('.card-header'); - expect(header?.textContent).toContain('Account settings'); + it('should render the header', () => { + expect(compiled.querySelector('.card-header')?.textContent).toContain('Account settings'); }); - it('should display the username and email inputs filled', async () => { - await fixture.whenStable(); + it('should display the username and email inputs filled', () => { const inputs = compiled.querySelectorAll('input'); expect(inputs[0].value).toContain('Test user'); expect(inputs[1].value).toContain('test@example.com'); @@ -74,5 +61,4 @@ describe('Component: Account', () => { expect(button).toBeTruthy(); expect(button?.disabled).toBeFalsy(); }); - }); diff --git a/client/app/account/account.component.ts b/client/app/account/account.component.ts index 319d7bb8..c9d000be 100644 --- a/client/app/account/account.component.ts +++ b/client/app/account/account.component.ts @@ -1,44 +1,55 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + import { ToastComponent } from '../shared/toast/toast.component'; +import { LoadingComponent } from '../shared/loading/loading.component'; import { AuthService } from '../services/auth.service'; import { UserService } from '../services/user.service'; +import { ToastService } from '../shared/toast/toast.service'; import { User } from '../shared/models/user.model'; @Component({ selector: 'app-account', templateUrl: './account.component.html', - standalone: false + imports: [FormsModule, ToastComponent, LoadingComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class AccountComponent implements OnInit { private auth = inject(AuthService); - toast = inject(ToastComponent); + private toast = inject(ToastService); private userService = inject(UserService); - - user: User = new User(); - isLoading = true; + user = signal(new User()); + isLoading = signal(true); ngOnInit(): void { this.getUser(); } getUser(): void { - this.userService.getUser(this.auth.currentUser).subscribe({ - next: data => this.user = data, - error: error => console.log(error), - complete: () => this.isLoading = false + this.isLoading.set(true); + this.userService.getUser(this.auth.currentUser()).subscribe({ + next: data => this.user.set(data), + error: error => console.error(error), + complete: () => this.isLoading.set(false) }); } - save(user: User): void { + save(): void { + const user = this.user(); this.userService.editUser(user).subscribe({ - next: () => { + next: res => { this.toast.setMessage('Account settings saved!', 'success'); - this.auth.currentUser = user; - this.auth.isAdmin = user.role === 'admin'; + const decodedUser = this.auth.decodeUserFromToken(res.token); + this.auth.setCurrentUser(decodedUser); + localStorage.setItem('token', res.token); }, - error: error => console.log(error) + error: error => console.error(error) }); } + updateUserField(field: string, value: string) { + this.user.update(u => ({ ...u, [field]: value })); + } + } diff --git a/client/app/add-cat-form/add-cat-form.component.spec.ts b/client/app/add-cat-form/add-cat-form.component.spec.ts index 50b42b51..17e0b7e3 100644 --- a/client/app/add-cat-form/add-cat-form.component.spec.ts +++ b/client/app/add-cat-form/add-cat-form.component.spec.ts @@ -1,33 +1,24 @@ -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule, ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms'; -import { ToastComponent } from '../shared/toast/toast.component'; +import { ToastService } from '../shared/toast/toast.service'; import { CatService } from '../services/cat.service'; import { AddCatFormComponent } from './add-cat-form.component'; -class CatServiceMock { } - describe('Component: AddCatForm', () => { let component: AddCatFormComponent; let fixture: ComponentFixture; let compiled: HTMLElement; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [FormsModule, ReactiveFormsModule], - declarations: [ AddCatFormComponent ], - providers: [ - ToastComponent, UntypedFormBuilder, - { provide: CatService, useClass: CatServiceMock } - ] - }) - .compileComponents(); - })); - - beforeEach(() => { + beforeEach(async() => { + await TestBed.configureTestingModule({ + imports: [AddCatFormComponent, FormsModule, ReactiveFormsModule], + providers: [UntypedFormBuilder, ToastService, CatService] + }).compileComponents(); + fixture = TestBed.createComponent(AddCatFormComponent); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); compiled = fixture.nativeElement as HTMLElement; }); diff --git a/client/app/add-cat-form/add-cat-form.component.ts b/client/app/add-cat-form/add-cat-form.component.ts index 881f7f38..1353ff98 100644 --- a/client/app/add-cat-form/add-cat-form.component.ts +++ b/client/app/add-cat-form/add-cat-form.component.ts @@ -1,22 +1,23 @@ -import { Component, Input, inject } from '@angular/core'; -import { UntypedFormGroup, UntypedFormControl, Validators, UntypedFormBuilder } from '@angular/forms'; +import { Component, inject, output } from '@angular/core'; +import { UntypedFormGroup, UntypedFormControl, Validators, UntypedFormBuilder, ReactiveFormsModule } from '@angular/forms'; + import { CatService } from '../services/cat.service'; -import { ToastComponent } from '../shared/toast/toast.component'; +import { ToastService } from '../shared/toast/toast.service'; import { Cat } from '../shared/models/cat.model'; @Component({ selector: 'app-add-cat-form', templateUrl: './add-cat-form.component.html', styleUrls: ['./add-cat-form.component.scss'], - standalone: false + imports: [ReactiveFormsModule] }) export class AddCatFormComponent { private catService = inject(CatService); private formBuilder = inject(UntypedFormBuilder); - toast = inject(ToastComponent); + private toast = inject(ToastService); - @Input() cats: Cat[] = []; + catAdded = output(); addCatForm: UntypedFormGroup; name = new UntypedFormControl('', Validators.required); @@ -34,11 +35,11 @@ export class AddCatFormComponent { addCat(): void { this.catService.addCat(this.addCatForm.value).subscribe({ next: res => { - this.cats.push(res); + this.catAdded.emit(res); this.addCatForm.reset(); this.toast.setMessage('Item added successfully.', 'success'); }, - error: error => console.log(error) + error: error => console.error(error), }); } diff --git a/client/app/admin/admin.component.html b/client/app/admin/admin.component.html index 92742063..57c3ac2b 100644 --- a/client/app/admin/admin.component.html +++ b/client/app/admin/admin.component.html @@ -1,10 +1,10 @@ - + - + -@if (!isLoading) { +@if (!isLoading()) {
-

Registered users ({{users.length}})

+

Registered users ({{usersCount()}})

@@ -15,7 +15,7 @@

Registered users ({{users.length}})

- @if (users.length === 0) { + @if (usersCount() === 0) { @@ -23,14 +23,14 @@

Registered users ({{users.length}})

} - @for (user of users; track user) { + @for (user of users(); track user) { diff --git a/client/app/admin/admin.component.spec.ts b/client/app/admin/admin.component.spec.ts index 55d6348d..c3246eb9 100644 --- a/client/app/admin/admin.component.spec.ts +++ b/client/app/admin/admin.component.spec.ts @@ -1,14 +1,15 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of, Observable } from 'rxjs'; -import { ToastComponent } from '../shared/toast/toast.component'; import { AuthService } from '../services/auth.service'; import { UserService } from '../services/user.service'; +import { ToastService } from '../shared/toast/toast.service'; +import { User } from '../shared/models/user.model'; import { AdminComponent } from './admin.component'; -import { of, Observable } from 'rxjs'; class AuthServiceMock { - currentUser = { _id: '1', username: 'test1@example.com', role: 'admin' }; + currentUser = signal({ _id: '1', username: 'test1@example.com', role: 'admin' }); } class UserServiceMock { @@ -26,23 +27,19 @@ describe('Component: Admin', () => { let fixture: ComponentFixture; let compiled: HTMLElement; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [ AdminComponent ], + beforeEach(async() => { + await TestBed.configureTestingModule({ + imports: [AdminComponent], providers: [ - ToastComponent, + ToastService, { provide: AuthService, useClass: AuthServiceMock }, { provide: UserService, useClass: UserServiceMock }, ], - schemas: [NO_ERRORS_SCHEMA] - }) - .compileComponents(); - })); + }).compileComponents(); - beforeEach(() => { fixture = TestBed.createComponent(AdminComponent); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); compiled = fixture.nativeElement as HTMLElement; }); @@ -56,7 +53,7 @@ describe('Component: Admin', () => { }); it('should display the text for no users', () => { - component.users = []; + component.users.set([]); fixture.detectChanges(); const header = compiled.querySelector('h4'); expect(header?.textContent).toContain('Registered users (0)'); diff --git a/client/app/admin/admin.component.ts b/client/app/admin/admin.component.ts index 938a77fb..17861f45 100644 --- a/client/app/admin/admin.component.ts +++ b/client/app/admin/admin.component.ts @@ -1,41 +1,46 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core'; import { ToastComponent } from '../shared/toast/toast.component'; +import { LoadingComponent } from '../shared/loading/loading.component'; import { AuthService } from '../services/auth.service'; import { UserService } from '../services/user.service'; +import { ToastService } from '../shared/toast/toast.service'; import { User } from '../shared/models/user.model'; @Component({ selector: 'app-admin', templateUrl: './admin.component.html', - standalone: false + imports: [ToastComponent, LoadingComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class AdminComponent implements OnInit { auth = inject(AuthService); - toast = inject(ToastComponent); + private toast = inject(ToastService); private userService = inject(UserService); + users = signal([]); + isLoading = signal(true); - users: User[] = []; - isLoading = true; + usersCount = computed(() => this.users().length); ngOnInit(): void { this.getUsers(); } getUsers(): void { + this.isLoading.set(true); this.userService.getUsers().subscribe({ - next: data => this.users = data, - error: error => console.log(error), - complete: () => this.isLoading = false + next: data => this.users.set(data), + error: error => console.error(error), + complete: () => this.isLoading.set(false) }); } deleteUser(user: User): void { - if (window.confirm('Are you sure you want to delete ' + user.username + '?')) { + if (window.confirm(`Are you sure you want to delete ${user.username}?`)) { this.userService.deleteUser(user).subscribe({ - next: () => this.toast.setMessage('User deleted successfully.', 'success'), - error: error => console.log(error), + next: () => this.toast.setMessage(`User ${user.username} deleted successfully.`, 'success'), + error: error => console.error(error), complete: () => this.getUsers() }); } diff --git a/client/app/app.component.spec.ts b/client/app/app.component.spec.ts deleted file mode 100644 index 465b9300..00000000 --- a/client/app/app.component.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; -import { RouterTestingModule } from '@angular/router/testing'; - -import { AuthService } from './services/auth.service'; -import { AppComponent } from './app.component'; - -class AuthServiceMock { } - -describe('Component: App', () => { - let component: AppComponent; - let fixture: ComponentFixture; - let authService: AuthService; - let compiled: HTMLElement; - - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ RouterTestingModule ], - declarations: [ AppComponent ], - providers: [ { provide: AuthService, useClass: AuthServiceMock } ], - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(AppComponent); - component = fixture.componentInstance; - authService = fixture.debugElement.injector.get(AuthService); - fixture.detectChanges(); - compiled = fixture.nativeElement as HTMLElement; - }); - - it('should create the app', waitForAsync(() => { - expect(component).toBeTruthy(); - })); - - it('should display the navigation bar correctly for guests', () => { - const elems = compiled.querySelectorAll('.nav-link'); - expect(elems.length).toBe(4); - expect(elems[0].textContent).toContain('Home'); - expect(elems[1].textContent).toContain('Cats'); - expect(elems[2].textContent).toContain('Login'); - expect(elems[3].textContent).toContain('Register'); - }); - - it('should display the navigation bar correctly for logged users', () => { - authService.loggedIn = true; - authService.currentUser = { _id: '123', username: 'Tester', role: 'user' }; - fixture.detectChanges(); - const elems = compiled.querySelectorAll('.nav-link'); - expect(elems.length).toBe(4); - expect(elems[0].textContent).toContain('Home'); - expect(elems[1].textContent).toContain('Cats'); - expect(elems[2].textContent).toContain('Account (Tester)'); - expect(elems[3].textContent).toContain('Logout'); - }); - - it('should display the navigation bar correctly for admin users', () => { - authService.loggedIn = true; - authService.isAdmin = true; - authService.currentUser = { _id: '123', username: 'Tester', role: 'admin' }; - fixture.detectChanges(); - const elems = compiled.querySelectorAll('.nav-link'); - expect(elems.length).toBe(5); - expect(elems[0].textContent).toContain('Home'); - expect(elems[1].textContent).toContain('Cats'); - expect(elems[2].textContent).toContain('Account (Tester)'); - expect(elems[3].textContent).toContain('Admin'); - expect(elems[4].textContent).toContain('Logout'); - }); - -}); diff --git a/client/app/app.config.ts b/client/app/app.config.ts new file mode 100644 index 00000000..122dcc4a --- /dev/null +++ b/client/app/app.config.ts @@ -0,0 +1,35 @@ +import { ApplicationConfig, importProvidersFrom, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { JwtModule } from '@auth0/angular-jwt'; + +// Routes +import { routes } from './app.routes'; +// Services +import { CatService } from './services/cat.service'; +import { UserService } from './services/user.service'; +import { AuthService } from './services/auth.service'; +import { AuthGuardLogin } from './services/auth-guard-login.service'; +import { AuthGuardAdmin } from './services/auth-guard-admin.service'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes), + importProvidersFrom( + JwtModule.forRoot({ + config: { + tokenGetter: () => localStorage.getItem('token'), + // allowedDomains: ['example.com'], + // disallowedRoutes: ['http://example.com/examplebadroute/'], + }, + }), + ), + provideHttpClient(withInterceptorsFromDi()), + AuthService, + AuthGuardLogin, + AuthGuardAdmin, + CatService, + UserService, + ], +}; diff --git a/client/app/app.component.html b/client/app/app.html similarity index 90% rename from client/app/app.component.html rename to client/app/app.html index cc057384..882ce139 100644 --- a/client/app/app.component.html +++ b/client/app/app.html @@ -19,35 +19,35 @@
Actions
There are no registered users.
{{user.username}} {{user.email}} {{user.role}}
@@ -15,16 +15,16 @@

Current cats ({{cats.length}})

- @if (cats.length === 0) { + @if (catsCount() === 0) { } - @if (!isEditing) { + @if (!isEditing()) { - @for (cat of cats; track cat) { + @for (cat of cats(); track cat) { @@ -41,29 +41,32 @@

Current cats ({{cats.length}})

} } - @if (isEditing) { + @if (isEditing()) {
Actions
There are no cats in the DB. Add a new cat below.
{{cat.name}} {{cat.age}}
- +
- +
- +
- +
-
@@ -78,6 +81,6 @@

Current cats ({{cats.length}})

} -@if (!isEditing) { - +@if (!isEditing()) { + } \ No newline at end of file diff --git a/client/app/cats/cats.component.spec.ts b/client/app/cats/cats.component.spec.ts index a61c8564..8f88c89a 100644 --- a/client/app/cats/cats.component.spec.ts +++ b/client/app/cats/cats.component.spec.ts @@ -1,12 +1,11 @@ -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { waitForAsync, TestBed, ComponentFixture } from '@angular/core/testing'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; import { FormsModule, UntypedFormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { of, Observable } from 'rxjs'; -import { ToastComponent } from '../shared/toast/toast.component'; import { CatService } from '../services/cat.service'; +import { ToastService } from '../shared/toast/toast.service'; import { CatsComponent } from './cats.component'; -import { of, Observable } from 'rxjs'; class CatServiceMock { mockCats = [ @@ -23,23 +22,19 @@ describe('Component: Cats', () => { let fixture: ComponentFixture; let compiled: HTMLElement; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ RouterTestingModule, FormsModule, ReactiveFormsModule ], - declarations: [ CatsComponent ], + beforeEach(async() => { + await TestBed.configureTestingModule({ + imports: [CatsComponent, RouterTestingModule, FormsModule, ReactiveFormsModule], providers: [ - ToastComponent, UntypedFormBuilder, + ToastService, + UntypedFormBuilder, { provide: CatService, useClass: CatServiceMock } ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] - }) - .compileComponents(); - })); + }).compileComponents(); - beforeEach(() => { fixture = TestBed.createComponent(CatsComponent); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); compiled = fixture.nativeElement as HTMLElement; }); @@ -53,7 +48,7 @@ describe('Component: Cats', () => { }); it('should display the text for no cats', () => { - component.cats = []; + component.cats.set([]); fixture.detectChanges(); const header = compiled.querySelector('.card-header'); expect(header?.textContent).toContain('Current cats (0)'); @@ -81,9 +76,8 @@ describe('Component: Cats', () => { }); it('should display the edit form', async () => { - component.isEditing = true; - component.cat = { name: 'Cat 1', age: 1, weight: 2 }; - fixture.detectChanges(); + component.isEditing.set(true); + component.cat.set({ name: 'Cat 1', age: 1, weight: 2 }); await fixture.whenStable(); const tds = compiled.querySelectorAll('td'); expect(tds.length).toBe(1); diff --git a/client/app/cats/cats.component.ts b/client/app/cats/cats.component.ts index 70b89392..94c3d9a4 100644 --- a/client/app/cats/cats.component.ts +++ b/client/app/cats/cats.component.ts @@ -1,58 +1,65 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { CatService } from '../services/cat.service'; +import { ToastService } from '../shared/toast/toast.service'; +import { LoadingComponent } from '../shared/loading/loading.component'; import { ToastComponent } from '../shared/toast/toast.component'; +import { AddCatFormComponent } from '../add-cat-form/add-cat-form.component'; import { Cat } from '../shared/models/cat.model'; @Component({ selector: 'app-cats', templateUrl: './cats.component.html', styleUrls: ['./cats.component.scss'], - standalone: false + imports: [FormsModule, AddCatFormComponent, ToastComponent, LoadingComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class CatsComponent implements OnInit { private catService = inject(CatService); - toast = inject(ToastComponent); + private toast = inject(ToastService); + cat = signal(new Cat()); + cats = signal([]); + isLoading = signal(true); + isEditing = signal(false); - cat = new Cat(); - cats: Cat[] = []; - isLoading = true; - isEditing = false; + catsCount = computed(() => this.cats().length); ngOnInit(): void { this.getCats(); } getCats(): void { + this.isLoading.set(true); this.catService.getCats().subscribe({ - next: data => this.cats = data, - error: error => console.log(error), - complete: () => this.isLoading = false + next: data => this.cats.set(data), + error: error => console.error(error), + complete: () => this.isLoading.set(false) }); } enableEditing(cat: Cat): void { - this.isEditing = true; - this.cat = cat; + this.isEditing.set(true); + this.cat.set({ ...cat }); } - cancelEditing(): void { - this.isEditing = false; - this.cat = new Cat(); + cancelEditing(event: Event): void { + event.preventDefault(); // Prevent triggering submit + this.isEditing.set(false); + this.cat.set(new Cat()); this.toast.setMessage('Item editing cancelled.', 'warning'); - // reload the cats to reset the editing - this.getCats(); } editCat(cat: Cat): void { this.catService.editCat(cat).subscribe({ next: () => { - this.isEditing = false; - this.cat = cat; + this.isEditing.set(false); + this.cat.set(cat); this.toast.setMessage('Item edited successfully.', 'success'); + this.cats.update(items => items.map(item => item._id === cat._id ? cat : item)); }, - error: error => console.log(error) + error: error => console.error(error) }); } @@ -60,12 +67,20 @@ export class CatsComponent implements OnInit { if (window.confirm('Are you sure you want to permanently delete this item?')) { this.catService.deleteCat(cat).subscribe({ next: () => { - this.cats = this.cats.filter(elem => elem._id !== cat._id); + this.cats.update(list => list.filter(elem => elem._id !== cat._id)); this.toast.setMessage('Item deleted successfully.', 'success'); }, - error: error => console.log(error) + error: error => console.error(error) }); } } + onCatAdded(newCat: Cat): void { + this.cats.update(list => [...list, newCat]); + } + + updateCatField(field: string, value: string) { + this.cat.update(c => ({ ...c, [field]: value })); + } + } diff --git a/client/app/login/login.component.html b/client/app/login/login.component.html index 5163727e..00ecaf96 100644 --- a/client/app/login/login.component.html +++ b/client/app/login/login.component.html @@ -1,4 +1,4 @@ - +

Login

diff --git a/client/app/login/login.component.spec.ts b/client/app/login/login.component.spec.ts index 4b3c63af..191c7c9e 100644 --- a/client/app/login/login.component.spec.ts +++ b/client/app/login/login.component.spec.ts @@ -1,38 +1,37 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule, UntypedFormBuilder, ReactiveFormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; -import { ToastComponent } from '../shared/toast/toast.component'; +import { ToastService } from '../shared/toast/toast.service'; import { AuthService } from '../services/auth.service'; +import { UserService } from '../services/user.service'; +import { User } from '../shared/models/user.model'; import { LoginComponent } from './login.component'; -class AuthServiceMock { } -class RouterMock { } +class AuthServiceMock { + currentUser = signal(new User()); + loggedIn = signal(true); +} describe('Component: Login', () => { let component: LoginComponent; let fixture: ComponentFixture; let compiled: HTMLElement; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ FormsModule, ReactiveFormsModule ], - declarations: [ LoginComponent ], + beforeEach(async() => { + await TestBed.configureTestingModule({ + imports: [LoginComponent, FormsModule, ReactiveFormsModule], providers: [ - UntypedFormBuilder, ToastComponent, - { provide: Router, useClass: RouterMock }, - { provide: AuthService, useClass: AuthServiceMock } + UntypedFormBuilder, + ToastService, + UserService, + { provide: AuthService, useClass: AuthServiceMock }, ], - schemas: [NO_ERRORS_SCHEMA] - }) - .compileComponents(); - })); + }).compileComponents(); - beforeEach(() => { fixture = TestBed.createComponent(LoginComponent); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); compiled = fixture.nativeElement as HTMLElement; }); diff --git a/client/app/login/login.component.ts b/client/app/login/login.component.ts index 91f7718e..326b30df 100644 --- a/client/app/login/login.component.ts +++ b/client/app/login/login.component.ts @@ -1,6 +1,7 @@ import { Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; -import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; +import { ReactiveFormsModule, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; import { AuthService } from '../services/auth.service'; import { ToastComponent } from '../shared/toast/toast.component'; @@ -8,14 +9,12 @@ import { ToastComponent } from '../shared/toast/toast.component'; @Component({ selector: 'app-login', templateUrl: './login.component.html', - standalone: false + imports: [CommonModule, ReactiveFormsModule, ToastComponent], }) export class LoginComponent implements OnInit { private auth = inject(AuthService); private formBuilder = inject(UntypedFormBuilder); private router = inject(Router); - toast = inject(ToastComponent); - loginForm: UntypedFormGroup; email = new UntypedFormControl('', [ @@ -37,7 +36,7 @@ export class LoginComponent implements OnInit { } ngOnInit(): void { - if (this.auth.loggedIn) { + if (this.auth.loggedIn()) { this.router.navigate(['/']); } } diff --git a/client/app/logout/logout.component.spec.ts b/client/app/logout/logout.component.spec.ts index 98784514..ce4e986f 100644 --- a/client/app/logout/logout.component.spec.ts +++ b/client/app/logout/logout.component.spec.ts @@ -1,12 +1,13 @@ -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; import { AuthService } from '../services/auth.service'; import { LogoutComponent } from './logout.component'; class AuthServiceMock { - loggedIn = true; + loggedIn = signal(true); logout(): void { - this.loggedIn = false; + this.loggedIn.set(false); } } @@ -15,19 +16,16 @@ describe('Component: Logout', () => { let fixture: ComponentFixture; let authService: AuthService; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [ LogoutComponent ], - providers: [ { provide: AuthService, useClass: AuthServiceMock } ], - }) - .compileComponents(); - })); + beforeEach(async() => { + await TestBed.configureTestingModule({ + imports: [LogoutComponent], + providers: [{ provide: AuthService, useClass: AuthServiceMock }], + }).compileComponents(); - beforeEach(() => { fixture = TestBed.createComponent(LogoutComponent); component = fixture.componentInstance; authService = fixture.debugElement.injector.get(AuthService); - fixture.detectChanges(); + await fixture.whenStable(); }); it('should create', () => { @@ -35,10 +33,10 @@ describe('Component: Logout', () => { }); it('should logout the user', () => { - authService.loggedIn = true; - expect(authService.loggedIn).toBeTruthy(); + authService.loggedIn.set(true); + expect(authService.loggedIn()).toBeTruthy(); authService.logout(); - expect(authService.loggedIn).toBeFalsy(); + expect(authService.loggedIn()).toBeFalsy(); }); }); diff --git a/client/app/logout/logout.component.ts b/client/app/logout/logout.component.ts index b57f40a7..3fb2081d 100644 --- a/client/app/logout/logout.component.ts +++ b/client/app/logout/logout.component.ts @@ -1,10 +1,10 @@ import { Component, OnInit, inject } from '@angular/core'; + import { AuthService } from '../services/auth.service'; @Component({ selector: 'app-logout', template: '', - standalone: false }) export class LogoutComponent implements OnInit { private auth = inject(AuthService); diff --git a/client/app/not-found/not-found.component.spec.ts b/client/app/not-found/not-found.component.spec.ts index fa10f270..dbe76d7a 100644 --- a/client/app/not-found/not-found.component.spec.ts +++ b/client/app/not-found/not-found.component.spec.ts @@ -1,4 +1,4 @@ -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { NotFoundComponent } from './not-found.component'; @@ -7,17 +7,14 @@ describe('Component: NotFound', () => { let fixture: ComponentFixture; let compiled: HTMLElement; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [ NotFoundComponent ] - }) - .compileComponents(); - })); + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NotFoundComponent] + }).compileComponents(); - beforeEach(() => { fixture = TestBed.createComponent(NotFoundComponent); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); compiled = fixture.nativeElement as HTMLElement; }); diff --git a/client/app/not-found/not-found.component.ts b/client/app/not-found/not-found.component.ts index d2d33f8e..6246b379 100644 --- a/client/app/not-found/not-found.component.ts +++ b/client/app/not-found/not-found.component.ts @@ -3,7 +3,6 @@ import { Component } from '@angular/core'; @Component({ selector: 'app-not-found', templateUrl: './not-found.component.html', - standalone: false }) export class NotFoundComponent { diff --git a/client/app/register/register.component.html b/client/app/register/register.component.html index e7031097..c2987d55 100644 --- a/client/app/register/register.component.html +++ b/client/app/register/register.component.html @@ -1,4 +1,4 @@ - +

Register

diff --git a/client/app/register/register.component.spec.ts b/client/app/register/register.component.spec.ts index 4cc72322..324d412a 100644 --- a/client/app/register/register.component.spec.ts +++ b/client/app/register/register.component.spec.ts @@ -1,13 +1,10 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { Router } from '@angular/router'; -import { ToastComponent } from '../shared/toast/toast.component'; +import { ToastService } from '../shared/toast/toast.service'; import { UserService } from '../services/user.service'; import { RegisterComponent } from './register.component'; -class RouterMock { } class UserServiceMock { } describe('Component: Register', () => { @@ -15,21 +12,15 @@ describe('Component: Register', () => { let fixture: ComponentFixture; let compiled: HTMLElement; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [ FormsModule, ReactiveFormsModule ], - declarations: [ RegisterComponent ], + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RegisterComponent, FormsModule, ReactiveFormsModule], providers: [ - ToastComponent, - { provide: Router, useClass: RouterMock }, + ToastService, { provide: UserService, useClass: UserServiceMock } - ], - schemas: [NO_ERRORS_SCHEMA] - }) - .compileComponents(); - })); + ] + }).compileComponents(); - beforeEach(() => { fixture = TestBed.createComponent(RegisterComponent); component = fixture.componentInstance; fixture.detectChanges(); diff --git a/client/app/register/register.component.ts b/client/app/register/register.component.ts index 88b91bde..1bcd59c3 100644 --- a/client/app/register/register.component.ts +++ b/client/app/register/register.component.ts @@ -1,19 +1,21 @@ import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; -import { UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; +import { ReactiveFormsModule, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; import { UserService } from '../services/user.service'; +import { ToastService } from '../shared/toast/toast.service'; import { ToastComponent } from '../shared/toast/toast.component'; @Component({ selector: 'app-register', templateUrl: './register.component.html', - standalone: false + imports: [CommonModule, ReactiveFormsModule, ToastComponent] }) export class RegisterComponent { private formBuilder = inject(UntypedFormBuilder); private router = inject(Router); - toast = inject(ToastComponent); + private toast = inject(ToastService); private userService = inject(UserService); diff --git a/client/app/services/auth-guard-admin.service.ts b/client/app/services/auth-guard-admin.service.ts index 752f8e12..d495a8a9 100644 --- a/client/app/services/auth-guard-admin.service.ts +++ b/client/app/services/auth-guard-admin.service.ts @@ -6,9 +6,8 @@ import { AuthService } from './auth.service'; export class AuthGuardAdmin { auth = inject(AuthService); - canActivate(): boolean { - return this.auth.isAdmin; + return this.auth.isAdmin(); } } diff --git a/client/app/services/auth-guard-login.service.ts b/client/app/services/auth-guard-login.service.ts index 1ee82046..97f9d521 100644 --- a/client/app/services/auth-guard-login.service.ts +++ b/client/app/services/auth-guard-login.service.ts @@ -6,9 +6,8 @@ import { AuthService } from './auth.service'; export class AuthGuardLogin { auth = inject(AuthService); - canActivate(): boolean { - return this.auth.loggedIn; + return this.auth.loggedIn(); } } diff --git a/client/app/services/auth.service.ts b/client/app/services/auth.service.ts index 33d17e94..310118dc 100644 --- a/client/app/services/auth.service.ts +++ b/client/app/services/auth.service.ts @@ -1,10 +1,9 @@ -import { Injectable, inject } from '@angular/core'; +import { Injectable, inject, signal } from '@angular/core'; import { Router } from '@angular/router'; import { JwtHelperService } from '@auth0/angular-jwt'; import { UserService } from './user.service'; -import { ToastComponent } from '../shared/toast/toast.component'; import { User } from '../shared/models/user.model'; @Injectable() @@ -12,12 +11,10 @@ export class AuthService { private userService = inject(UserService); private router = inject(Router); private jwtHelper = inject(JwtHelperService); - toast = inject(ToastComponent); - loggedIn = false; - isAdmin = false; - - currentUser: User = new User(); + loggedIn = signal(false); + isAdmin = signal(false); + currentUser = signal(new User()); constructor() { const token = localStorage.getItem('token'); @@ -33,32 +30,28 @@ export class AuthService { localStorage.setItem('token', res.token); const decodedUser = this.decodeUserFromToken(res.token); this.setCurrentUser(decodedUser); - this.loggedIn = true; this.router.navigate(['/']); }, - error: () => this.toast.setMessage('Invalid email or password!', 'danger') + // error: () => this.toast.setMessage('Invalid email or password!', 'danger') }); } logout(): void { localStorage.removeItem('token'); - this.loggedIn = false; - this.isAdmin = false; - this.currentUser = new User(); + this.loggedIn.set(false); + this.isAdmin.set(false); + this.currentUser.set(new User()); this.router.navigate(['/']); } - decodeUserFromToken(token: string): object { + decodeUserFromToken(token: string): User { return this.jwtHelper.decodeToken(token).user; } setCurrentUser(decodedUser: User): void { - this.loggedIn = true; - this.currentUser._id = decodedUser._id; - this.currentUser.username = decodedUser.username; - this.currentUser.role = decodedUser.role; - this.isAdmin = decodedUser.role === 'admin'; - delete decodedUser.role; + this.loggedIn.set(true); + this.currentUser.set(decodedUser); + this.isAdmin.set(decodedUser.role === 'admin'); } } diff --git a/client/app/services/user.service.ts b/client/app/services/user.service.ts index 48142393..65f289c4 100644 --- a/client/app/services/user.service.ts +++ b/client/app/services/user.service.ts @@ -33,8 +33,8 @@ export class UserService { return this.http.get(`/api/user/${user._id}`); } - editUser(user: User): Observable { - return this.http.put(`/api/user/${user._id}`, user, { responseType: 'text' }); + editUser(user: User): Observable<{ token: string }> { + return this.http.put<{ token: string }>(`/api/user/${user._id}`, user); } deleteUser(user: User): Observable { diff --git a/client/app/shared/loading/loading.component.html b/client/app/shared/loading/loading.component.html index 1277f1b1..15170e11 100644 --- a/client/app/shared/loading/loading.component.html +++ b/client/app/shared/loading/loading.component.html @@ -1,4 +1,4 @@ -@if (condition) { +@if (displayedCondition()) {

Loading...

diff --git a/client/app/shared/loading/loading.component.spec.ts b/client/app/shared/loading/loading.component.spec.ts index 5583afda..712829b6 100644 --- a/client/app/shared/loading/loading.component.spec.ts +++ b/client/app/shared/loading/loading.component.spec.ts @@ -1,5 +1,4 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { LoadingComponent } from './loading.component'; @@ -8,18 +7,14 @@ describe('Component: Loading', () => { let fixture: ComponentFixture; let compiled: HTMLElement; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - declarations: [ LoadingComponent ], - schemas: [NO_ERRORS_SCHEMA] - }) - .compileComponents(); - })); + beforeEach(async() => { + await TestBed.configureTestingModule({ + imports: [LoadingComponent], + }).compileComponents(); - beforeEach(() => { fixture = TestBed.createComponent(LoadingComponent); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); compiled = fixture.nativeElement as HTMLElement; }); @@ -33,12 +28,11 @@ describe('Component: Loading', () => { }); it('should show the DOM element', () => { - component.condition = true; + fixture.componentRef.setInput('condition', true); fixture.detectChanges(); expect(component).toBeTruthy(); - const div = compiled.querySelector('div'); - expect(div).toBeDefined(); - expect(div?.textContent).toContain('Loading...'); + const header = compiled.querySelector('h4'); + expect(header?.textContent).toContain('Loading...'); }); }); diff --git a/client/app/shared/loading/loading.component.ts b/client/app/shared/loading/loading.component.ts index f97451d4..7491b6a7 100644 --- a/client/app/shared/loading/loading.component.ts +++ b/client/app/shared/loading/loading.component.ts @@ -1,10 +1,12 @@ -import { Component, Input } from '@angular/core'; +import { Component, input, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; @Component({ selector: 'app-loading', + imports: [CommonModule], templateUrl: './loading.component.html', - standalone: false }) export class LoadingComponent { - @Input() condition = false; + condition = input(false); + displayedCondition = computed(() => this.condition()); } diff --git a/client/app/shared/shared.module.ts b/client/app/shared/shared.module.ts deleted file mode 100644 index 38963d36..00000000 --- a/client/app/shared/shared.module.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NgModule } from '@angular/core'; -import { BrowserModule } from '@angular/platform-browser'; -import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; - -import { ToastComponent } from './toast/toast.component'; -import { LoadingComponent } from './loading/loading.component'; - -@NgModule({ - exports: [ - // Shared Modules - BrowserModule, - FormsModule, - ReactiveFormsModule, - // Shared Components - ToastComponent, - LoadingComponent, - ], - declarations: [ - ToastComponent, - LoadingComponent - ], - imports: [ - BrowserModule, - FormsModule, - ReactiveFormsModule - ], - providers: [ - ToastComponent, - provideHttpClient(withInterceptorsFromDi()) - ], -}) -export class SharedModule {} diff --git a/client/app/shared/toast/toast.component.html b/client/app/shared/toast/toast.component.html index 76934867..b13f9102 100644 --- a/client/app/shared/toast/toast.component.html +++ b/client/app/shared/toast/toast.component.html @@ -1,9 +1,9 @@ -@if (message.body) { -