English English

MEAN Stack - Introduction into creating a MEAN stack app with Angular 9

This tutorial will show you how to create a "MEAN stack" (app stack) app with backwards compatibility for older browsers.
We will create a web app which does "CRUD" (stands for: create, replace, update and delete) operations.

A "MEAN" stack app has a "backend" which uses "NodeJS" and a "frontend" that is uses "Angular". "Express" is a server which allows the access of the "backend" from outside (the "frontend"). "MongoDB" is used as a database, which is a NoSQL databases that saves data in text files ("JSON" format).
The programming language "JavaScript" is used with "NodeJS" framework in the backend. The "frontend" uses also "TypeScript" which is similar to "JavaScript" and it has full support of data types.
"TypeScript" has also more functionality. More information about "TypeScript":
https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html

 

Requirements

You need to install MongoDB:
https://www.mongodb.com/download-center/community
MongoDB must be already configured. MongoDB is running on localhost on the same machine in this tutorial.

You need to install NodeJS:
https://nodejs.org/en/download/

You need to install Angular CLI:

npm install -g @angular/cli


Now we can start to create our app. Other dependencies will be installed later.
Please create a project folder for our "MEAN" stack app. We will call this app "mean_crud_example".

 

NodeJS - Backend

We will start with the "backend" of our application. You need to setup the "backend" with the "npm" package manager of your "NodeJS" installation.

Create a folder called "backend".

mkdir backend


Run this command in that folder:

npm init

You will be asked for the configuration of your "NodeJS" app. But you can use the default settings. Our "NodeJS" application code is saved mainly in the file "server.js", which we will create later.

 

We will use "Babel" to ensure backwards compatibility of our "app". Because of "Babel" we can also use "Javascript ES6" code in our "NodeJS" app.
Please install the following packages:

npm install --save-dev @babel/core @babel/cli @babel/preset-env babel-watch
npm install --save @babel/polyfill



"Babel" needs the ".babelrc" configuration file, which should be in the root folder of our "backend".
The ".babelrc" file must have the following content:

{
"presets": [
"@babel/preset-env"
]
}

You can customize the functionality of Babel in this configuration form. You can configure for example which browser version should be targeted (command: "targets"). If you want to know more about ".babelrc" or "babel.config.json" (for large projects), then please go to the "Babel" documentation:
https://babeljs.io/docs/en/config-files

You also need to edit your "package.json" file to make "babel-watch" start automatically. You have to add the following to your "package.json" file:

"scripts": {
"dev": "babel-watch server.js",
"build": "babel src -d build"
}

This creates also the shortcut command "dev" which is linked to our "babel-watch" program call. You can now use the shortcut like this: "npm run dev".
If you run that shortcut command, then every saved change of our "server.js" file is converted automatically to a backward compatible code (compatible for "NodeJS server").

 

Express framework - Backend

"Express" will be used as a middleware between our backend "NodeJS" and frontend "Angular". You need to install the dependency "express":

npm install express


The dependency "cors" will be installed to improve the security of the app. "body-parser" is used to parse incoming "HTTP requests" in our "Express" server.

npm install cors body-parser

This dependency allows the configuration of CORS (Cross-Origin Resource Sharing), which defines the external domain names that can be used in this app. Hacker cannot then inject scripts from other sites, because with "cors" only certain added sites can connect to this app.

It is time now to create the "server.js" file with the following content:

import express from 'express';
import cors from 'cors';
import mongoose from 'mongoose';
import bodyParser from 'body-parser';

const app = express();
const router = express.Router();

const DATABASE_CONNECTION = "mongodb://127.0.0.1/tasks";

app.use(cors());
app.use(bodyParser.json());

mongoose.connect(DATABASE_CONNECTION);
const connection = mongoose.connection;
connection.once("open", () => {
    console.log("Mongoose (MongoDB) database connection was created successfully!");
});

 

Add also the code that sets up the "Express" Server

app.use('/api', router);
app.listen(4000, () => console.log("Your Express server runs on the port 4000"));

