diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..16fb59bc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git/ +node_modules/ +dist/ diff --git a/.editorconfig b/.editorconfig index 6e87a003..f166060d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Editor configuration, see http://editorconfig.org +# Editor configuration, see https://editorconfig.org root = true [*] @@ -8,6 +8,10 @@ indent_size = 2 insert_final_newline = true trim_trailing_whitespace = true +[*.ts] +quote_type = single +ij_typescript_use_double_quotes = false + [*.md] max_line_length = off trim_trailing_whitespace = false diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..bf4668f0 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms + +github: [DavideViolante] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +custom: ['https://www.paypal.me/dviolante'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 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/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..759c631c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,20 @@ +name: Build + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + - run: npm ci + - run: npm run lint + - run: npm run build \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..b744da89 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,26 @@ +name: Tests + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + tests: + runs-on: ubuntu-latest + services: + mongodb: + image: mongo:5.0.13 + ports: + - 27017:27017 + if: "!contains(github.event.head_commit.message, '[skip tests]')" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + - run: npm ci + - run: npm run test -- --watch=false + - run: npm run test:be \ No newline at end of file diff --git a/.gitignore b/.gitignore index b284e1e2..31688422 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,18 @@ -# See http://help.github.com/ignore-files/ for more about ignoring files. +# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. -# compiled output +# Compiled output /dist /tmp /out-tsc +/bazel-out -# dependencies +# Node /node_modules +npm-debug.log +yarn-error.log # IDEs and editors -/.idea +.idea/ .project .classpath .c9/ @@ -17,24 +20,28 @@ .settings/ *.sublime-workspace -# IDE - VSCode -/.vscode +# Visual Studio Code .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json +!.vscode/mcp.json +.history/* -# misc -/.sass-cache +# Miscellaneous +/.angular/cache +.sass-cache/ /connect.lock /coverage /libpeerconnection.log -npm-debug.log -yarn-error.log testem.log /typings +__screenshots__/ -# System Files +# System files .DS_Store Thumbs.db + +# MongoDB +/data diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..5e5b9769 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +npm run lint +npm run build \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..2465c377 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM node:24-alpine + +WORKDIR /usr/src/app +COPY package*.json ./ +RUN npm ci +COPY . . +ENV MONGODB_URI mongodb://mongo:27017/angularfullstack +#RUN npm run build:dev +RUN npm run build +EXPOSE 3000 +CMD [ "npm", "start" ] diff --git a/LICENSE b/LICENSE index 7bb8a99f..ab270fcb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 Davide Violante +Copyright (c) 2020 Davide Violante Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 11488f09..4a39bd32 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ -# Angular Full Stack [![Dependencies](https://david-dm.org/DavideViolante/Angular-Full-Stack.svg)](https://david-dm.org/DavideViolante/Angular2-Full-Stack) [![Donate](https://img.shields.io/badge/paypal-donate-179BD7.svg)](https://www.paypal.me/dviolante) [![MIT license](http://img.shields.io/badge/license-MIT-lightgrey.svg)](http://opensource.org/licenses/MIT) +# Angular Full Stack +[![](https://github.com/davideviolante/Angular-Full-Stack/workflows/Build/badge.svg)](https://github.com/DavideViolante/Angular-Full-Stack/actions?query=workflow%3ABuild) [![](https://github.com/davideviolante/Angular-Full-Stack/workflows/Tests/badge.svg)](https://github.com/DavideViolante/Angular-Full-Stack/actions?query=workflow%3ATests) [![Donate](https://img.shields.io/badge/paypal-donate-179BD7.svg)](https://www.paypal.me/dviolante) -The frontend is generated with [Angular CLI](https://github.com/angular/angular-cli). The backend is made from scratch. Whole stack in [TypeScript](https://www.typescriptlang.org). +Angular Full Stack is a project to easly get started with the latest Angular using a real backend and database. Whole stack is in TypeScript, from frontend to backend, giving you the advantage to code in one single language throughout the all stack. 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: @@ -14,63 +15,59 @@ 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 -1. Install [Node.js](https://nodejs.org) and [MongoDB](https://www.mongodb.com) +1. Install [Node.js](https://nodejs.org) and [MongoDB Community Edition](https://www.mongodb.com/try/download/community) 2. Install Angular CLI: `npm i -g @angular/cli` 3. From project root folder install all the dependencies: `npm i` ## Run -### Development mode +### Development mode with files watching `npm run dev`: [concurrently](https://github.com/kimmobrunfeldt/concurrently) execute MongoDB, Angular build, TypeScript compiler and Express server. A window will automatically open at [localhost:4200](http://localhost:4200). Angular and Express files are being watched. Any change automatically creates a new bundle, restart Express server and reload your browser. ### Production mode -`npm run prod`: run the project with a production bundle and AOT compilation listening at [localhost:3000](http://localhost:3000) - -## Deploy (Heroku) -1. Go to Heroku and create a new app (eg: `your-app-name`) -2. Install [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli) -3. `heroku login` -4. `mkdir your-app-name && cd your-app-name` -5. `git init` -6. `heroku git:remote -a your-app-name` -7. Download this repo and copy all files into `your-app-name` folder -8. `npm i` -9. Edit `package.json` as following: - - add this line to scripts: `"postinstall": "tsc -p server && ng build --aot --prod"` - - move the following packages from devDependencies to dependencies: `@angular/cli`, `@angular/compiler-cli`, `@types/*`, `chai`, `chai-http` and `typescript`. -10. Edit `.env` and replace the MongoDB URI with a real remote MongoDB server. You can create a MongoDB server with Heroku or mLab. -11. `git add .` -12. `git commit -m "Going to Heroku"` -13. `git push heroku master` -14. `heroku open` and a window will open with your app online +`npm run prod`: run the project with a production bundle listening at [localhost:3000](http://localhost:3000) + +### Manual mode +1. Build frontend: `npm run build:dev` for dev or `npm run build` for prod +2. Build backend: `npm run predev` +3. Run MongoDB: `mongod` +4. Run the app: `npm start` + +### Docker +1. `sudo docker-compose up` +2. Go to [localhost:3000](http://localhost:3000) + +### AWS EC2 +1. Create a EC2 Linux machine on AWS +2. Edit the EC2 Security Group and add TCP port `3000` as an Inbound rule for Source `0.0.0.0/0` +3. Clone this repo into the EC2 machine +4. If you use a remote MongoDB instance, edit `.env` file +5. Run `npm ci` +6. Run `npm run build` +7. Run `npm start` +8. The app is now running and listening on port 3000 +9. You can now visit the public IP of your AWS EC2 followed by the port, eg: `12.34.56.78:3000` +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 `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). - -Run `mongod` to run an instance of MongoDB then run `npm run testbe` to execute the backend tests via [Mocha](https://mochajs.org/). +Run `npm run test:be` to execute the backend tests via [Jest](https://jestjs.io/) (it requires `mongod` already running). ## Running linters -Run `ng lint` to execute the frontend TS linting via [TSLint](https://github.com/palantir/tslint). - -Run `npm run lintbe` to execute the backend TS linting via [TSLint](https://github.com/palantir/tslint). - -Run `npm run linthtml` to execute the frontend HTML linting via [HTMLHint](https://github.com/htmlhint/HTMLHint). - -Run `npm run lintscss` to execute the frontend SCSS linting via [SASS-Lint](https://github.com/sasstools/sass-lint). +Run `npm run lint` to execute [Angular ESLint](https://github.com/angular-eslint/angular-eslint), [HTML linting](https://github.com/htmlhint/HTMLHint). ## Wiki To get more help about this project, [visit the official wiki](https://github.com/DavideViolante/Angular-Full-Stack/wiki). @@ -80,3 +77,9 @@ To get more help on the `angular-cli` use `ng --help` or go check out the [Angul ### Author * [Davide Violante](https://github.com/DavideViolante) + +### Contributors + + + + diff --git a/angular.json b/angular.json index 3aab3a4c..0087a038 100644 --- a/angular.json +++ b/angular.json @@ -3,149 +3,131 @@ "version": 1, "newProjectRoot": "projects", "projects": { - "angular2-full-stack": { - "root": "", - "sourceRoot": "client", + "angular-full-stack": { "projectType": "application", - "prefix": "app", "schematics": { "@schematics/angular:component": { - "styleext": "scss" + "style": "scss" } }, + "root": "", + "sourceRoot": "client", + "prefix": "app", "architect": { "build": { - "builder": "@angular-devkit/build-angular:browser", + "builder": "@angular/build:application", "options": { "outputPath": "dist/public", - "index": "client/index.html", - "main": "client/main.ts", - "polyfills": "client/polyfills.ts", - "tsConfig": "client/tsconfig.app.json", + "browser": "client/main.ts", + "tsConfig": "tsconfig.app.json", + "inlineStyleLanguage": "scss", "assets": [ + { + "glob": "**/*", + "input": "public" + }, "client/assets/favicon.ico", "client/assets" ], "styles": [ - "node_modules/bootstrap/dist/css/bootstrap.min.css", "node_modules/font-awesome/css/font-awesome.min.css", "client/styles.scss" ], "scripts": [ - "node_modules/jquery/dist/jquery.min.js", - "node_modules/tether/dist/js/tether.min.js", - "node_modules/bootstrap/dist/js/bootstrap.min.js" - ] + "node_modules/bootstrap/dist/js/bootstrap.bundle.min.js" + ], + "stylePreprocessorOptions": { + "sass": { + "silenceDeprecations": ["color-functions", "global-builtin", "import", "if-function"] + } + } }, "configurations": { "production": { - "fileReplacements": [ - { - "replace": "client/environments/environment.ts", - "with": "client/environments/environment.prod.ts" - } - ], - "optimization": true, - "outputHashing": "all", - "sourceMap": false, - "extractCss": true, - "namedChunks": false, - "aot": true, - "extractLicenses": true, - "vendorChunk": false, - "buildOptimizer": true, "budgets": [ { "type": "initial", - "maximumWarning": "2mb", - "maximumError": "5mb" + "maximumWarning": "750kB", + "maximumError": "1MB" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "2kB", + "maximumError": "4kB" } - ] + ], + "outputHashing": "all" + }, + "development": { + "optimization": false, + "extractLicenses": false, + "sourceMap": true } - } + }, + "defaultConfiguration": "production" }, "serve": { - "builder": "@angular-devkit/build-angular:dev-server", - "options": { - "browserTarget": "angular2-full-stack:build" - }, + "builder": "@angular/build:dev-server", "configurations": { "production": { - "browserTarget": "angular2-full-stack:build:production" + "buildTarget": "angular-full-stack:build:production" + }, + "development": { + "proxyConfig": "proxy.conf.json", + "buildTarget": "angular-full-stack:build:development" } - } + }, + "defaultConfiguration": "development" }, "extract-i18n": { - "builder": "@angular-devkit/build-angular:extract-i18n", - "options": { - "browserTarget": "angular2-full-stack:build" - } + "builder": "@angular/build:extract-i18n" }, "test": { - "builder": "@angular-devkit/build-angular:karma", - "options": { - "main": "client/test.ts", - "polyfills": "client/polyfills.ts", - "tsConfig": "client/tsconfig.spec.json", - "karmaConfig": "client/karma.conf.js", - "styles": [ - "node_modules/bootstrap/dist/css/bootstrap.min.css", - "node_modules/font-awesome/css/font-awesome.min.css", - "client/styles.scss" - ], - "scripts": [ - "node_modules/jquery/dist/jquery.min.js", - "node_modules/tether/dist/js/tether.min.js", - "node_modules/bootstrap/dist/js/bootstrap.min.js" - ], - "assets": [ - "client/assets/favicon.ico", - "client/assets" - ] - } - }, - "lint": { - "builder": "@angular-devkit/build-angular:tslint", - "options": { - "tsConfig": [ - "client/tsconfig.app.json", - "client/tsconfig.spec.json" - ], - "exclude": [ - "**/node_modules/**" - ] - } - } - } - }, - "angular2-full-stack-e2e": { - "root": "e2e/", - "projectType": "application", - "prefix": "", - "architect": { - "e2e": { - "builder": "@angular-devkit/build-angular:protractor", - "options": { - "protractorConfig": "e2e/protractor.conf.js", - "devServerTarget": "angular2-full-stack:serve" - }, - "configurations": { - "production": { - "devServerTarget": "angular2-full-stack:serve:production" - } - } + "builder": "@angular/build:unit-test" }, "lint": { - "builder": "@angular-devkit/build-angular:tslint", + "builder": "@angular-eslint/builder:lint", "options": { - "tsConfig": "e2e/tsconfig.e2e.json", - "exclude": [ - "**/node_modules/**" + "lintFilePatterns": [ + "client/**/*.ts", + "client/**/*.html", + "server/**/*.ts" ] } } } } }, - "defaultProject": "angular2-full-stack" -} \ No newline at end of file + "cli": { + "packageManager": "npm", + "schematicCollections": [ + "angular-eslint" + ] + }, + "schematics": { + "@schematics/angular:component": { + "type": "component" + }, + "@schematics/angular:directive": { + "type": "directive" + }, + "@schematics/angular:service": { + "type": "service" + }, + "@schematics/angular:guard": { + "typeSeparator": "." + }, + "@schematics/angular:interceptor": { + "typeSeparator": "." + }, + "@schematics/angular:module": { + "typeSeparator": "." + }, + "@schematics/angular:pipe": { + "typeSeparator": "." + }, + "@schematics/angular:resolver": { + "typeSeparator": "." + } + } +} diff --git a/client/app/about/about.component.spec.ts b/client/app/about/about.component.spec.ts index f96cf116..9b5cf19c 100644 --- a/client/app/about/about.component.spec.ts +++ b/client/app/about/about.component.spec.ts @@ -1,32 +1,27 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; +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(async(() => { - 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 string "About" in h4', () => { - const el = fixture.debugElement.query(By.css('h4')).nativeElement; - expect(el.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 bc100bd0..ccf946df 100644 --- a/client/app/about/about.component.ts +++ b/client/app/about/about.component.ts @@ -3,10 +3,8 @@ import { Component } from '@angular/core'; @Component({ selector: 'app-about', templateUrl: './about.component.html', - styleUrls: ['./about.component.scss'] + styleUrls: ['./about.component.scss'], }) export class AboutComponent { - constructor() { } - } diff --git a/client/app/account/account.component.html b/client/app/account/account.component.html index 26c1e7f0..08e757fd 100644 --- a/client/app/account/account.component.html +++ b/client/app/account/account.component.html @@ -1,44 +1,43 @@ - + - + -
-

