Angular 15/16 Free Course #6 - User Management (CRUD) Section
Built and tested with Angular 15 and Angular 16
In this free step by step Angular course we'll be covering how to implement routing, authentication, registration and CRUD functionality in Angular.
Other parts available in this Angular course:
- Part 1 - Create Base Project Structure
- Part 2 - Add Routing & Multiple Pages
- Part 3 - Login Form, Authentication & Route Guard
- Part 4 - Registration Form & Service Methods
- Part 5 - Alerts & Home Page
- Part 6 - User Management (CRUD) Section
- Part 7 - Migrate to Standalone Components and Functional Interceptors
- Part 8 - Dockerize App with Nginx
Angular Tutorial Part 6
In part 6 of this Angular tutorial series we're going to add a user management section with CRUD (Create, Read, Update, Delete) functionality.
Code on GitHub
The complete source code for this part of the tutorial is available on GitHub at https://github.com/cornflourblue/angular-16-tutorial in the part-6
folder. If you haven't completed Part 5 (Alerts & Home Page) but want to follow the steps in this part of the course you can start with the code in the part-5
folder of the GitHub repo.
Tutorial Steps
- Add CRUD Routes to Fake Backend API
- Add CRUD Methods to Account Service
- Create User Management Pages
- Create Users Feature Module
- Update Nav with Link to Users Section
- Start Angular Application!
Add CRUD Routes to Fake Backend API
To support CRUD functionality we'll add routes to the fake backend API for getting, updating and deleting users.
The getUsers()
function:
- checks if the user is logged in, if not a
401 Unauthorized
response is returned. - returns an array of all users.
The getUserById()
function:
- checks if the user is logged in, if not a
401 Unauthorized
response is returned. - returns the user with the specified id.
The updateUser()
function:
- checks if the user is logged in, if not a
401 Unauthorized
response is returned. - updates the specified user with the params from the
PUT
request body. - returns an empty
200 OK
response
The deleteUser()
function:
- checks if the user is logged in, if not a
401 Unauthorized
response is returned. - deletes the user with the specified id.
- returns an empty
200 OK
response
Update Fake Backend
This is how the fake backend (/src/_helpers/fake-backend.ts
) should look with the new CRUD routes, the new lines are 23-30
, 62-98
, 112-115
and 122-129
.
import { Injectable } from '@angular/core';
import { HttpRequest, HttpResponse, HttpHandler, HttpEvent, HttpInterceptor, HTTP_INTERCEPTORS } from '@angular/common/http';
import { Observable, of, throwError } from 'rxjs';
import { delay, materialize, dematerialize } from 'rxjs/operators';
// array in local storage for registered users
const usersKey = 'angular-tutorial-users';
let users: any[] = JSON.parse(localStorage.getItem(usersKey)!) || [];
@Injectable()
export class FakeBackendInterceptor implements HttpInterceptor {
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const { url, method, headers, body } = request;
return handleRoute();
function handleRoute() {
switch (true) {
case url.endsWith('/users/authenticate') && method === 'POST':
return authenticate();
case url.endsWith('/users/register') && method === 'POST':
return register();
case url.endsWith('/users') && method === 'GET':
return getUsers();
case url.match(/\/users\/\d+$/) && method === 'GET':
return getUserById();
case url.match(/\/users\/\d+$/) && method === 'PUT':
return updateUser();
case url.match(/\/users\/\d+$/) && method === 'DELETE':
return deleteUser();
default:
// pass through any requests not handled above
return next.handle(request);
}
}
// route functions
function authenticate() {
const { username, password } = body;
const user = users.find(x => x.username === username && x.password === password);
if (!user) return error('Username or password is incorrect');
return ok({
...basicDetails(user),
token: 'fake-jwt-token'
})
}
function register() {
const user = body
if (users.find(x => x.username === user.username)) {
return error('Username "' + user.username + '" is already taken')
}
user.id = users.length ? Math.max(...users.map(x => x.id)) + 1 : 1;
users.push(user);
localStorage.setItem(usersKey, JSON.stringify(users));
return ok();
}
function getUsers() {
if (!isLoggedIn()) return unauthorized();
return ok(users.map(x => basicDetails(x)));
}
function getUserById() {
if (!isLoggedIn()) return unauthorized();
const user = users.find(x => x.id === idFromUrl());
return ok(basicDetails(user));
}
function updateUser() {
if (!isLoggedIn()) return unauthorized();
let params = body;
let user = users.find(x => x.id === idFromUrl());
// only update password if entered
if (!params.password) {
delete params.password;
}
// update and save user
Object.assign(user, params);
localStorage.setItem(usersKey, JSON.stringify(users));
return ok();
}
function deleteUser() {
if (!isLoggedIn()) return unauthorized();
users = users.filter(x => x.id !== idFromUrl());
localStorage.setItem(usersKey, JSON.stringify(users));
return ok();
}
// helper functions
function ok(body?: any) {
return of(new HttpResponse({ status: 200, body }))
.pipe(delay(500)); // delay observable to simulate server api call
}
function error(message: string) {
return throwError(() => ({ error: { message } }))
.pipe(materialize(), delay(500), dematerialize()); // call materialize and dematerialize to ensure delay even if an error is thrown (https://github.com/Reactive-Extensions/RxJS/issues/648);
}
function unauthorized() {
return throwError(() => ({ status: 401, error: { message: 'Unauthorized' } }))
.pipe(materialize(), delay(500), dematerialize());
}
function basicDetails(user: any) {
const { id, username, firstName, lastName } = user;
return { id, username, firstName, lastName };
}
function isLoggedIn() {
return headers.get('Authorization') === 'Bearer fake-jwt-token';
}
function idFromUrl() {
const urlParts = url.split('/');
return parseInt(urlParts[urlParts.length - 1]);
}
}
}
export const fakeBackendProvider = {
// use fake backend in place of Http service for backend-less development
provide: HTTP_INTERCEPTORS,
useClass: FakeBackendInterceptor,
multi: true
};
Add CRUD Methods to Account Service
Next we'll add CRUD methods to the account service to send HTTP requests to the backend API to get, update and delete users.
The getAll()
method:
- sends a
GET
request to the/users
API route to fetch all users.
The getById(id: string)
method:
- sends a
GET
request to the/users/{id}
API route to fetch the user with the specified id.
The update(id: string, params: any)
method:
- sends a
PUT
request to the/users/{id}
API route to update the specified user with the providedparams
. - if the logged in user updated there own record, the current
user
object is updated in local storage and published to subscriber components withthis.userSubject.next(user)
.
The delete(id: string)
method:
- sends a
DELETE
request to the/users/{id}
API route to delete the user with the specified id. - if the logged in user deleted there own record they are automatically logged out of the app.
Update Account Service
This is how the account service (/src/_services/account.service.ts
) should look after adding the CRUD methods, the new lines are 48-81
.
import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { environment } from '@environments/environment';
import { User } from '@app/_models';
@Injectable({ providedIn: 'root' })
export class AccountService {
private userSubject: BehaviorSubject<User | null>;
public user: Observable<User | null>;
constructor(
private router: Router,
private http: HttpClient
) {
this.userSubject = new BehaviorSubject(JSON.parse(localStorage.getItem('user')!));
this.user = this.userSubject.asObservable();
}
public get userValue() {
return this.userSubject.value;
}
login(username: string, password: string) {
return this.http.post<User>(`${environment.apiUrl}/users/authenticate`, { username, password })
.pipe(map(user => {
// store user details and jwt token in local storage to keep user logged in between page refreshes
localStorage.setItem('user', JSON.stringify(user));
this.userSubject.next(user);
return user;
}));
}
logout() {
// remove user from local storage and set current user to null
localStorage.removeItem('user');
this.userSubject.next(null);
this.router.navigate(['/account/login']);
}
register(user: User) {
return this.http.post(`${environment.apiUrl}/users/register`, user);
}
getAll() {
return this.http.get<User[]>(`${environment.apiUrl}/users`);
}
getById(id: string) {
return this.http.get<User>(`${environment.apiUrl}/users/${id}`);
}
update(id: string, params: any) {
return this.http.put(`${environment.apiUrl}/users/${id}`, params)
.pipe(map(x => {
// update stored user if the logged in user updated their own record
if (id == this.userValue?.id) {
// update local storage
const user = { ...this.userValue, ...params };
localStorage.setItem('user', JSON.stringify(user));
// publish updated user to subscribers
this.userSubject.next(user);
}
return x;
}));
}
delete(id: string) {
return this.http.delete(`${environment.apiUrl}/users/${id}`)
.pipe(map(x => {
// auto logout if the logged in user deleted their own record
if (id == this.userValue?.id) {
this.logout();
}
return x;
}));
}
}
Create User Management Pages
The user management pages will provide the interface for performing CRUD operations on users in the Angular app.
Create Users Folder
Create a users
folder inside the app
folder. This will hold all components for the users feature.
List Users Component
The list users component contains the logic for fetching all users from the API and deleting users.
The constructor()
method:
- specifies the account service as a dependency by including it as a parameter, this is automatically injected by the Angular Dependency Injection (DI) system when the component is created.
The ngOnInit()
method:
- is an Angular lifecycle hook that runs once after the component is created. For more info on Angular lifecycle hooks see https://angular.io/guide/lifecycle-hooks.
- fetches all users from the API by calling
this.accountService.getAll()
and assigns the response to theusers
component property.
The call to.pipe(first())
unsubscribes from the observable immediately after the first value is emitted.
The deleteUser()
method:
- gets the specified user from the
users
array. - sets the
user.isDeleting
property totrue
, this is used in the component template to disable the delete button and show a spinner while the user is deleting. - deletes the specified user by calling
this.accountService.delete(id)
. - removes the deleted user from the
users
array so it is removed from the UI.
Create List Users Component
Create a file named list.component.ts
inside the users
folder and add the following TypeScript code to it:
import { Component, OnInit } from '@angular/core';
import { first } from 'rxjs/operators';
import { AccountService } from '@app/_services';
@Component({ templateUrl: 'list.component.html' })
export class ListComponent implements OnInit {
users?: any[];
constructor(private accountService: AccountService) {}
ngOnInit() {
this.accountService.getAll()
.pipe(first())
.subscribe(users => this.users = users);
}
deleteUser(id: string) {
const user = this.users!.find(x => x.id === id);
user.isDeleting = true;
this.accountService.delete(id)
.pipe(first())
.subscribe(() => this.users = this.users!.filter(x => x.id !== id));
}
}
List Users Component Template
The list users component template displays a list of all users and contains buttons for adding, editing and deleting users.
An *ngFor
directive loops over the array of users
to render a table row for each.
The add and edit buttons link to different routes with the routerLink
directive.
The delete button is bound to the deleteUser()
method with the (click)="deleteUser(user.id)"
event binding attribute, the [disabled]="user.isDeleting"
attribute directive disables the button when the isDeleting
property of the user
is true
, a spinner is also displayed in the button while deleting with the use of the *ngIf="user.isDeleting"
directive. The spinner styling and animation is part of Bootstrap 5.
Create List Users Component Template
Create a file named list.component.html
inside the users
folder and add the following HTML to it:
<div class="p-4">
<div class="container">
<h1>Users</h1>
<a routerLink="add" class="btn btn-sm btn-success mb-2">Add User</a>
<table class="table table-striped">
<thead>
<tr>
<th style="width: 30%">First Name</th>
<th style="width: 30%">Last Name</th>
<th style="width: 30%">Username</th>
<th style="width: 10%"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let user of users">
<td>{{user.firstName}}</td>
<td>{{user.lastName}}</td>
<td>{{user.username}}</td>
<td style="white-space: nowrap">
<a routerLink="edit/{{user.id}}" class="btn btn-sm btn-primary me-1">Edit</a>
<button (click)="deleteUser(user.id)" class="btn btn-sm btn-danger" style="width: 58px" [disabled]="user.isDeleting">
<span *ngIf="user.isDeleting" class="spinner-border spinner-border-sm"></span>
<span *ngIf="!user.isDeleting">Delete</span>
</button>
</td>
</tr>
<tr *ngIf="!users">
<td colspan="4" class="text-center">
<span class="spinner-border spinner-border-lg align-center"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
Add/Edit User Component
The add/edit user component is used for both adding and editing users, the component is in edit mode when there a user id
route parameter, otherwise it is in add mode.
In add mode the password field is required and the form fields are empty by default. In edit mode the password field is optional and the form is pre-populated with the specified user details, which are fetched from the API with the account service.
On submit a user is either created or updated by calling the account service, and on success you are redirected back to the users list page with a success message.
Create Add/Edit Component
Create a file named add-edit.component.ts
inside the users
folder and add the following TypeScript code to it:
import { Component, OnInit } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { first } from 'rxjs/operators';
import { AccountService, AlertService } from '@app/_services';
@Component({ templateUrl: 'add-edit.component.html' })
export class AddEditComponent implements OnInit {
form!: FormGroup;
id?: string;
title!: string;
loading = false;
submitting = false;
submitted = false;
constructor(
private formBuilder: FormBuilder,
private route: ActivatedRoute,
private router: Router,
private accountService: AccountService,
private alertService: AlertService
) { }
ngOnInit() {
this.id = this.route.snapshot.params['id'];
// form with validation rules
this.form = this.formBuilder.group({
firstName: ['', Validators.required],
lastName: ['', Validators.required],
username: ['', Validators.required],
// password only required in add mode
password: ['', [Validators.minLength(6), ...(!this.id ? [Validators.required] : [])]]
});
this.title = 'Add User';
if (this.id) {
// edit mode
this.title = 'Edit User';
this.loading = true;
this.accountService.getById(this.id)
.pipe(first())
.subscribe(x => {
this.form.patchValue(x);
this.loading = false;
});
}
}
// convenience getter for easy access to form fields
get f() { return this.form.controls; }
onSubmit() {
this.submitted = true;
// reset alerts on submit
this.alertService.clear();
// stop here if form is invalid
if (this.form.invalid) {
return;
}
this.submitting = true;
this.saveUser()
.pipe(first())
.subscribe({
next: () => {
this.alertService.success('User saved', true);
this.router.navigateByUrl('/users');
},
error: error => {
this.alertService.error(error);
this.submitting = false;
}
})
}
private saveUser() {
// create or update user based on id param
return this.id
? this.accountService.update(this.id!, this.form.value)
: this.accountService.register(this.form.value);
}
}
Add/Edit User Component Template
The add/edit user component template contains a dynamic form that supports both adding and editing users. The form is in edit mode when there a user id
property in the current route, otherwise it is in add mode.
In edit mode the form is pre-populated with user details fetched from the API and the password field is optional. The dynamic behaviour is implemented in the add/edit user component above.
Create Add/Edit User Component Template
Create a file named add-edit.component.html
inside the users
folder and add the following HTML to it:
<div class="p-4">
<div class="container">
<h1>{{title}}</h1>
<form *ngIf="!loading" [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="row">
<div class="mb-3 col">
<label class="form-label">First Name</label>
<input type="text" formControlName="firstName" class="form-control" [ngClass]="{ 'is-invalid': submitted && f.firstName.errors }" />
<div *ngIf="submitted && f.firstName.errors" class="invalid-feedback">
<div *ngIf="f.firstName.errors.required">First Name is required</div>
</div>
</div>
<div class="mb-3 col">
<label class="form-label">Last Name</label>
<input type="text" formControlName="lastName" class="form-control" [ngClass]="{ 'is-invalid': submitted && f.lastName.errors }" />
<div *ngIf="submitted && f.lastName.errors" class="invalid-feedback">
<div *ngIf="f.lastName.errors.required">Last Name is required</div>
</div>
</div>
</div>
<div class="row">
<div class="mb-3 col">
<label class="form-label">Username</label>
<input type="text" formControlName="username" class="form-control" [ngClass]="{ 'is-invalid': submitted && f.username.errors }" />
<div *ngIf="submitted && f.username.errors" class="invalid-feedback">
<div *ngIf="f.username.errors.required">Username is required</div>
</div>
</div>
<div class="mb-3 col">
<label class="form-label">
Password
<em *ngIf="id">(Leave blank to keep the same password)</em>
</label>
<input type="password" formControlName="password" class="form-control" [ngClass]="{ 'is-invalid': submitted && f.password.errors }" />
<div *ngIf="submitted && f.password.errors" class="invalid-feedback">
<div *ngIf="f.password.errors.required">Password is required</div>
<div *ngIf="f.password.errors.minlength">Password must be at least 6 characters</div>
</div>
</div>
</div>
<div class="mb-3">
<button [disabled]="submitting" class="btn btn-primary">
<span *ngIf="submitting" class="spinner-border spinner-border-sm me-1"></span>
Save
</button>
<a routerLink="/users" class="btn btn-link">Cancel</a>
</div>
</form>
<div *ngIf="loading" class="text-center m-5">
<span class="spinner-border spinner-border-lg align-center"></span>
</div>
</div>
</div>
Create Users Feature Module
We're going to wrap the user management pages and routes in a self-contained Angular feature module. Feature modules can be lazy loaded to improve performance by only loading when requested by the user.
Component names only need to be unique within the feature module, so we can use more concise names without worrying about clashing with other components in the Angular app (e.g. ListComponent
and AddEditComponent
instead of UsersListComponent
and UsersAddEditComponent
).
For more info about angular feature modules see https://angular.io/guide/feature-modules.
Users Routing Module
The users routing module defines the routes for the users feature module. It includes routes for listing, adding and editing users.
The add and edit routes are different but both load the same component (AddEditComponent
) which modifies its behaviour based on the route.
Create Users Routing Module
Create a file named users-routing.module.ts
inside the users
folder and add the following TypeScript code to it:
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ListComponent } from './list.component';
import { AddEditComponent } from './add-edit.component';
const routes: Routes = [
{ path: '', component: ListComponent },
{ path: 'add', component: AddEditComponent },
{ path: 'edit/:id', component: AddEditComponent }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class UsersRoutingModule { }
Users Feature Module
The users module defines the feature module for the users section of the tutorial application along with metadata about the module. The imports
specify which other angular modules are required by this module, and the declarations
state which components belong to this module.
The users module is hooked into the main app inside the app routing module with lazy loading in the next step.
Create Users Feature Module
Create a file named users.module.ts
inside the users
folder and add the following TypeScript code to it:
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { UsersRoutingModule } from './users-routing.module';
import { ListComponent } from './list.component';
import { AddEditComponent } from './add-edit.component';
@NgModule({
imports: [
CommonModule,
ReactiveFormsModule,
UsersRoutingModule
],
declarations: [
ListComponent,
AddEditComponent
]
})
export class UsersModule { }
Lazy Load Users Feature Module in App Routing Module
The users feature module is lazy loaded into the Angular app by configuring a route with the loadChildren
property and passing it a callback function that dynamically imports the UsersModule
when requested.
Update App Routing Module
Open the app routing module file (/src/app/app-routing.module.ts
) and add a lazy loaded route for the users module.
This is how the app routing module file should look after the update, the new lines are 8
and 12
.
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from './home';
import { LoginComponent, RegisterComponent } from './account';
import { AuthGuard } from './_helpers';
const usersModule = () => import('./users/users.module').then(x => x.UsersModule);
const routes: Routes = [
{ path: '', component: HomeComponent, canActivate: [AuthGuard] },
{ path: 'users', loadChildren: usersModule, canActivate: [AuthGuard] },
{ path: 'account/login', component: LoginComponent },
{ path: 'account/register', component: RegisterComponent },
// otherwise redirect to home
{ path: '**', redirectTo: '' }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
Update Nav with Link to Users Section
Open the app component template (/src/app/app.component.html
) and add a link to the new users section.
Also add routerLinkActive="active"
to each link to highlight the current nav link with the CSS class active
. The [routerLinkActiveOptions]="{exact: true}"
option only highlights the home link when the path exactly matches the routerLink (/
), otherwise it matches any path that begins with the link path (e.g. /users*
).
This is how the app component template should look after the update, line 4
is updated and line 5
is new.
<!-- nav -->
<nav class="navbar navbar-expand navbar-dark bg-dark px-3" *ngIf="user">
<div class="navbar-nav">
<a class="nav-item nav-link" routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{exact: true}">Home</a>
<a class="nav-item nav-link" routerLink="/users" routerLinkActive="active">Users</a>
<button class="btn btn-link nav-item nav-link" (click)="logout()">Logout</button>
</div>
</nav>
<!-- main app container -->
<div class="container">
<alert></alert>
<router-outlet></router-outlet>
</div>
Start Angular Application!
Run the command npm start
from the project root folder (where the package.json is located) to start the Angular application, then open a browser tab to the URL http://localhost:4200.
The command output should look something like this:
PS C:\Projects\angular-tutorial> npm start
> [email protected] start
> ng serve
✔ Browser application bundle generation complete.
Initial Chunk Files | Names | Raw Size
vendor.js | vendor | 2.44 MB |
polyfills.js | polyfills | 314.29 kB |
styles.css, styles.js | styles | 209.42 kB |
main.js | main | 64.83 kB |
runtime.js | runtime | 12.65 kB |
| Initial Total | 3.02 MB
Lazy Chunk Files | Names | Raw Size
src_app_users_users_module_ts.js | users-users-module | 30.84 kB |
Build at: 2023-05-10T02:12:05.631Z - Hash: d6d8a273a0d84dfc - Time: 13322ms
** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **
√ Compiled successfully.
Need Some Angular 16 Help?
Search fiverr for freelance Angular 16 developers.
Follow me for updates
When I'm not coding...
Me and Tina are on a motorcycle adventure around Australia.
Come along for the ride!