You have to enter your full "mongodb" database server address with the database name (here: "tasks") in the variable "DATABASE_CONNECTION". This code creates a database connection to your "Mongodb" server. If the connection was established successfully, then you will the string ""Mongoose (MongoDB) database connection was created successfully!" will be displayed.
After that the "Express" Server listens on the page "/api" for the accessed "routes" of our "REST API" in the "Express" server. The API will be created later.

Info: If you want to host this app on "Heroku", then use the following code instead:

app.get('*', (req, res) => {
    res.sendFile(path.join(__dirname, 'public/index.html'));
});
const expressServerPort = process.env.PORT || 5000;
app.listen(expressServerPort, () => console.log("Express Server is currently running on the port " + expressServerPort));

 

MongoDB - Database Backend

Now we will configure the database of our app. If you did not install "mongodb", then please install it with this command:

npm install mongoose


In "mongodb" there are "collections" which are the equivalent of tables (in SQL). A "collection" contains documents which are the equivalent of rows (in SQL).
A "collection" in your "MongoDB" database can be added by creating a "schema" in your app. Please create the folder "model", which will have the "schemas" of this app.

Please create the file "Tasks.js" with the following content:

import mongoose from 'mongoose';

const Schema = mongoose.Schema;

let Tasks = new Schema({
title: {
type: String,
required: [true, "Please enter a task title"]
},
description: {
type: String 
},
priority: {
type: Number,
default: 0
}
});

export default mongoose.model('Tasks', Tasks);

This "schema" has a required field "title". When the required field is empty, then you will get the defined error message. The field "priority" has a default value of 0.

Please import this "schema" in the file "server.js", because we will use it to access data in the database of this app:

import Tasks from './model/Tasks';

 

Creating a REST API to access database data

You can create a "REST API" by using "routes" in your "NodeJS" app. You use "HTTP" commands "GET" and "POST" to retrieve and send database data. This "REST API" is accessed then through "Angular Services", which will be created later in the "frontend" part of this tutorial.

"HTTP GET" is used here to query a certain task or tasks. But also to delete a certain task or tasks. You pass all arguments in the URL (full address) link.
Example: "/api/task/4" - "4" is the id of the task.

"HTTP POST" is used to save or edit a task. All values and arguments are not added in the URL. But they are added in the data of the website ("HTML form").

The database call is done in the "route" command. The imported schema class is used to call a "MongoDB" function. The "MongoDB" function gets a callback function which is called when data was retourned from the database. Asynchronous programming is used in this case.

Please create the following "routes" for the "REST API" of this app. Add the following codes into your "server.js" file:

Retrieving all tasks

router.route("/tasks").get((req, res) => {
    Tasks.find((err, tasks) => {
        if (err) {
            console.log(err);
        } else {
            res.json(tasks);
        }
    });
});

 

Retrieving a certain task through the task id:

router.route("/task/:id").get((req, res) => {
    Tasks.findById(req.params.id, (err, task) => {
        if (err) {
            console.log(err);
        } else {
            res.json(task);
        }
    });
});

The task can be queried through the variable "_id" which is the primary key of the saved data set (row).


Adding a new task

router.route('/task/add').post((req, res) => {
    let newTask = new Tasks(req.body);
    newTask.save()
        .then(task => {
            res.status(200).json("Task was added.");
        })
        .catch(err => {
            res.status(400).json("Error! Task could not be saved.");
            console.log(err);
        });
});

The task is created in the frontend "Angular" and then passed here as in object in the "body" of the "HTTP" data.

 

Updating a task

router.route('/task/update/:id').post((req, res) => {
    Tasks.findById(req.params.id, (err, task) => {
        if (!task)
            res.status(400).json("The task that should be updated was not found.");
        else {
            task.title = req.body.title;
            task.description = req.body.description;
            task.priority = req.body.priority;

            task.save().then(task => {
                res.status(200).json("Task was successfully updated.");
            }).catch(err => {
                res.status(400).json("Error! Task could not be updated.");
                console.log(err);
            });
        }
    });
});

The respective task will be loaded from the database through the command "findById". After that then the content of the loaded task is changed and saved.


Delete all tasks

router.route("/tasks/deleteall").get((req, res) => {
    Tasks.remove({}, (err) => {
        if (err) {
            res.status(400).json("Error. Tasks could not be deleted.");
            console.log(err);
        } else {
            res.status(200).json("Tasks were successfully deleted.");
        }
    });
});

 