Account settings

-
-
-
-
+@if (!isLoading()) { +
+

Account settings

+
+ +
+
- -
-
-
+
+
- -
-
-
+
+
- -
- - + + +
-
\ No newline at end of file +} \ No newline at end of file diff --git a/client/app/account/account.component.spec.ts b/client/app/account/account.component.spec.ts index 5edf261b..9b76f886 100644 --- a/client/app/account/account.component.spec.ts +++ b/client/app/account/account.component.spec.ts @@ -1,25 +1,64 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Observable, of } from 'rxjs'; import { AccountComponent } from './account.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'; -describe('AccountComponent', () => { - let component: AccountComponent; - let fixture: ComponentFixture; +class AuthServiceMock { + currentUser = signal(new User()); +} +class UserServiceMock { + mockUser = { + username: 'Test user', + email: 'test@example.com', + role: 'user' + }; + getUser(): Observable { + return of(this.mockUser); + } +} - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ AccountComponent ] - }) - .compileComponents(); - })); +describe('Component: Account', () => { + let fixture: ComponentFixture; + let compiled: HTMLElement; - beforeEach(() => { + beforeEach(async() => { + await TestBed.configureTestingModule({ + imports: [AccountComponent], + providers: [ + ToastService, + { provide: AuthService, useClass: AuthServiceMock }, + { provide: UserService, useClass: UserServiceMock }, + ] + }).compileComponents(); + fixture = TestBed.createComponent(AccountComponent); - component = fixture.componentInstance; - 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 render the header', () => { + expect(compiled.querySelector('.card-header')?.textContent).toContain('Account settings'); + }); + + 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'); + }); + + it('should display the save button enabled', () => { + const button = compiled.querySelector('button'); + expect(button).toBeTruthy(); + expect(button?.disabled).toBeFalsy(); + }); }); diff --git a/client/app/account/account.component.ts b/client/app/account/account.component.ts index e2b968c4..c9d000be 100644 --- a/client/app/account/account.component.ts +++ b/client/app/account/account.component.ts @@ -1,39 +1,55 @@ -import { Component, OnInit } 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' + templateUrl: './account.component.html', + imports: [FormsModule, ToastComponent, LoadingComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class AccountComponent implements OnInit { + private auth = inject(AuthService); + private toast = inject(ToastService); + private userService = inject(UserService); - user: User; - isLoading = true; - - constructor(private auth: AuthService, - public toast: ToastComponent, - private userService: UserService) { } + user = signal(new User()); + isLoading = signal(true); - ngOnInit() { + ngOnInit(): void { this.getUser(); } - getUser() { - this.userService.getUser(this.auth.currentUser).subscribe( - data => this.user = data, - error => console.log(error), - () => this.isLoading = false - ); + getUser(): void { + 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(): void { + const user = this.user(); + this.userService.editUser(user).subscribe({ + next: res => { + this.toast.setMessage('Account settings saved!', 'success'); + const decodedUser = this.auth.decodeUserFromToken(res.token); + this.auth.setCurrentUser(decodedUser); + localStorage.setItem('token', res.token); + }, + error: error => console.error(error) + }); } - save(user: User) { - this.userService.editUser(user).subscribe( - res => this.toast.setMessage('account settings saved!', 'success'), - error => console.log(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.html b/client/app/add-cat-form/add-cat-form.component.html new file mode 100644 index 00000000..a03c4ce5 --- /dev/null +++ b/client/app/add-cat-form/add-cat-form.component.html @@ -0,0 +1,26 @@ +
+

Add new cat

+
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
diff --git a/client/app/add-cat-form/add-cat-form.component.scss b/client/app/add-cat-form/add-cat-form.component.scss new file mode 100644 index 00000000..e69de29b 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 new file mode 100644 index 00000000..17e0b7e3 --- /dev/null +++ b/client/app/add-cat-form/add-cat-form.component.spec.ts @@ -0,0 +1,48 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule, ReactiveFormsModule, UntypedFormBuilder } from '@angular/forms'; + +import { ToastService } from '../shared/toast/toast.service'; +import { CatService } from '../services/cat.service'; +import { AddCatFormComponent } from './add-cat-form.component'; + +describe('Component: AddCatForm', () => { + let component: AddCatFormComponent; + let fixture: ComponentFixture; + let compiled: HTMLElement; + + beforeEach(async() => { + await TestBed.configureTestingModule({ + imports: [AddCatFormComponent, FormsModule, ReactiveFormsModule], + providers: [UntypedFormBuilder, ToastService, CatService] + }).compileComponents(); + + fixture = TestBed.createComponent(AddCatFormComponent); + component = fixture.componentInstance; + await fixture.whenStable(); + compiled = fixture.nativeElement as HTMLElement; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display header text', () => { + const header = compiled.querySelector('.card-header'); + expect(header?.textContent).toContain('Add new cat'); + }); + + it('should display the add form', () => { + const form = compiled.querySelector('form'); + expect(form).toBeTruthy(); + const inputs = compiled.querySelectorAll('input'); + expect(inputs[0]).toBeTruthy(); + expect(inputs[1]).toBeTruthy(); + expect(inputs[2]).toBeTruthy(); + expect(inputs[0].value).toBeFalsy(); + expect(inputs[1].value).toBeFalsy(); + expect(inputs[2].value).toBeFalsy(); + const button = compiled.querySelector('button'); + expect(button).toBeTruthy(); + }); + +}); diff --git a/client/app/add-cat-form/add-cat-form.component.ts b/client/app/add-cat-form/add-cat-form.component.ts new file mode 100644 index 00000000..1353ff98 --- /dev/null +++ b/client/app/add-cat-form/add-cat-form.component.ts @@ -0,0 +1,46 @@ +import { Component, inject, output } from '@angular/core'; +import { UntypedFormGroup, UntypedFormControl, Validators, UntypedFormBuilder, ReactiveFormsModule } from '@angular/forms'; + +import { CatService } from '../services/cat.service'; +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'], + imports: [ReactiveFormsModule] +}) + +export class AddCatFormComponent { + private catService = inject(CatService); + private formBuilder = inject(UntypedFormBuilder); + private toast = inject(ToastService); + + catAdded = output(); + + addCatForm: UntypedFormGroup; + name = new UntypedFormControl('', Validators.required); + age = new UntypedFormControl('', Validators.required); + weight = new UntypedFormControl('', Validators.required); + + constructor() { + this.addCatForm = this.formBuilder.group({ + name: this.name, + age: this.age, + weight: this.weight + }); + } + + addCat(): void { + this.catService.addCat(this.addCatForm.value).subscribe({ + next: res => { + this.catAdded.emit(res); + this.addCatForm.reset(); + this.toast.setMessage('Item added successfully.', 'success'); + }, + error: error => console.error(error), + }); + } + +} diff --git a/client/app/admin/admin.component.html b/client/app/admin/admin.component.html index bf84171c..57c3ac2b 100644 --- a/client/app/admin/admin.component.html +++ b/client/app/admin/admin.component.html @@ -1,37 +1,43 @@ - + - + -
-

Registered users ({{users.length}})

-
- - - - - - - - - - - - - - - - - - - - - - -
UsernameEmailRoleActions
There are no registered users.
{{user.username}}{{user.email}}{{user.role}} - -
+@if (!isLoading()) { +
+

Registered users ({{usersCount()}})

+
+ + + + + + + + + + @if (usersCount() === 0) { + + + + + + } + + @for (user of users(); track user) { + + + + + + + } + +
UsernameEmailRoleActions
There are no registered users.
{{user.username}}{{user.email}}{{user.role}} + +
+
-
\ No newline at end of file +} \ No newline at end of file diff --git a/client/app/admin/admin.component.spec.ts b/client/app/admin/admin.component.spec.ts index cf70e680..c3246eb9 100644 --- a/client/app/admin/admin.component.spec.ts +++ b/client/app/admin/admin.component.spec.ts @@ -1,25 +1,82 @@ -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of, Observable } from 'rxjs'; +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'; -describe('AdminComponent', () => { +class AuthServiceMock { + currentUser = signal({ _id: '1', username: 'test1@example.com', role: 'admin' }); +} + +class UserServiceMock { + mockUsers = [ + { _id: '1', username: 'Test 1', email: 'test1@example.com', role: 'admin' }, + { _id: '2', username: 'Test 2', email: 'test2@example.com', role: 'user' }, + ]; + getUsers(): Observable<{_id: string; username: string; email: string; role: string;}[]> { + return of(this.mockUsers); + } +} + +describe('Component: Admin', () => { let component: AdminComponent; let fixture: ComponentFixture; + let compiled: HTMLElement; - beforeEach(async(() => { - TestBed.configureTestingModule({ - declarations: [ AdminComponent ] - }) - .compileComponents(); - })); + beforeEach(async() => { + await TestBed.configureTestingModule({ + imports: [AdminComponent], + providers: [ + ToastService, + { provide: AuthService, useClass: AuthServiceMock }, + { provide: UserService, useClass: UserServiceMock }, + ], + }).compileComponents(); - beforeEach(() => { fixture = TestBed.createComponent(AdminComponent); component = fixture.componentInstance; - fixture.detectChanges(); + await fixture.whenStable(); + compiled = fixture.nativeElement as HTMLElement; }); - /*it('should create', () => { + it('should create', () => { expect(component).toBeTruthy(); - });*/ + }); + + it('should display the page header text', () => { + const header = compiled.querySelector('.card-header'); + expect(header?.textContent).toContain('Registered users (2)'); + }); + + it('should display the text for no users', () => { + component.users.set([]); + fixture.detectChanges(); + const header = compiled.querySelector('h4'); + expect(header?.textContent).toContain('Registered users (0)'); + const td = compiled.querySelector('td'); + expect(td?.textContent).toContain('There are no registered users'); + }); + + it('should display registered users', () => { + const tds = compiled.querySelectorAll('td'); + expect(tds[0].textContent).toContain('Test 1'); + expect(tds[1].textContent).toContain('test1@example.com'); + expect(tds[2].textContent).toContain('admin'); + expect(tds[4].textContent).toContain('Test 2'); + expect(tds[5].textContent).toContain('test2@example.com'); + expect(tds[6].textContent).toContain('user'); + }); + + it('should display the delete buttons', () => { + const buttons = compiled.querySelectorAll('button'); + expect(buttons[0].disabled).toBeTruthy(); + expect(buttons[0].textContent).toContain('Delete'); + expect(buttons[1].disabled).toBeFalsy(); + expect(buttons[1].textContent).toContain('Delete'); + }); + }); diff --git a/client/app/admin/admin.component.ts b/client/app/admin/admin.component.ts index f42a1137..17861f45 100644 --- a/client/app/admin/admin.component.ts +++ b/client/app/admin/admin.component.ts @@ -1,42 +1,48 @@ -import { Component, OnInit } 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' + templateUrl: './admin.component.html', + imports: [ToastComponent, LoadingComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class AdminComponent implements OnInit { + auth = inject(AuthService); + private toast = inject(ToastService); + private userService = inject(UserService); - users: User[] = []; - isLoading = true; + users = signal([]); + isLoading = signal(true); - constructor(public auth: AuthService, - public toast: ToastComponent, - private userService: UserService) { } + usersCount = computed(() => this.users().length); - ngOnInit() { + ngOnInit(): void { this.getUsers(); } - getUsers() { - this.userService.getUsers().subscribe( - data => this.users = data, - error => console.log(error), - () => this.isLoading = false - ); + getUsers(): void { + this.isLoading.set(true); + this.userService.getUsers().subscribe({ + next: data => this.users.set(data), + error: error => console.error(error), + complete: () => this.isLoading.set(false) + }); } - deleteUser(user: User) { - if (window.confirm('Are you sure you want to delete ' + user.username + '?')) { - this.userService.deleteUser(user).subscribe( - data => this.toast.setMessage('user deleted successfully.', 'success'), - error => console.log(error), - () => this.getUsers() - ); + deleteUser(user: User): void { + if (window.confirm(`Are you sure you want to delete ${user.username}?`)) { + this.userService.deleteUser(user).subscribe({ + 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.html b/client/app/app.component.html deleted file mode 100644 index b99ce6b9..00000000 --- a/client/app/app.component.html +++ /dev/null @@ -1,52 +0,0 @@ - \ No newline at end of file diff --git a/client/app/app.component.spec.ts b/client/app/app.component.spec.ts deleted file mode 100644 index 4e1c172f..00000000 --- a/client/app/app.component.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { NO_ERRORS_SCHEMA } from '@angular/core'; -import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; - -import { AppComponent } from './app.component'; -import { AuthService } from './services/auth.service'; - -describe('Component: App', () => { - let component: AppComponent; - let fixture: ComponentFixture; - let authService: AuthService; - let authServiceStub: { - loggedIn: boolean, - isAdmin: boolean, - currentUser: any - }; - - beforeEach(async(() => { - authServiceStub = { - loggedIn: false, - isAdmin: false, - currentUser: { username: 'Tester' } - }; - TestBed.configureTestingModule({ - declarations: [ AppComponent ], - providers: [ { provide: AuthService, useValue: authServiceStub } ], - schemas: [ NO_ERRORS_SCHEMA ] - }) - .compileComponents().then(() => { - fixture = TestBed.createComponent(AppComponent); - component = fixture.componentInstance; - authService = fixture.debugElement.injector.get(AuthService); - fixture.detectChanges(); - }); - })); - - it('should create the app', async(() => { - expect(component).toBeTruthy(); - })); - - it('should display the navigation bar correctly for guests', () => { - const de = fixture.debugElement.queryAll(By.css('a')); - expect(de.length).toBe(4); - expect(de[0].nativeElement.textContent).toContain('Home'); - expect(de[1].nativeElement.textContent).toContain('Cats'); - expect(de[2].nativeElement.textContent).toContain('Login'); - expect(de[3].nativeElement.textContent).toContain('Register'); - expect(de[0].attributes['routerLink']).toBe('/'); - expect(de[1].attributes['routerLink']).toBe('/cats'); - expect(de[2].attributes['routerLink']).toBe('/login'); - expect(de[3].attributes['routerLink']).toBe('/register'); - }); - - it('should display the navigation bar correctly for logged users', () => { - authService.loggedIn = true; - fixture.detectChanges(); - const de = fixture.debugElement.queryAll(By.css('a')); - expect(de.length).toBe(4); - expect(de[0].nativeElement.textContent).toContain('Home'); - expect(de[1].nativeElement.textContent).toContain('Cats'); - expect(de[2].nativeElement.textContent).toContain('Account (Tester)'); - expect(de[3].nativeElement.textContent).toContain('Logout'); - expect(de[0].attributes['routerLink']).toBe('/'); - expect(de[1].attributes['routerLink']).toBe('/cats'); - expect(de[2].attributes['routerLink']).toBe('/account'); - expect(de[3].attributes['routerLink']).toBe('/logout'); - }); - - it('should display the navigation bar correctly for admin users', () => { - authService.loggedIn = true; - authService.isAdmin = true; - fixture.detectChanges(); - const de = fixture.debugElement.queryAll(By.css('a')); - expect(de.length).toBe(5); - expect(de[0].nativeElement.textContent).toContain('Home'); - expect(de[1].nativeElement.textContent).toContain('Cats'); - expect(de[2].nativeElement.textContent).toContain('Account (Tester)'); - expect(de[3].nativeElement.textContent).toContain('Admin'); - expect(de[4].nativeElement.textContent).toContain('Logout'); - expect(de[0].attributes['routerLink']).toBe('/'); - expect(de[1].attributes['routerLink']).toBe('/cats'); - expect(de[2].attributes['routerLink']).toBe('/account'); - expect(de[3].attributes['routerLink']).toBe('/admin'); - expect(de[4].attributes['routerLink']).toBe('/logout'); - }); - -}); diff --git a/client/app/app.component.ts b/client/app/app.component.ts deleted file mode 100644 index 978111ff..00000000 --- a/client/app/app.component.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { AfterViewChecked, ChangeDetectorRef, Component } from '@angular/core'; -import { AuthService } from './services/auth.service'; - -@Component({ - selector: 'app-root', - templateUrl: './app.component.html' -}) -export class AppComponent implements AfterViewChecked { - - constructor(public auth: AuthService, - private changeDetector: ChangeDetectorRef) { } - - // This fixes: https://github.com/DavideViolante/Angular-Full-Stack/issues/105 - ngAfterViewChecked() { - this.changeDetector.detectChanges(); - } - -} 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.html b/client/app/app.html new file mode 100644 index 00000000..882ce139 --- /dev/null +++ b/client/app/app.html @@ -0,0 +1,62 @@ +
+ + + + + +
\ No newline at end of file diff --git a/client/app/app.module.ts b/client/app/app.module.ts deleted file mode 100644 index 51be1c1a..00000000 --- a/client/app/app.module.ts +++ /dev/null @@ -1,61 +0,0 @@ -// Angular -import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; -import { JwtModule } from '@auth0/angular-jwt'; -// Modules -import { AppRoutingModule } from './app-routing.module'; -import { SharedModule } from './shared/shared.module'; -// 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'; -// Components -import { AppComponent } from './app.component'; -import { CatsComponent } from './cats/cats.component'; -import { AboutComponent } from './about/about.component'; -import { RegisterComponent } from './register/register.component'; -import { LoginComponent } from './login/login.component'; -import { LogoutComponent } from './logout/logout.component'; -import { AccountComponent } from './account/account.component'; -import { AdminComponent } from './admin/admin.component'; -import { NotFoundComponent } from './not-found/not-found.component'; - -export function tokenGetter() { - return localStorage.getItem('token'); -} - -@NgModule({ - declarations: [ - AppComponent, - CatsComponent, - AboutComponent, - RegisterComponent, - LoginComponent, - LogoutComponent, - AccountComponent, - AdminComponent, - NotFoundComponent - ], - imports: [ - AppRoutingModule, - SharedModule, - JwtModule.forRoot({ - config: { - tokenGetter: tokenGetter, - // whitelistedDomains: ['localhost:3000', 'localhost:4200'] - } - }) - ], - providers: [ - AuthService, - AuthGuardLogin, - AuthGuardAdmin, - CatService, - UserService - ], - schemas: [CUSTOM_ELEMENTS_SCHEMA], - bootstrap: [AppComponent] -}) - -export class AppModule { } diff --git a/client/app/app-routing.module.ts b/client/app/app.routes.ts similarity index 82% rename from client/app/app-routing.module.ts rename to client/app/app.routes.ts index ff833a1d..30c1eaec 100644 --- a/client/app/app-routing.module.ts +++ b/client/app/app.routes.ts @@ -1,6 +1,5 @@ -// Angular -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; +import { Routes } from '@angular/router'; + // Services import { AuthGuardLogin } from './services/auth-guard-login.service'; import { AuthGuardAdmin } from './services/auth-guard-admin.service'; @@ -14,7 +13,7 @@ import { AccountComponent } from './account/account.component'; import { AdminComponent } from './admin/admin.component'; import { NotFoundComponent } from './not-found/not-found.component'; -const routes: Routes = [ +export const routes: Routes = [ { path: '', component: AboutComponent }, { path: 'cats', component: CatsComponent }, { path: 'register', component: RegisterComponent }, @@ -25,10 +24,3 @@ const routes: Routes = [ { path: 'notfound', component: NotFoundComponent }, { path: '**', redirectTo: '/notfound' }, ]; - -@NgModule({ - imports: [RouterModule.forRoot(routes)], - exports: [RouterModule] -}) - -export class AppRoutingModule {} diff --git a/client/app/app.spec.ts b/client/app/app.spec.ts new file mode 100644 index 00000000..d9f4e1b4 --- /dev/null +++ b/client/app/app.spec.ts @@ -0,0 +1,86 @@ +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { App } from './app'; +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'; + +class AuthServiceMock { + currentUser = signal({ _id: '1', username: 'test1@example.com', role: 'user' }); + loggedIn = signal(true); + isAdmin = signal(false); + + login(): void { + this.loggedIn.set(true); + } + logout(): void { + this.loggedIn.set(false); + } +} + +describe('App', () => { + let fixture: ComponentFixture; + let compiled: HTMLElement; + let authService: AuthService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [App, RouterTestingModule], // @todo replace deprecated + providers: [ + ToastService, + UserService, + { provide: AuthService, useClass: AuthServiceMock }, + ] + }).compileComponents(); + + fixture = TestBed.createComponent(App); + compiled = fixture.nativeElement as HTMLElement; + authService = fixture.debugElement.injector.get(AuthService); + await fixture.whenStable(); + }); + + it('should create the app', () => { + const app = fixture.componentInstance; + expect(app).toBeTruthy(); + }); + + it('should render nav links', () => { + authService.loggedIn.set(false); + fixture.detectChanges(); + expect(authService.loggedIn()).toBeFalsy(); + const navLinks = compiled.querySelectorAll('.nav-link'); + expect(navLinks[0]?.textContent).toContain('Home'); + expect(navLinks[1]?.textContent).toContain('Cats'); + expect(navLinks[2]?.textContent).toContain('Login'); + expect(navLinks[3]?.textContent).toContain('Register'); + }); + + it('should render nav links as logged in', () => { + authService.loggedIn.set(true); + fixture.detectChanges(); + expect(authService.loggedIn()).toBeTruthy(); + const navLinks = compiled.querySelectorAll('.nav-link'); + expect(navLinks[0]?.textContent).toContain('Home'); + expect(navLinks[1]?.textContent).toContain('Cats'); + expect(navLinks[2]?.textContent).toContain('Account'); + expect(navLinks[3]?.textContent).toContain('Logout'); + }); + + it('should render nav links as logged in as admin', () => { + authService.loggedIn.set(true); + authService.isAdmin.set(true); + fixture.detectChanges(); + expect(authService.loggedIn()).toBeTruthy(); + expect(authService.isAdmin()).toBeTruthy(); + const navLinks = compiled.querySelectorAll('.nav-link'); + expect(navLinks[0]?.textContent).toContain('Home'); + expect(navLinks[1]?.textContent).toContain('Cats'); + expect(navLinks[2]?.textContent).toContain('Account'); + expect(navLinks[3]?.textContent).toContain('Admin'); + expect(navLinks[4]?.textContent).toContain('Logout'); + }); + +}); diff --git a/client/app/app.ts b/client/app/app.ts new file mode 100644 index 00000000..1d05e53d --- /dev/null +++ b/client/app/app.ts @@ -0,0 +1,22 @@ +import { AfterViewChecked, ChangeDetectorRef, Component, inject } from '@angular/core'; +import { RouterModule, RouterOutlet } from '@angular/router'; + +import { AuthService } from './services/auth.service'; +import { ToastService } from './shared/toast/toast.service'; + +@Component({ + selector: 'app-root', + imports: [RouterOutlet, RouterModule], + providers: [ToastService], + templateUrl: './app.html', +}) +export class App implements AfterViewChecked { + auth = inject(AuthService); + private changeDetector = inject(ChangeDetectorRef); + + // This fixes: https://github.com/DavideViolante/Angular-Full-Stack/issues/105 + ngAfterViewChecked(): void { + this.changeDetector.detectChanges(); + } + +} diff --git a/client/app/cats/cats.component.html b/client/app/cats/cats.component.html index b9ffc65e..9d8b616a 100644 --- a/client/app/cats/cats.component.html +++ b/client/app/cats/cats.component.html @@ -1,76 +1,86 @@ - + - + -
-

Current cats ({{cats.length}})

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameAgeWeightActions
There are no cats in the DB. Add a new cat below.
{{cat.name}}{{cat.age}}{{cat.weight}} - - -
-
- - - - - -
-
+@if (!isLoading()) { +
+

Current cats ({{catsCount()}})

+
+ + + + + + + + + + @if (catsCount() === 0) { + + + + + + } + @if (!isEditing()) { + + @for (cat of cats(); track cat) { + + + + + + + } + + } + @if (isEditing()) { + + + + + + } +
NameAgeWeightActions
There are no cats in the DB. Add a new cat below.
{{cat.name}}{{cat.age}}{{cat.weight}} + + +
+
+
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+
+
-
+} -
-

Add new cat

-
-
- - - - -
-
-
\ No newline at end of file +@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 88176f25..8f88c89a 100644 --- a/client/app/cats/cats.component.spec.ts +++ b/client/app/cats/cats.component.spec.ts @@ -1,9 +1,95 @@ -import { TestBed, async } 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 { CatService } from '../services/cat.service'; +import { ToastService } from '../shared/toast/toast.service'; import { CatsComponent } from './cats.component'; -/*describe('Component: Cats', () => { - it('should create an instance', () => { - let component = new CatsComponent(); +class CatServiceMock { + mockCats = [ + { name: 'Cat 1', age: 1, weight: 2 }, + { name: 'Cat 2', age: 3, weight: 4.2 }, + ]; + getCats(): Observable<{name: string; age: number; weight: number}[]> { + return of(this.mockCats); + } +} + +describe('Component: Cats', () => { + let component: CatsComponent; + let fixture: ComponentFixture; + let compiled: HTMLElement; + + beforeEach(async() => { + await TestBed.configureTestingModule({ + imports: [CatsComponent, RouterTestingModule, FormsModule, ReactiveFormsModule], + providers: [ + ToastService, + UntypedFormBuilder, + { provide: CatService, useClass: CatServiceMock } + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CatsComponent); + component = fixture.componentInstance; + await fixture.whenStable(); + compiled = fixture.nativeElement as HTMLElement; + }); + + it('should create', () => { expect(component).toBeTruthy(); }); -});*/ + + it('should display the page header text', () => { + const header = compiled.querySelector('.card-header'); + expect(header?.textContent).toContain('Current cats (2)'); + }); + + it('should display the text for no cats', () => { + component.cats.set([]); + fixture.detectChanges(); + const header = compiled.querySelector('.card-header'); + expect(header?.textContent).toContain('Current cats (0)'); + const td = compiled.querySelector('td'); + expect(td?.textContent).toContain('There are no cats in the DB. Add a new cat below.'); + }); + + it('should display current cats', () => { + const tds = compiled.querySelectorAll('td'); + expect(tds.length).toBe(8); + expect(tds[0].textContent).toContain('Cat 1'); + expect(tds[1].textContent).toContain('1'); + expect(tds[2].textContent).toContain('2'); + expect(tds[4].textContent).toContain('Cat 2'); + expect(tds[5].textContent).toContain('3'); + expect(tds[6].textContent).toContain('4.2'); + }); + + it('should display the edit and delete buttons', () => { + const buttons = compiled.querySelectorAll('button'); + expect(buttons[0].textContent).toContain('Edit'); + expect(buttons[1].textContent).toContain('Delete'); + expect(buttons[2].textContent).toContain('Edit'); + expect(buttons[3].textContent).toContain('Delete'); + }); + + it('should display the edit form', async () => { + 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); + const form = compiled.querySelector('form'); + expect(form).toBeTruthy(); + const inputs = compiled.querySelectorAll('input'); + expect(inputs[0].value).toContain('Cat 1'); + expect(inputs[1].value).toContain('1'); + expect(inputs[2].value).toContain('2'); + const buttons = compiled.querySelectorAll('button'); + expect(buttons[0].textContent).toContain('Save'); + expect(buttons[1].textContent).toContain('Cancel'); + }); + +}); diff --git a/client/app/cats/cats.component.ts b/client/app/cats/cats.component.ts index ca5578dd..94c3d9a4 100644 --- a/client/app/cats/cats.component.ts +++ b/client/app/cats/cats.component.ts @@ -1,94 +1,86 @@ -import { Component, OnInit } from '@angular/core'; -import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; +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'] + styleUrls: ['./cats.component.scss'], + imports: [FormsModule, AddCatFormComponent, ToastComponent, LoadingComponent], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class CatsComponent implements OnInit { + private catService = inject(CatService); + private toast = inject(ToastService); - cat = new Cat(); - cats: Cat[] = []; - isLoading = true; - isEditing = false; + cat = signal(new Cat()); + cats = signal([]); + isLoading = signal(true); + isEditing = signal(false); - addCatForm: FormGroup; - name = new FormControl('', Validators.required); - age = new FormControl('', Validators.required); - weight = new FormControl('', Validators.required); + catsCount = computed(() => this.cats().length); - constructor(private catService: CatService, - private formBuilder: FormBuilder, - public toast: ToastComponent) { } - - ngOnInit() { + ngOnInit(): void { this.getCats(); - this.addCatForm = this.formBuilder.group({ - name: this.name, - age: this.age, - weight: this.weight - }); - } - - getCats() { - this.catService.getCats().subscribe( - data => this.cats = data, - error => console.log(error), - () => this.isLoading = false - ); } - addCat() { - this.catService.addCat(this.addCatForm.value).subscribe( - res => { - this.cats.push(res); - this.addCatForm.reset(); - this.toast.setMessage('item added successfully.', 'success'); - }, - error => console.log(error) - ); + getCats(): void { + this.isLoading.set(true); + this.catService.getCats().subscribe({ + next: data => this.cats.set(data), + error: error => console.error(error), + complete: () => this.isLoading.set(false) + }); } - enableEditing(cat: Cat) { - this.isEditing = true; - this.cat = cat; + enableEditing(cat: Cat): void { + this.isEditing.set(true); + this.cat.set({ ...cat }); } - cancelEditing() { - this.isEditing = false; - this.cat = new Cat(); - this.toast.setMessage('item editing cancelled.', 'warning'); - // reload the cats to reset the editing - this.getCats(); + 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'); } - editCat(cat: Cat) { - this.catService.editCat(cat).subscribe( - () => { - this.isEditing = false; - this.cat = cat; - this.toast.setMessage('item edited successfully.', 'success'); + editCat(cat: Cat): void { + this.catService.editCat(cat).subscribe({ + next: () => { + 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 => console.log(error) - ); + error: error => console.error(error) + }); } - deleteCat(cat: Cat) { + deleteCat(cat: Cat): void { if (window.confirm('Are you sure you want to permanently delete this item?')) { - this.catService.deleteCat(cat).subscribe( - () => { - const pos = this.cats.map(elem => elem._id).indexOf(cat._id); - this.cats.splice(pos, 1); - this.toast.setMessage('item deleted successfully.', 'success'); + this.catService.deleteCat(cat).subscribe({ + next: () => { + this.cats.update(list => list.filter(elem => elem._id !== cat._id)); + this.toast.setMessage('Item deleted successfully.', 'success'); }, - 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 b4b84ca0..00ecaf96 100644 --- a/client/app/login/login.component.html +++ b/client/app/login/login.component.html @@ -1,26 +1,22 @@ - +

Login

-
- - - -
+ + + + formControlName="email" placeholder="Email" autocomplete="email">
-
- - - -
+ + + + formControlName="password" placeholder="Password" autocomplete="current-password">