Delete a certain task

router.route("/task/delete/:id").get((req, res) => {
    Tasks.findByIdAndRemove({ _id: req.params.id }, (err, task) => {
        if (err) {
            res.status(400).json("Error! Task could not be deleted.");
            console.log(err);
        } else {
            res.status(200).json("Task was successfully deleted.");
        }
    });
});

This was the creation of the "REST API" of this app. You can test it with any "REST API" client such as "Swagger UI" or "Postman".

 

Angular - Frontend

We will create the view in "HTML" and frontend program code in "TypeScript". "Angular Material" is used for the user interface (input fields, table, style, etc.).

Setting up the "Angular" configuration

We will use "Angular" for the "frontend" of our app. That is why we will create an angular app called frontend.

ng new frontend

Follow the instructions and click on "yes" when you are asked about "Angular routing". Please select "CSS" as a stylesheet.

Now change to the folder of your new created "Angular" app.

We need to add now our "Angular" dependencies:

ng add @angular/material

You will be asked to about the theme of your application design and some design settings.

Creating the components of our "Angular" app

An "Angular" web app must contain components that display the content of the web app. For example you have a components which shows your contact details, a component for listing your tasks, etc.

We will create these components which are needed for our "CRUD" application.

ng g c components/view

Component that displays tasks. This command is a abbreviation of the command "ng generate component components/view"

 

ng g c components/create

Component that creates tasks.

 

ng g c components/edit

Component that edits tasks.

 

Now we need to add the "routing" of our application. This defines the link that is used to access a certain "component" in our "Angular" app.

Go to your "app-routing.module.ts" file. The file is located in the path "src/app".
And add the following content:

const routes: Routes = [
{ path: 'view', component: ViewComponent },
{ path: 'edit/:id', component: EditComponent },
{ path: 'list', component: ListComponent },
{ path: '', redirectTo: '/list', pathMatch: 'full'}
];


You can import "Angular" modules in the file "app.module.ts".
We will use for this app the "MatToolbar" module. The module can be imported with these commands:
Add this import statement at the top of this file:

import { MatToolbarModule } from '@angular/material/toolbar';


You have to add also the module in the "import" section:

imports: [
... 
MatToolbarModule 
...
]

We also need to add the module "HttpClientModule" which is needed to access our "REST API" in our "Express" Server.
Please add that also in our "app.module.ts":

import { HttpClientModule } from '@angular/common/http';

 

imports: [
...
HttpClientModule
...
]

 

Components of your app are added in the tag "<router-outlet>" of your file "app.component.html".

We will create the following "app.component.html":

<mat-toolbar>
  <span class="appTitle">MEAN Stack CRUD Example APP - Tasks</span>
  <br>
  <br>
</mat-toolbar>
<h4 class="centered-text">This is a demo app.</h4>

<div>
  <router-outlet></router-outlet>
</div>

This is the root of your web app frontend and is also a component. Every component has a "html" file and a "CSS" file which change the frontend of your component. But also a ".ts" file which defines the programming code (business logic) of a component.

Our root "app.component" has also a custom "css" file. Please add the following to your "app.component.css" file:

.appTitle{
    font-size: 16px;
    color: orange;
}
.centered-text{
    text-align: center;
    font-size: 15px;
    font-weight: bold;
    margin: 10px;
    color: red;
}

 

Creating the "Service" of our Angular app

Now, it is time to create an "Angular Service" to access our database data. This can be done with this command:

ng g s Tasks

This command is an abbreviation of the command "ng generate service". If you used the name "Tasks", then the "Angular Service" is called "TasksService".

You have to import the created "Angular Service" in the file "app.module.ts":

 

import { TasksService } from './tasks.service';

Add also this in the file "app.module.ts":

  providers: [TasksService],

 

Add the following to new created "TasksService":

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({
  providedIn: 'root'
})
export class TasksService {

API_URL = "http://127.0.0.1:4000/api";

  constructor(private httpClient: HttpClient) { }

}

"API_URL" has the full address to our "Express" server and "REST API". Now you have to add create functions for all "routes" that were defined in our "REST API" in the "backend".
Add the following functions to your "TasksService":

//Call the "REST API" to get a certain task through the task id
  getTaskById(id) {
    return this.httpClient.get(`${this.API_URL}/task/${id}`);
  }

//Returns all tasks in the database
  getTasks() {
    return this.httpClient.get(`${this.API_URL}/tasks`);
  }

//Creates a new task object and send it to the "REST API" to save it
  addTask(title, description, priority) {
    const newTask = {
      title: title,
      description: description,
      priority: priority
    };
    return this.httpClient.post(`${this.API_URL}/task/add`, newTask);
  }
  
//Creates a new task object and send task id and task object to the "REST API" to update it
  updateTask(id, title, description, priority) {
    const updatedTask = {
      title: title,
      description: description,
      priority: priority
    };
    return this.httpClient.post(`${this.API_URL}/task/update/${id}`, updatedTask)
  }

//Delete task - Returns only info or error message
  deleteTaskById(id) {
    return this.httpClient.get(`${this.API_URL}/task/delete/${id}`);
  }
  
//Delete all tasks - Returns only info or error message
  deleteAllTasks() {
    return this.httpClient.get(`${this.API_URL}/task/deleteall`);
  }

You can now use this "Angular Service" "Task Services" when you import it and add it as a variable (inject it) in the constructor of your component class.
This will be done later in this tutorial.

Implementing the created "Angular" components

Now we reached the final part of this tutorial. You need now to add the "Angular modules" that we will use in our "Angular components".
Please add the following in your "app.module.ts" file:

import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatTableModule } from '@angular/material/table';
import { MatDividerModule } from '@angular/material/divider';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatSliderModule } from '@angular/material/slider'; 
import { MatPaginatorModule } from '@angular/material/paginator'; 

  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule,
    MatToolbarModule,
	FormsModule,
    ReactiveFormsModule,
    MatFormFieldModule,
    MatInputModule,
    MatIconModule,
    MatButtonModule,
    MatCardModule,
    MatTableModule,
    MatDividerModule,
    MatSnackBarModule,
    MatSliderModule,
	MatPaginatorModule
  ],

 

You need to create a "TypeScript" class for the "MongoDB schema" "Tasks". This is needed to map the "MongoDB" object to a "Javascript" object.
Create the file "tasks.model.ts" with the following content:

export interface Tasks {
    id: String;
    title: String;
    description: String;
    priority: Number;
}

 

Now, you have to implement all the created components with the following contents:

ViewComponent

This component will display the tasks in a table. There is an edit and delete button for every task in the table.

Typescript component class "view.component.ts":

import { Component, OnInit } from '@angular/core';
import { TasksService } from 'src/app/tasks.service';
import { Router } from '@angular/router';
import { Tasks } from 'src/tasks.model';
import { MatSnackBar } from '@angular/material/snack-bar';

@Component({
  selector: 'app-view',
  templateUrl: './view.component.html',
  styleUrls: ['./view.component.css']
})
export class ViewComponent implements OnInit {

  tasks: Tasks[];
  tableColumns = ['title', 'description', 'priority', 'edit'];

  constructor(private snackBar: MatSnackBar, private tasksService: TasksService, private router: Router) { 
    this.loadTasks();
  }

  ngOnInit(): void {
  }

  loadTasks() {
    this.tasksService.getTasks().subscribe((tasksData: Tasks[]) => {
      this.tasks = tasksData;
    });
  }

  editTask(id) {
    this.router.navigate([`/edit/${id}`]);
  }

  deleteTask(id) {
    this.tasksService.deleteTaskById(id).subscribe(() => {
      this.loadTasks();
      this.snackBar.open("Task was deleted.", "OK", {
        duration: 4000
      });
    })
  }
}

 

HTML file "view.component.html":

<div>
    <br>
    <mat-card>
        <button mat-raised-button color="primary" routerLink="/create">Add new task</button>
        <br><br>
        <mat-divider></mat-divider>
        <br>
        <h3>All Tasks</h3>
        <br>
        <table mat-table [dataSource]="tasks">
            <ng-container matColumnDef="title">
                <th mat-header-cell *matHeaderCellDef> Title </th>
                <td mat-cell *matCellDef="let element"> {{element.title}} </td>
            </ng-container>

            <ng-container matColumnDef="description">
                <th mat-header-cell *matHeaderCellDef> Description </th>
                <td mat-cell *matCellDef="let element"> {{element.description}} </td>
            </ng-container>

            <ng-container matColumnDef="priority">
                <th mat-header-cell *matHeaderCellDef> Priority </th>
                <td mat-cell *matCellDef="let element"> {{element.priority}} </td>
            </ng-container>

            <ng-container matColumnDef="edit">
                <th mat-header-cell *matHeaderCellDef class="mat-column-right"> Actions </th>
                <td mat-cell *matCellDef="let element" class="mat-column-right">
                    <button mat-button color="primary" (click)="editTask(element._id)">Edit</button>
                    <button mat-button color="warn" (click)="deleteTask(element._id)">DELETE</button>
                </td>
            </ng-container>

            <tr mat-header-row *matHeaderRowDef="tableColumns"></tr>
            <tr mat-row *matRowDef="let row; columns: tableColumns;"></tr>

            <mat-paginator [length]="500" [pageSize]="10" [pageSizeOptions]="[5, 10, 25, 100, 500]">
            </mat-paginator>
        </table>

        <div *ngIf="tasks == null || tasks.length < 1">
            <h4 class="noTasksInfo">You have no tasks!</h4>
        </div>

    </mat-card>
</div>

The command " ng-container matColumnDef="actions" " defines the column of our table. All rows from our array are displayed then with the help of this command " mat-row *matRowDef="let row; columns: tableColumns;" ".
"*ngIf" is used to decide through "TypeScript" code whether to display a certain "HTML tag" or not.

 

CSS file "view.component.css":

table {
    width: 100%
}

.mat-column-right {
    text-align: center;
}

 

CreateComponent

This component is used to save a task. A "form" module is used to realize that.

Typescript component class "create.component.ts":

import { Component, OnInit } from '@angular/core';
import { TasksService } from 'src/app/tasks.service';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';

@Component({
  selector: 'app-create',
  templateUrl: './create.component.html',
  styleUrls: ['./create.component.css']
})
export class CreateComponent implements OnInit {

  createTaskForm: FormGroup;
  priorityValue = 0;

  constructor(private snackBar: MatSnackBar, private tasksService: TasksService, private formBuilder: FormBuilder, private router: Router) {
    this.createTaskForm = this.formBuilder.group({
      title: ['', Validators.required],
      description: '',
      priority: 0
    });
  }

  ngOnInit(): void {
  }

  createTask(title, description, priority) {
    this.tasksService.addTask(title, description, priority).subscribe(() => {
      this.router.navigate(['/']);
      this.snackBar.open("OK. Task was added.", "OK", {
        duration: 4000
      });
    })
  }

}

 

HTML file "create.component.html":

<div>
    <br>
    <mat-card>
        <br>
        <h3>Add A New Task</h3>
        <mat-divider></mat-divider>
        <br>
        <form [formGroup]="createTaskForm" class="create-task-form">
            <mat-form-field class="column-full-width">
                <input matInput placeholder="Task Title" formControlName="title" #title>
            </mat-form-field>

            <mat-form-field class="column-full-width">
                <input matInput placeholder="Task Description" formControlName="description" #description>
            </mat-form-field>

            <div class="column-full-width">
                <h4>Priority: </h4>
                <mat-slider thumbLabel tickInterval="1" min="1" max="10" [(ngModel)]="priorityValue" formControlName="priority" #priority>
                </mat-slider>
            </div>

            <mat-divider></mat-divider>
            <br><br>
            <button class="saveButton" type="submit" (click)="createTask(title.value, description.value, priority.value)"
                [disabled]="createTaskForm.pristine || createTaskForm.invalid" mat-raised-button
                color="primary">Save</button>
            <button mat-raised-button color="accent" routerLink="/">Back</button>
        </form>
    </mat-card>
</div>

The command "" [disabled]="createTaskForm.pristine || createTaskForm.invalid" "" deactivates the submit button when at least one text field was not clicked or there is incorrect data in the text fields.
"[(ngModel)]" is used to define the value of a "Angular" UI element.

 

CSS file "create.component.css":

.create-task-form {
    min-width: 160px;
    width: 100%;
}

.column-full-width {
    width: 100%;
}

.saveButton{
    margin-right: 15px;
}

 

EditComponent

This component is used to edit a task. A "form" module is used to realize that.

Typescript component class "edit.component.ts":

import { Component, OnInit } from '@angular/core';
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { TasksService } from 'src/app/tasks.service';
import { MatSnackBar } from '@angular/material/snack-bar';

@Component({
  selector: 'app-edit',
  templateUrl: './edit.component.html',
  styleUrls: ['./edit.component.css']
})
export class EditComponent implements OnInit {


  id: String;
  task: any = {};
  updateTaskForm: FormGroup;
  priorityValue = 0;

  constructor(private tasksService: TasksService,private router: Router, private route: ActivatedRoute, private snackBar: MatSnackBar, private formBuilder: FormBuilder ) {
    this.loadEditTaskForm();
   }

  ngOnInit(): void {
    this.route.params.subscribe(params => {
      this.id = params.id;
      this.tasksService.getTaskById(this.id).subscribe(res => {
        this.task = res;
        this.updateTaskForm.get("title").setValue(this.task.title);
        this.updateTaskForm.get("description").setValue(this.task.description);
        this.priorityValue = this.task.priority;
      });
    });
  }
  
  loadEditTaskForm(){
    this.updateTaskForm = this.formBuilder.group({
      title: ['', Validators.required],
      description: '',
      priority: 0
    });
  }

  editTask(title, description, priority){
    this.tasksService.updateTask(this.id, title, description, priority).subscribe(() => {
      this.router.navigate(['/']);
      this.snackBar.open('Task was updated successfully.', 'OK', {
        duration: 3000
      });
    });
  }
}

 

HTML file "edit.component.html":

<div>
    <br>
    <mat-card>
        <h3>Edit Task</h3>
        <mat-divider></mat-divider>
        <br>
        <form [formGroup]="updateTaskForm" class="edit-task-form">
            <mat-form-field class="column-full-width">
                <input matInput placeholder="Task Title" formControlName="title" #title>
            </mat-form-field>

            <mat-form-field class="column-full-width">
                <input matInput placeholder="Task Description" formControlName="description" #description>
            </mat-form-field>

            <div class="column-full-width">
                <mat-slider thumbLabel tickInterval="1" min="1" max="10" [(ngModel)]="priorityValue" formControlName="priority" #priority></mat-slider>
            </div>

            <mat-divider></mat-divider>
            <br><br>
            <button class="saveButton" type="submit" (click)="editTask(title.value, description.value, priority.value)" mat-raised-button
            color="primary">Save</button>
            <button mat-raised-button color="accent" routerLink="/">Back</button>
        </form>
    </mat-card>
</div>

 

CSS file "edit.component.css":

.edit-task-form {
    min-width: 160px;
    width: 100%;
}

.column-full-width {
    width: 100%;
}

.saveButton{
    margin-right: 15px;
}

 

The creation of this app is now finished.

If you want to start this app, then run these 2 commands in seperate terminals:
In the "backend" folder:

npm run dev

 

In the "frontend" folder:

ng serve --open

 

If you want to build this app to host on a web server (production use), then use these commands:
Go to the "backend" folder. Copy all your JavaScript files into the folder "src". After that run this command:

npm run build

 

In the "frontend" folder:

ng build --prod

 

This was an introduction into creating a "MEAN stack" app with "CRUD" functionality. You can add also a whole range of functionality with the help of the package manager "npm" and "Angular modules".

This application on Github:
https://github.com/a-dridi/mean_crud_example

More about MongoDB:
https://docs.mongodb.com/manual/core/databases-and-collections/

REST API Client - Swagger UI:
https://swagger.io/tools/swagger-ui/

REST API Client - Postman:
https://www.postman.com/

Angular Material:
https://material.angular.io/

Angular:
https://angular.io/docs

NodeJS:
https://nodejs.org/en/docs/

Category:
We use cookies on our website. They are essential for the operation of the site
Ok