This tutorial continues the previous one, Strapi v4 Authentication and Authorization with Angular 13 on a Quiz App: Part 1 - Strapi App The first tutorial explained setting up a Strapi v4 app to manage questions, quizzes, answers, and scores. The Strapi app would also handle authentication and authorization using its Users and Permissions plugin.
A tutorial titled Build a Quiz App using a Strapi API with Angular preceded part 1. It outlined how to create an Angular 11 quiz app with a Strapi v3 backend. The app displayed a list of quizzes, let users answer questions, and presented their results. It did not track how they performed or allowed them to sign in. This tutorial will build on the initial Angular 11 quiz app.
To start, upgrade the app to the current major version of Angular, v13 (as of the date this was published). Then, add services to handle authentication and scoring. Moreover, you will create a login page, a signup page, and a score list page. The services will interface with the Strapi app you created in part 1 to manage the quiz and score content. The Strapi app will also handle authentication as well as authorization. By the end of the tutorial, users of the quiz app should be able to sign up or log in, see a list of quizzes and attempt them, and see their past scores. Here’s a video showing the completed quiz app in action.
In this tutorial, you will:
To follow along, you should have:
As mentioned earlier, in this tutorial, you will be building out an existing Angular 11 quiz app built in the “Build a Quiz App using a Strapi API with Angular” tutorial. Clone it from GitHub by running this command on your terminal:
1 git clone https://github.com/zaracooper/quiz-app
Change the directory to quiz-app
to start working on the app:
1 cd quiz-app
The current major version of Angular is v13. We built the initial quiz app with Angular 11; since v11 is outdated, the app may have vulnerable dependencies. In this step, you will update the app’s Angular core and CLI dependencies. The update involves first updating from v11 to v12, then from v12 to v13. You’ll perform two upgrades because it’s impossible to perform a migration between multiple major versions of Angular. To begin, install the app’s dependencies on your terminal:
1 npm i
Once the installation is complete, make sure that the repository is clean by committing any changes that result from the dependency installation. Then, run the following command to update Angular core and CLI from v11 to v12 and ng-bootstrap to a version compatible with Angular v12:
1 npx @angular/cli@12 update @angular/core@12 @angular/cli@12 @ng-bootstrap/ng-bootstrap@10.0.0
You may have to use the --force
flag if the update is unable to complete due to Angular CLI failing to resolve some dependencies. Continue the update of Angular core and CLI from v12 to v13 and ng-bootstrap to an Angular-v13-compatible-version by running:
1 ng update @angular/core@13 @angular/cli@13 @ng-bootstrap/ng-bootstrap@11.0.0
In this step, you will update the Strapi API URL and how fonts are loaded onto the project. Strapi v4 changed its API URL to include an /api
path segment suffix. So the Strapi API environment variable will change from http://localhost:1337
to http://localhost:1337/api
. You’ll need to modify this in the src/environments/environment.ts file. Replace the content of that file with:
1 export const environment = {
2 production: false,
3 strapiUrl: 'http://localhost:1337/api'
4 };
The highlighted portion is what changes. Next, update the fonts used in the app. Begin by removing all the @font-face
styling from src/styles.css. After that, **delete all fonts from the src/assets** folder with this command:
1 rm -rf src/assets/fonts
Then, in the src/index.html file, add these links within the head
tag:
1<link rel="preconnect" href="https://fonts.googleapis.com">
2<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
3<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@100;400;700&display=swap" rel="stylesheet">
In this step, you will add new models to the Core and Data modules. These models are used to structure data from the Strapi app.
User
model that represents a user in the Core module. UserAnswer
model and replace it with an Answer
model. This enables it to reflect the a``nswer
content type in the Strapi app accurately. ScoreResponse
model to reflect the responses you get from the Strapi Score API. Quiz
, Question
, and Score
models to match their corresponding content types on Strapi. To begin, create the new models and delete the old ones. If you are currently running the Angular server, it may stop working because of the changes you’ll make. Run the following commands on your terminal:
1 ng generate interface core/models/user
2 ng generate interface data/models/answer
3 ng generate interface data/models/score-response
4 rm src/app/data/models/user-answer.ts
The highlighted portions of the code blocks below indicate the updates made to the existing models.
Add this to src/app/core/models/user.ts:
1 export interface User {
2 id: string,
3 username: string,
4 email: string
5 }
Add this to src/app/data/models/answer.ts:
1 import { Question } from "./question";
2
3 export interface Answer {
4 id?: number;
5 question: Question;
6 value: string;
7 correct?: boolean;
8 correctValue?: string;
9 }
Update src/app/data/models/question.ts using the code below:
1 export interface Question {
2 id: number;
3 text?: string;
4 a?: string;
5 b?: string;
6 c?: string;
7 d?: string;
8 answer?: string;
9 }
Update src/app/data/models/quiz.ts with the code below:
1 import { Question } from "./question";
2
3 export interface Quiz {
4 id: number;
5 title?: string;
6 description?: string;
7 questions?: Question[];
8 }
Add the following code to src/app/data/models/score-response.ts:
1 import { Quiz } from "./quiz";
2 import { Score } from "./score";
3
4 export interface ScoreResponse {
5 questionCount: number;
6 quiz: Quiz;
7 score: Score;
8 scoreTotal: number;
9 }
Update src/app/data/models/score.ts with the following code:
1 import { Answer } from "./answer";
2 import { Quiz } from "./quiz";
3
4 export interface Score {
5 id: number;
6 answers: Answer[];
7 createdAt: Date;
8 quiz?: Quiz;
9 }
In this step, you will add new services and update existing ones.
The Core module currently has no services. Add the three services outlined below to it:
To generate these new services run the command below:
1 for service in authentication storage toast; do ng generate service "core/services/${service}"; done
This service interfaces with local storage. It adds a namespace prefix to each key name that’s used. Add the code below to src/app/core/services/storage.service.ts:
1 import { Injectable } from '@angular/core';
2
3 @Injectable({
4 providedIn: 'root'
5 })
6 export class StorageService {
7 private namespace = "s";
8
9 constructor() { }
10
11 getItem(key: string): string | null {
12 return window.localStorage.getItem(`${this.namespace}_${key}`);
13 }
14
15 setItem(key: string, value: string) {
16 window.localStorage.setItem(`${this.namespace}_${key}`, value);
17 }
18
19 removeItem(key: string) {
20 window.localStorage.removeItem(`${this.namespace}_${key}`);
21 }
22
23 clear() {
24 window.localStorage.clear();
25 }
26 }
The storage service has four methods:
getItem
to get values from local storage. setItem
to add values to local storage. removeItem
to remove items from local storage. clear
to clear the content of local storage. This service shows toast messages. A toast component which you will create in a later step will use this service to manage messages, titles, and styling options for the notifications it shows. Add this code to src/app/core/services/toast.service.ts:
1 import { Injectable } from '@angular/core';
2
3 @Injectable({
4 providedIn: 'root'
5 })
6 export class ToastService {
7 toasts: any[] = [];
8
9 constructor() { }
10
11 show(text: string, options: any = {}) {
12 this.toasts.push({ text, ...options });
13 }
14
15 showDanger(text: string, options: any = {}) {
16 this.toasts.push({text, classname: 'bg-danger text-light', header: 'Error', ...options});
17 }
18
19 showWarning(text: string, options: any = {}) {
20 this.toasts.push({text, classname: 'bg-warning text-dark', header: 'Warning', ...options});
21 }
22
23 showSuccess(text: string, options: any = {}) {
24 this.toasts.push({text, classname: 'bg-success text-light', header: 'Success', ...options});
25 }
26
27 showPrimary(text: string, options: any = {}) {
28 this.toasts.push({text, classname: 'bg-primary text-light', ...options});
29 }
30
31 showInfo(text: string, options: any = {}) {
32 this.toasts.push({text, classname: 'bg-info text-light', header: 'Info', ...options});
33 }
34
35 showDark(text: string, options: any = {}) {
36 this.toasts.push({text, classname: 'bg-dark text-light', ...options});
37 }
38 }
The Toast service has seven methods:
show
to display generic toast messagesshowDanger
to display danger toast messagesshowWarning
to display warning toast messagesshowSuccess
to display success toast messagesshowPrimary
to display primary toast messagesshowInfo
to display informational toast messagesshowDark
to display toast messages styled in a dark themeThis service is responsible for authentication. In src/app/core/services/authentication.service.ts, add this code:
1 import { HttpClient } from '@angular/common/http';
2 import { Injectable } from '@angular/core';
3 import { BehaviorSubject } from 'rxjs';
4 import { environment } from 'src/environments/environment';
5 import { User } from '../models/user';
6 import { StorageService } from './storage.service';
7
8 interface AuthResponse {
9 jwt: string;
10 user: User;
11 }
12
13 @Injectable({
14 providedIn: 'root'
15 })
16 export class AuthenticationService {
17 private url = `${environment.strapiUrl}/auth/local`;
18 private loginTracker = new BehaviorSubject(this.checkIfLoggedIn());
19
20 loggedInStatus$ = this.loginTracker.asObservable();
21
22 constructor(private http: HttpClient, private ss: StorageService) { }
23
24 login(identifier: string, password: string) {
25 return this.http.post<AuthResponse>(this.url, { identifier, password });
26 }
27
28 register(username: string, email: string, password: string) {
29 return this.http.post<AuthResponse>(`${this.url}/register`, { username, email, password });
30 }
31
32 checkIfLoggedIn() {
33 return this.ss.getItem('loggedIn') === 'true';
34 }
35
36 persistUser(resp: AuthResponse) {
37 [
38 ['userId', resp.user.id],
39 ['userEmail', resp.user.email],
40 ['username', resp.user.username],
41 ['loggedIn', 'true'],
42 ['token', resp.jwt]
43 ].forEach(item => this.ss.setItem(item[0], item[1]));
44 this.loginTracker.next(true);
45 }
46
47 getPersistedUser(): User {
48 return {
49 id: this.ss.getItem('userId') || '',
50 username: this.ss.getItem('username') || '',
51 email: this.ss.getItem('userEmail') || ''
52 };
53 }
54
55 getPersistedToken(): string {
56 return this.ss.getItem('token') || '';
57 }
58
59 logout() {
60 ['userId', 'userEmail', 'username', 'loggedIn', 'token'].forEach(item => this.ss.removeItem(item));
61 this.loginTracker.next(false);
62 }
63
64 getAuthHeader() {
65 return {
66 headers: { 'Authorization': `Bearer ${this.getPersistedToken()}` }
67 };
68 };
69 }
The Authentication service has eight methods:
login
: It takes an identifier
(email) and password
and makes a POST
request to the /auth/local
endpoint of the Strapi app to login a user. It returns the user’s information and a JWT if successful. register
: It signs up a user. It takes a username
, email
, and password
, makes a POST
request to the Strapi /auth/local
endpoint, and returns the new user’s information and a JWT. checkLoggedIn
: It checks whether the user is logged in based on a value stored in local storage that is set when they log in.persistUser
: It saves user information using the Storage service when they log in and updates their login status through the loginTracker
. getPersistedUser
: This method gets the current user’s information from the Storage service. getPersistedToken
: It gets the user’s token from the Storage service. logout
: This method logs out the user by clearing their information from storage and updates their login status using loginTracker
. getAuthHeader
: It creates an authentication header using the persisted token to use in requests to Strapi.The loggedInStatus$
observed tracks whether the user has logged in and shares this status with components that subscribe to it. The service uses the HttpClient
to request Strapi and the StorageService
to persist information. They are both injected in its constructor. AuthResponse
models the response received from Strapi’s /auth/local
endpoint.
The Data module only has one service, the Quiz service. Modify this service and add two newer ones to the module. These new services are:
To generate these new services, run:
1 for service in score normalization; do ng generate service "data/services/${service}"; done
Strapi v4 overhauled how data is returned and is drastically different from what Strapi v3 returned. For example, single entries are returned in an object under a data property and their attributes under an attributes property. Strapi v3 did not have this nested object structure. . Strapi v3 did not have this nested object structure.
Since the Angular app was initially built using Strapi v3, we need to make many changes to make to work with data from Strapi v4. A normalization service would restructure the data from the Strapi v4 app to something similar to what Strapi v3 returned. In the src/app/data/services/normalization.service.ts file, add the code below:
1 import { Injectable } from '@angular/core';
2 import { Observable } from 'rxjs';
3 import { map, switchMap, toArray } from 'rxjs/operators';
4
5 interface StrapiEntry { id: number, attributes: any }
6 interface StrapiSingleEntryData { data: StrapiEntry }
7 interface StrapiMultiEntryData { data: StrapiEntry[] }
8
9 @Injectable({
10 providedIn: 'root'
11 })
12 export class NormalizationService {
13
14 constructor() { }
15
16 restructureAttributes(nestedAttribute?: string): (source$: Observable<StrapiSingleEntryData>) => Observable<any> {
17 return source$ => source$.pipe(
18 map(v => this.restructureNestedAttributes(v.data, nestedAttribute)),
19 );
20 }
21
22 restructureArrayedAttributes(nestedAttribute?: string): (source$: Observable<StrapiMultiEntryData>) => Observable<any> {
23 return source$ => source$.pipe(
24 map(v => v.data),
25 switchMap(v => v),
26 map(v => this.restructureNestedAttributes(v, nestedAttribute)),
27 toArray()
28 );
29 }
30
31 private restructureNestedAttributes(v: StrapiEntry, nestedAttribute?: string) {
32 if (nestedAttribute) {
33 v.attributes[nestedAttribute] = v.attributes[nestedAttribute].data.map((nv: StrapiEntry) => ({ id: nv.id, ...nv.attributes }));
34 }
35 return { id: v.id, ...v.attributes };
36 }
37 }
The Normalization service has two public methods:
restructureAttributes
flattens a single entry Strapi response. It returns the single entry from the data
property. The content of the attributes
property of the single entry are placed at the same level as the entry’s id
. For example, { data: { id: 3, attributes: { title:
'``quiz``'
}}
would be converted to { id: 3, title:
'``quiz``'
}
.restructureArrayedAttributes
works similar to restructureAttributes
but it flattens data with multiple entries that are in an array. Since the QuizService
is a pre-existing service, it only has to be modified to normalize data. Also, since there is a new Score API on Strapi, there will be a new Score service on the Angular app to interface with it. The score
method is no longer needed on the QuizService
and will be removed. Replace the content of src/app/data/services/quiz.service.ts with the code below:
1 import { HttpClient, HttpParams } from '@angular/common/http';
2 import { Injectable } from '@angular/core';
3 import { Observable } from 'rxjs';
4 import { environment } from 'src/environments/environment';
5 import { Quiz } from '../models/quiz';
6 import { NormalizationService } from './normalization.service';
7
8 interface StrapiResponse {
9 data: any;
10 }
11
12 @Injectable({
13 providedIn: 'root'
14 })
15 export class QuizService {
16 private url = `${environment.strapiUrl}/quizzes`;
17 private populateQuestionsParam = { params: new HttpParams().set('populate', '*') };
18
19 constructor(private http: HttpClient, private ns: NormalizationService) { }
20
21 getQuizzes(): Observable<Quiz[]> {
22 return this.http.get<StrapiResponse>(
23 this.url,
24 this.populateQuestionsParam
25 ).pipe(this.ns.restructureArrayedAttributes('questions'));
26 }
27
28 getQuiz(id: number): Observable<Quiz> {
29 return this.http.get<StrapiResponse>(`${this.url}/${id}`,
30 this.populateQuestionsParam
31 ).pipe(this.ns.restructureAttributes('questions'));
32 }
33 }
The Quiz service injects two services into its constructor, HttpClient
, to make requests to the Strapi app and NormalizationService
to normalize the data returned from Strapi. Its two methods are:
getQuizzes
, which returns all the quizzes. getQuiz
, which returns a single quiz identified by its id
. populateQuestionsParam
is passed to each request to make sure that each returned quiz is populated with its questions.
This service interfaces with the Strapi Score API. In the src/app/data/services/score.service.ts file, add the code below:
1 import { HttpClient, HttpParams } from '@angular/common/http';
2 import { Injectable } from '@angular/core';
3 import { environment } from 'src/environments/environment';
4 import { ScoreResponse } from '../models/score-response';
5 import { Answer } from '../models/answer';
6 import { Quiz } from '../models/quiz';
7 import { Observable } from 'rxjs';
8 import { AuthenticationService } from 'src/app/core/services/authentication.service';
9
10 @Injectable({
11 providedIn: 'root'
12 })
13 export class ScoreService {
14 private url = `${environment.strapiUrl}/scores`;
15
16 constructor(private http: HttpClient, private auth: AuthenticationService) { }
17
18 createScore(quiz: Quiz, answers: Answer[]): Observable<ScoreResponse> {
19 return this.http.post<ScoreResponse>(
20 this.url,
21 { quiz, answers },
22 this.auth.getAuthHeader()
23 );
24 }
25
26 getScore(id: number): Observable<ScoreResponse> {
27 return this.http.get<ScoreResponse>(
28 `${this.url}/${id}`,
29 this.auth.getAuthHeader()
30 );
31 }
32
33 getScores(): Observable<ScoreResponse[]> {
34 return this.http.get<ScoreResponse[]>(this.url,
35 {
36 params: new HttpParams({
37 fromObject: {
38 'sort': 'createdAt:desc',
39 'pagination[pageSize]': 10
40 }
41 }),
42 ...this.auth.getAuthHeader()
43 }
44 );
45 }
46 }
The Score service uses the HttpClient
service to make a request to Strapi and the AuthenticationService
to create authentication headers for the requests. It has three methods:
createScore
, which creates scores given a user’s answers
and a quiz
to score it against. getScore
, which gets a single score identified by the provided id
. getScores
, which gets all the scores belonging to the authenticated user. The data returned by the Strapi Score API does not need to be normalized because the controllers that create and fetch scores were modified to return data that takes a ScoreResponse
structure.
In this step, you will add restriction guards that will prevent access to Quiz, Score, and Signup pages. Both guards will be placed in the Core module; they are:
LoggedInGuard
, which prevents unauthenticated users from taking quizzes on the Quiz page and viewing scores on the Score and Scores pages.SignupGuard
, which prevents logged-in users from accessing the signup page. This is done because already logged-in users don’t need to create accounts. If they wish to create new accounts, they need to log out first. To generate the guards, run this command on your terminal:
1 for guard in "logged-in" signup; do ng generate guard "core/guards/${guard}"; done
When prompted on what interface they should implement, select CanActivate
.
Replace the content of src/app/core/guards/logged-in.guard.ts with this code:
1 import { Injectable } from '@angular/core';
2 import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
3 import { Observable } from 'rxjs';
4 import { AuthenticationService } from '../services/authentication.service';
5 import { StorageService } from '../services/storage.service';
6
7 @Injectable({
8 providedIn: 'root'
9 })
10 export class LoggedInGuard implements CanActivate {
11
12 constructor(
13 private auth: AuthenticationService,
14 private router: Router,
15 private ss: StorageService
16 ) { }
17
18 canActivate(
19 route: ActivatedRouteSnapshot,
20 state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
21 if (this.auth.checkIfLoggedIn()) {
22 return true;
23 }
24
25 this.ss.setItem('attemptedRoute', state.url);
26 return this.router.parseUrl('/');
27 }
28 }
The logged-in guard uses three other injected services: AuthenticationService
to check if a user is logged in, StorageService
to store the route they attempted but were denied access, and Router
to parse string URLs into URL trees. If an authenticated user attempts to access a route, they can activate it. If they are unauthenticated, the route they’re trying to access is saved in storage and they are redirected to the Quizzes page. When they login, they are redirected to the route they attempted before but were denied.
Replace the content of src/app/core/guards/signup.guard.ts with this code:
1 import { Injectable } from '@angular/core';
2 import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot, UrlTree } from '@angular/router';
3 import { Observable } from 'rxjs';
4 import { AuthenticationService } from '../services/authentication.service';
5
6 @Injectable({
7 providedIn: 'root'
8 })
9 export class SignupGuard implements CanActivate {
10
11 constructor(private auth: AuthenticationService, private router: Router) { }
12
13 canActivate(
14 route: ActivatedRouteSnapshot,
15 state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
16 return this.auth.checkIfLoggedIn() ? this.router.parseUrl('/') : true;
17 }
18 }
The signup guard uses two injected services: AuthenticationService
to check if a user is logged in and Router
to parse string URLs into URL trees. When an authenticated user attempts to access the Signup page, they are redirected to the Quizzes page. If they are unauthenticated, they can create an account on the Signup page.
In this step, you will create new components to display error and general messages. You will also modify the HeaderComponent
to add the login, signup, and user profile buttons. Lastly, you will modify the existing QuestionComponent
to work with the newer data models.
Two new components will be added to the Core module. The T``oastComponent
shows toasts, and the MessageComponent
shows an error or general messages on separate pages.
To generate both, run:
1 for comp in toast message; do ng generate component "core/components/${comp}"; done
The TitleComponent
displays titles on pages and in other components. Initially, the only stand-alone pages were in the Quiz module. However, now that you will be adding additional pages in other modules, the TitleComponent
will also be used in them. It makes sense to move this component from the Quiz module to the Core module. To do this, run the following command:
1 mv src/app/features/quiz/components/title src/app/core/components
You need to update src/app/features/quiz/quiz.module.ts to reflect this removal by deleting the TitleComponent
import and declaration. The QuizModule
declarations should now look like this:
1 @NgModule({
2 declarations: [ QuestionComponent, QuizzesComponent, QuizComponent, ScoreComponent],
3 imports: [ CommonModule, QuizRoutingModule, NgbModule, ReactiveFormsModule]
4 })
5 export class QuizModule { }
6
7Next, update **src/app/core/core.module.ts** to include `TitleComponent` as a declaration and export of `CoreModule`. The highlighted portions are all the code you need to add.
8
9 import { TitleComponent } from './components/title/title.component';
10
11 @NgModule({
12 declarations: [ HeaderComponent, NotFoundComponent, ToastComponent, MessageComponent, TitleComponent],
13 imports: [ CommonModule, RouterModule ],
14 exports: [ HeaderComponent, NotFoundComponent, TitleComponent ]
15 })
16 export class CoreModule { }
In the original header component, the template only consisted of the app title and a home button. In this section, you will add:
This is what the initial header looked like:
To add functionality for the four additions mentioned above, replace the content of src/app/core/components/header/header.component.ts with this:
1 import { Component, OnDestroy, OnInit } from '@angular/core';
2 import { Router } from '@angular/router';
3 import { Subscription } from 'rxjs';
4 import { AuthenticationService } from '../../services/authentication.service';
5 import { ToastService } from '../../services/toast.service';
6
7 @Component({
8 selector: 'app-header',
9 templateUrl: './header.component.html',
10 styleUrls: ['./header.component.css']
11 })
12 export class HeaderComponent implements OnInit, OnDestroy {
13 isLoggedIn = false;
14 avatarInitial = '';
15 username = '';
16 authStatus!: Subscription;
17
18 constructor(
19 private auth: AuthenticationService,
20 private router: Router,
21 private toast: ToastService
22 ) { }
23
24 ngOnInit(): void {
25 this.authStatus = this.auth.loggedInStatus$.subscribe(status => {
26 this.isLoggedIn = status;
27
28 if (status) {
29 this.username = this.auth.getPersistedUser().username;
30 this.avatarInitial = this.username[0] || 'Q';
31 }
32 });
33 }
34
35 ngOnDestroy(): void {
36 this.authStatus.unsubscribe();
37 }
38
39 logout() {
40 this.auth.logout();
41 this.toast.showSuccess('Successfully logged out.');
42 this.router.navigateByUrl('/');
43 }
44 }
The Header component injects three services: AuthenticationService
to log in and out, Router
for navigation, and ToastService
to display toasts. When the component is initialized, a check is done to confirm whether the user is already logged in. If they are, their username
is fetched from storage and used in the profile button. Their login status, isLoggedIn
, is also tracked and used to determine the visibility of the login, logout, and signup buttons. If the user is logged in, the logout and profile buttons are shown. If they are not logged in, the signup and login buttons are shown instead.
When they log out, the logout
method is called. In this method, the AuthenticationService
logs them out, a successful toast is displayed using the ToastService
, and they are directed to the home page.
To add all the buttons, change the content of src/app/core/components/header/header.component.html to the code below:
1 <div class="d-flex flex-row vw-95 justify-content-between align-items-center mx-auto mt-2">
2 <h3 routerLink="/" class="font-weight-bold">Quiz<span class="font-weight-light">App</span></h3>
3 <div class="d-flex align-items-center">
4 <div class="rounded-circle bg-success text-white avatar mr-2 d-flex align-items-center justify-content-center"
5 *ngIf="isLoggedIn" placement="left" ngbTooltip="Scores for {{username}}" routerLink="/scores">
6 <h3 class="avatar-text">{{avatarInitial | uppercase}}</h3>
7 </div>
8 <button routerLink="/auth/signup" type="button" class="m-1 btn app-bg-grey btn-sm" *ngIf="!isLoggedIn">Sign
9 Up</button>
10 <button routerLink="/auth/login" type="button" class="m-1 btn app-bg-grey btn-sm"
11 *ngIf="!isLoggedIn">Login</button>
12 <button (click)="logout()" type="button" class="m-1 btn app-bg-grey btn-sm" *ngIf="isLoggedIn">Logout</button>
13 </div>
14 </div>
To style the buttons, add this to src/app/core/components/header/header.component.css:
1 .avatar {
2 width: 40px;
3 height: 40px;
4 }
5 .avatar-text {
6 margin: 0;
7 padding: 0;
8 font-weight: bold;
9 }
Here’s what the header should look like when a user is signed in:
Here’s a screenshot of what it should look like when a user is not signed in:
This component shows an error message on a separate page in three instances:
Change the content of src/app/core/components/message/message.component.ts to this:
1 import { Component, Input } from '@angular/core';
2
3 @Component({
4 selector: 'app-message',
5 templateUrl: './message.component.html',
6 styleUrls: ['./message.component.css']
7 })
8 export class MessageComponent {
9 @Input() title = '';
10 @Input() subtitle = '';
11 @Input() message = '';
12 @Input() buttonText = '';
13 @Input() redirectPath = '';
14
15 constructor() { }
16 }
The Message component takes five inputs:
title
: The title of the pagesubtitle
: The text that appears under the titlemessage
: The general or error message to be displayedbuttonText
: The text shown on a button under the messageredirectPath
: The path that the user is redirected to when they click the button aboveAdd this to src/app/core/components/message/message.component.html:
1 <div class="flex-grow-1 vw-95 d-flex flex-column align-items-center">
2 <app-title *ngIf="title && subtitle" [title]="title" [subtitle]="subtitle"></app-title>
3 <div class="card app-bg-light-green m-2">
4 <div class="card-body d-flex align-items-center flex-column">
5 <h4 class="font-weight-normal text-center mb-3 message">{{message}}
6 </h4>
7 <button [routerLink]="redirectPath" type="button" class="btn btn-sm app-bg-green">{{buttonText}}</button>
8 </div>
9 </div>
10 </div>
To style this component, add this to src/app/core/components/message/message.component.css:
1 .message {
2 width: 40vw;
3 }
You will see screenshots of this component in use in step 7.
This component shows toast messages. Replace the content of src/app/core/components/toast/toast.component.ts with this:
1 import { Component } from '@angular/core';
2 import { ToastService } from '../../services/toast.service';
3
4 @Component({
5 selector: 'app-toast',
6 templateUrl: './toast.component.html',
7 host: { '[class.ngb-toasts]': 'true' },
8 styleUrls: ['./toast.component.css']
9 })
10 export class ToastComponent {
11 show = true;
12
13 constructor(public toastService: ToastService) { }
14
15 close() {
16 this.show = false;
17 }
18 }
The toast component only displays toast messages. It uses the ToastService
to manage different messages sent from across the app. The close
method dismisses the toast message.
Replace the content of src/app/core/components/toast/toast.component.html with this:
1 <ngb-toast *ngFor="let toast of toastService.toasts" [header]="toast.header || 'Message'" [class]="toast.classname"
2 [autohide]="true" [delay]="toast.delay || 5000" (hidden)="close()">
3 {{ toast.text }}
4 </ngb-toast>
Here’s a screenshot of a sample toast message:
The pages that display quizzes and scores heavily rely on the TitleComponent
to show their information. Since some properties in the Quiz
and Score
models aren’t always guaranteed, the TitleComponent
has to be able to accept undefined values as input. To make the TitleComponent
inputs take undefined values, modify them in the src/app/core/components/title/title.component.ts file as shown below:
1 export class TitleComponent {
2 @Input() title: string | undefined = '';
3 @Input() subtitle: string | undefined = '';
4
5 constructor() { }
6 }
There is only one support component in the Quiz module. It’s already an existing component from the initial Angular app.
In this component, you will update some of the answer models it imports and uses. Replace the content of the src/app/features/quiz/components/question/question.component.ts file with the code below:
1 import { Component, Input, Output, EventEmitter } from '@angular/core';
2 import { Answer } from 'src/app/data/models/answer';
3 import { Question } from 'src/app/data/models/question';
4
5 @Component({
6 selector: 'app-question',
7 templateUrl: './question.component.html',
8 styleUrls: ['./question.component.css']
9 })
10 export class QuestionComponent {
11 @Input() question = {} as Question;
12 @Input() number = 0;
13 @Output() setAnswer = new EventEmitter<Answer>();
14
15 selectedAnswer = '';
16
17 constructor() { }
18
19 pickAnswer(id: number, answer: string, value: string = '') {
20 this.selectedAnswer = `[${answer}] ${value}`;
21 this.setAnswer.emit({ question: { id }, value: answer });
22 }
23 }
In this file, you will replace the UserAnswer
model which you removed in step 3 with the new Answer
model.
In this step, you will add new pages for logging in, signing up, and showing all scores. You will also modify the Quiz, Quizzes, Not-Found, and single Score pages.
This is a new module that contains the signup and login pages. Since it didn’t exist in the initial app, you’ll have to generate it by running the code below:
1 ng generate module features/auth --routing
The --routing
flag adds an AuthRoutingModule
to handle routing for the AuthModule
. To generate the login and signup pages run the following code:
1for page in login signup; do ng generate component "features/auth/pages/${page}"; done
Running the above command will generate both the LoginComponent
and SignupComponent
at src/app/features/auth/pages.
This page enables users to register accounts. A user will provide a username, email, and password to a form to create an account. Replace the content of src/app/features/auth/pages/signup/signup.component.ts with this:
1 import { Component, OnDestroy } from '@angular/core';
2 import { FormBuilder, Validators } from '@angular/forms';
3 import { Router } from '@angular/router';
4 import { Subscription } from 'rxjs';
5 import { AuthenticationService } from 'src/app/core/services/authentication.service';
6 import { StorageService } from 'src/app/core/services/storage.service';
7 import { ToastService } from 'src/app/core/services/toast.service';
8
9 @Component({
10 selector: 'app-signup',
11 templateUrl: './signup.component.html',
12 styleUrls: ['./signup.component.css']
13 })
14 export class SignupComponent implements OnDestroy {
15 signupForm = this.fb.group({
16 username: ['', [Validators.required]],
17 email: ['', [Validators.required, Validators.email]],
18 password: ['', [Validators.required, Validators.minLength(8)]]
19 });
20
21 private registrationSub: Subscription | undefined;
22
23 constructor( private fb: FormBuilder, private auth: AuthenticationService, private router: Router, private ss: StorageService, private toast: ToastService) { }
24
25 ngOnDestroy(): void {
26 if (this.registrationSub) {
27 this.registrationSub.unsubscribe();
28 }
29 }
30
31 get password() { return this.signupForm.get('password'); }
32
33 signup() {
34 const user = this.signupForm.value;
35
36 this.registrationSub = this.auth.register(
37 user.username,
38 user.email,
39 user.password
40 ).subscribe(
41 resp => {
42 this.signupForm.reset();
43
44 this.auth.persistUser(resp);
45
46 this.toast.showSuccess('Successfully created account. Redirecting you to the quizzes.');
47
48 const attemptedRoute = this.ss.getItem('attemptedRoute');
49 this.ss.removeItem('attemptedRoute');
50 this.router.navigateByUrl(attemptedRoute || '/')
51 },
52 () => {
53 this.toast.showDanger('There was a problem registering your account.');
54 }
55 );
56 }
57 }
This component injects four services into its constructor: FormBuilder
to create and manage the signup form, AuthenticationService
to make a user registration request to Strapi, StorageService
to retrieve any route the user attempted and was denied, and ToastService
to display toast messages. signupForm
is a form group that contains the username, email, and password form controls. registrationSub
holds the subscription returned when a user signs up; it is unsubscribed from in the ngDestroy
method when the component is destroyed.
The component has two methods:
password
to get the password value from the signupForm
signup
, which creates the new user using the AuthenticationService
When the signup
method is called, all the values from the signupForm
are passed to the register
method of the AuthenticationService
. Once a successful response is received, the signupForm
is reset, user details are persisted, and a success toast is displayed. If the user attempted to access a route but was prevented access by a guard, the route they tried to access is retrieved using the StorageService
. The user is then redirected to that route and the initially attempted route is removed from storage. If registration is unsuccessful, an error toast message is displayed.
Replace the content of the src/app/features/auth/pages/signup/signup.component.html template with this:
1 <div class="d-flex flex-column align-items-center">
2 <app-title title="Register" subtitle="Create an account to take a quiz"></app-title>
3 <form class="d-flex flex-column align-items-center mt-3" [formGroup]="signupForm" (ngSubmit)="signup()">
4 <div class="form-group d-flex flex-column align-items-center">
5 <label for="exampleInputEmail1">Username</label>
6 <input type="text" class="form-control signup-input" formControlName="username">
7 </div>
8 <div class="form-group d-flex flex-column align-items-center">
9 <label for="exampleInputEmail1">Email address</label>
10 <input type="email" class="form-control signup-input" formControlName="email">
11 </div>
12 <div class="form-group d-flex flex-column align-items-center">
13 <label for="exampleInputPassword1">Password</label>
14 <input type="password" class="form-control signup-input" formControlName="password">
15 </div>
16 <div *ngIf="password?.invalid && (password?.dirty || password?.touched)" class="alert alert-danger"
17 role="alert">
18 Your password should be at least 8 characters long.
19 </div>
20 <button type="submit" class="btn btn-dark" [disabled]="!signupForm.valid">Register</button>
21 </form>
22 </div>
In this template, the form controls are added to an HTML form. If the password entered fails validation as specified in the component (it is a required value with a minimum length of 8), an error message is displayed. To style the form, add this to src/app/features/auth/pages/signup/signup.component.css:
1 .signup-input {
2 min-width: 25vw;
3 }
Here is a screenshot of the signup page:
On this page, the user provides their email and password to sign in. There’s a login form containing input fields for these values to be entered and submitted in the template. Replace the content of src/app/features/auth/pages/login/login.component.ts with this:
1 import { Component, OnDestroy } from '@angular/core';
2 import { FormBuilder, Validators } from '@angular/forms';
3 import { Router } from '@angular/router';
4 import { Subscription } from 'rxjs';
5 import { AuthenticationService } from 'src/app/core/services/authentication.service';
6 import { StorageService } from 'src/app/core/services/storage.service';
7 import { ToastService } from 'src/app/core/services/toast.service';
8
9 @Component({
10 selector: 'app-login',
11 templateUrl: './login.component.html',
12 styleUrls: ['./login.component.css']
13 })
14 export class LoginComponent implements OnDestroy {
15 loginForm = this.fb.group({
16 email: ['', [Validators.required, Validators.email]],
17 password: ['', [Validators.required]]
18 });
19
20 private loginSub: Subscription | undefined;
21
22 constructor(private fb: FormBuilder, private auth: AuthenticationService, private router: Router, private toast: ToastService, private ss: StorageService) { }
23
24 ngOnDestroy(): void {
25 if (this.loginSub) {
26 this.loginSub.unsubscribe();
27 }
28 }
29
30 login() {
31 const credentials = this.loginForm.value;
32
33 this.loginSub = this.auth.login(
34 credentials.email,
35 credentials.password
36 ).subscribe(
37 resp => {
38 this.loginForm.reset();
39
40 this.auth.persistUser(resp);
41
42 this.toast.showSuccess('Successfully logged in.');
43
44 const attemptedRoute = this.ss.getItem('attemptedRoute');
45 this.ss.removeItem('attemptedRoute');
46 this.router.navigateByUrl(attemptedRoute || '/')
47 },
48 () => {
49 this.toast.showDanger('Login unsuccessful. Check your credentials.');
50 }
51 );
52 }
53 }
Four services are injected into this component’s constructor: FormBuilder
to create the login form, AuthenticationService
to make a login request to Strapi, ToastService
to send toast messages, and StorageService
to get routes that the user attempted but was denied. loginForm
is the form group that contains the email and password form controls. loginSub
holds the subscription returned when making a request to login using the AuthenticationService
. When the component is destroyed, loginSub
is unsubscribed from to prevent memory leaks.
The only method LoginComponent
has is login
. When it is called, the email and password values from the loginForm
are passed to AuthenticationService
's login
method which makes a request to Strapi to log in. If the request is successful, the loginForm
is reset, the user details persisted, and a success toast message is displayed. If the user attempted to access a route but was denied because they were unauthenticated, they will be redirected to that route and it will be removed from storage. An error toast message is displayed if the signin was unsuccessful.
Add this code to the src/app/features/auth/pages/login/login.component.html template:
1 <div class="d-flex flex-column align-items-center">
2 <app-title title="Login" subtitle="Login to take a quiz"></app-title>
3 <form class="d-flex flex-column align-items-center mt-3" [formGroup]="loginForm" (ngSubmit)="login()">
4 <div class="form-group d-flex flex-column align-items-center">
5 <label for="exampleInputEmail1">Email address</label>
6 <input type="email" class="form-control login-input" formControlName="email">
7 </div>
8 <div class="form-group d-flex flex-column align-items-center">
9 <label for="exampleInputPassword1">Password</label>
10 <input type="password" class="form-control login-input" formControlName="password">
11 </div>
12 <button type="submit" class="btn btn-dark" [disabled]="!loginForm.valid">Login</button>
13 </form>
14 </div>
The loginForm
can only be submitted when the form is valid i.e. all the form controls pass the validators set in LoginComponent
. The submit button is disabled until then. To style the template, add the code below to src/app/features/auth/pages/login/login.component.css:
1 .login-input {
2 min-width: 25vw;
3 }
Here is a screenshot of the login page:
The QuizModule
in the initial version of the Angular app had three pages: the Quiz page, the Quizzes page, and the Score page. You will modify these pages to use the updated data models and services. You will also add a new Scores page to show a complete list of the user’s previous scores on quizzes. To generate the new Scores page, run the command below:
1 ng generate component features/quiz/pages/scores
This page is responsible for displaying a complete list of available quizzes. Modify this page to disable the Take Quiz
buttons on each quiz card if a user is unauthenticated. A user can only take a quiz if they are logged in. Begin by replacing the content of src/app/features/quiz/pages/quizzes/quizzes.component.ts with the code below:
1 import { Component } from '@angular/core';
2 import { AuthenticationService } from 'src/app/core/services/authentication.service';
3 import { QuizService } from 'src/app/data/services/quiz.service';
4
5 @Component({
6 selector: 'app-quizzes',
7 templateUrl: './quizzes.component.html',
8 styleUrls: ['./quizzes.component.css']
9 })
10 export class QuizzesComponent {
11 quizzes$ = this.quizService.getQuizzes();
12
13 constructor(private quizService: QuizService, public auth: AuthenticationService) { }
14 }
The major change made here is the injection of the AuthenticationService
in the constructor. It determines if the user is logged in or not.
Proceed to replace the content of src/app/features/quiz/pages/quizzes/quizzes.component.html with this:
1 <div class="flex-grow-1 vw-95 d-flex flex-column align-items-center">
2 <app-title title="Take a Quiz" subtitle="Test your Knowledge"></app-title>
3 <div *ngIf="quizzes$ | async as quizzes" class="vw-75 d-flex align-items-center justify-content-center flex-wrap">
4 <div *ngFor="let quiz of quizzes" class="align-self-start m-2 card quiz-card">
5 <div class="card-body d-flex flex-column flex-grow-1">
6 <h5 class="card-title font-weight-bold">{{quiz.title}}</h5>
7 <p class="card-text">{{quiz.description}}</p>
8 </div>
9 <ul class="list-group list-group-flush flex-shrink-1">
10 <li class="list-group-item">Questions: {{quiz.questions?.length}}</li>
11 <li class="list-group-item">
12 <button *ngIf="{ status: auth.loggedInStatus$ | async } as isLoggedIn" type="button"
13 [routerLink]="['/quizzes', quiz.id]" class="btn btn-sm" [disabled]="!isLoggedIn.status">
14 {{ isLoggedIn.status ? 'Take Quiz' : 'Login to attempt' }}
15 </button>
16 </li>
17 </ul>
18 </div>
19 </div>
20 </div>
The main change in this template is the replacement of the Take Quiz
link with a button that is disabled if the user is not logged in. How some quiz and question attributes are accessed have also been modified to be in line with the new data models. The highlighted portions show all the changes that were made.
Because the Take Quiz
link was replaced with a button that can be disabled, styling for the template had to be modified to reflect the change. Since this styling file is rather large, its source code won’t be placed here. Visit this link for the styling source, copy it, and replace the content of src/app/features/quiz/pages/quizzes/quizzes.component.css with it.
Here’s a screenshot of the quizzes page when a user is not logged in. Notice how all the buttons are labeled Login to attempt
and are disabled.
Here’s a screenshot of the same page when a user is logged in. Once you login, the button text changes to Take Quiz
and is enabled.
The main update made on this page is the change of the UserAnswer
model to the Answer
model. Replace the content of src/app/features/quiz/pages/quiz/quiz.component.ts to this:
1 import { Component, OnDestroy, OnInit } from '@angular/core';
2 import { ActivatedRoute, Router } from '@angular/router';
3 import { Subscription } from 'rxjs';
4 import { Quiz } from 'src/app/data/models/quiz';
5 import { QuizService } from 'src/app/data/services/quiz.service';
6 import { switchMap } from 'rxjs/operators';
7 import { FormControl, FormGroup, Validators } from '@angular/forms';
8 import { Answer } from 'src/app/data/models/answer';
9
10 @Component({
11 selector: 'app-quiz',
12 templateUrl: './quiz.component.html',
13 styleUrls: ['./quiz.component.css']
14 })
15 export class QuizComponent implements OnInit, OnDestroy {
16 quiz!: Quiz;
17 quizForm: FormGroup = new FormGroup({});
18 quizId = 0;
19
20 private quizSub!: Subscription;
21
22 constructor( private quizService: QuizService, private route: ActivatedRoute, private router: Router ) { }
23
24 ngOnDestroy(): void {
25 this.quizSub.unsubscribe();
26 }
27
28 ngOnInit(): void {
29 this.quizSub = this.route.paramMap.pipe(
30 switchMap(params => {
31 this.quizId = Number(params.get('id'));
32 return this.quizService.getQuiz(this.quizId);
33 })
34 ).subscribe(
35 quiz => {
36 this.quiz = quiz;
37
38 if (quiz.questions) {
39 quiz.questions.forEach(question => {
40 this.quizForm.addControl(question.id.toString(), new FormControl('', Validators.required));
41 });
42 }
43 }
44 );
45 }
46
47 setAnswerValue(answ: Answer) {
48 this.quizForm.controls[answ.question.id].setValue(answ.value);
49 }
50
51 score() {
52 this.router.navigateByUrl(`/quizzes/${this.quizId}/score`, { state: this.quizForm.value });
53 }
54 }
The highlighted portions are the changes that were made to the file. Most of the changes reflect the substitution of UserAnswer
for Answer
. The quizSub
subscription was made private as it’s not used outside the component. Also, the score page URL changed from /quiz/${this.quizId}/score
to /quizzes/${this.quizId}/score
in the score
method to better conform to REST resource naming best practices (quiz is plural now).
The src/app/features/quiz/pages/quiz/quiz.component.html template also needs modifying to conform to the new data models. Replace all its content with this:
1 <div *ngIf="quiz" class="d-flex flex-column align-items-center">
2 <app-title [title]="quiz?.title" [subtitle]="quiz?.description"></app-title>
3 <form class="d-flex flex-column" [formGroup]="quizForm" *ngIf="(quiz?.questions?.length ?? 0) > 0"
4 (ngSubmit)="score()">
5 <ngb-carousel [animation]="false" [interval]="0" [wrap]="false">
6 <ng-template ngbSlide *ngFor="let question of quiz.questions; index as no">
7 <div class="picsum-img-wrapper">
8 <app-question [question]="question" [number]="no" (setAnswer)="setAnswerValue($event)">
9 </app-question>
10 <input type="hidden" [formControlName]="question.id.toString()" />
11 </div>
12 </ng-template>
13 </ngb-carousel>
14 <button class="btn btn-dark mx-auto m-4 btn-lg app-bg-light-purple" [disabled]="!quizForm.valid">Submit</button>
15 </form>
16 <div *ngIf="quiz?.questions?.length === 0" class="card app-bg-light-purple m-2">
17 <div class="card-body d-flex flex-column align-items-center">
18 <h4 class="font-weight-normal text-center mb-3">This quiz has no questions.</h4>
19 <a routerLink="/" class="btn btn-sm app-bg-purple">See More Quizzes</a>
20 </div>
21 </div>
22 </div>
The highlighted portions are the changes made to the template. Nothing has changed in the appearance of the page from the initial version of the app, but here’s a screenshot of what it looks like:
The score page shows individual scores. It’s used in two instances: to show a calculated score immediately a user submit answers to a quiz and to show a score for a past attempted quiz. The major changes you’ll make to this file are updating the data models and services it uses. Replace the content of src/app/features/quiz/pages/score/score.component.ts with this:
1 import { Component, OnInit } from '@angular/core';
2 import { ActivatedRoute } from '@angular/router';
3 import { iif, Observable } from 'rxjs';
4 import { switchMap } from 'rxjs/operators';
5 import { Answer } from 'src/app/data/models/answer';
6 import { ScoreResponse } from 'src/app/data/models/score-response';
7 import { ScoreService } from 'src/app/data/services/score.service';
8
9 @Component({
10 selector: 'app-score',
11 templateUrl: './score.component.html',
12 styleUrls: ['./score.component.css']
13 })
14 export class ScoreComponent implements OnInit {
15 scoreResp$: Observable<ScoreResponse> | undefined;
16 id = 0;
17
18 constructor(private route: ActivatedRoute, private scoreService: ScoreService) { }
19
20 ngOnInit(): void {
21 this.scoreResp$ = this.route.paramMap
22 .pipe(
23 switchMap(params => {
24 const state = window.history.state;
25 this.id = Number(params.get('id'));
26
27 if (window.location.pathname.startsWith('/quizzes/')) {
28 let reqBody: Answer[] = [];
29
30 for (const [qstId, answ] of Object.entries(state)) {
31 if (typeof answ === 'string') {
32 reqBody.push({ question: { id: Number(qstId) }, value: answ.toLowerCase() });
33 }
34 }
35
36 return iif(() => reqBody.length > 0,
37 this.scoreService.createScore({ id: this.id }, reqBody));
38 }
39
40 return this.scoreService.getScore(this.id);
41 })
42 );
43 }
44 }
Two services are injected into this component’s constructor: ActivatedRoute
to get the quiz id from the route params and ScoreService
to create and fetch individual scores. scoreResp$
holds the results from a request to the Strapi Score API. id
is either a quiz or score id.
When the component is initialized, the answers the user gave and the quiz/score id
are retrieved from window.history.state
, and the route params respectively. If the page route path is /quizzes/{id}/score
, a score is created using the answers and quiz id
. A request body is constructed from the answers and a request is made using the ScoreService
's createScore
method to create a score. If no answers are provided, no request is made. If the page route path is /scores/{id}
, then an existing score is fetched using the id
. In this case, id
is a score identifier and not a quiz identifier as in the former case.
Update the content of the src/app/features/quiz/pages/score/score.component.html template to this:
1 <ng-template #noScore>
2 <app-message title="No Results Yet" subtitle="You have to take a quiz to view results"
3 message="You haven't provided any answers for this quiz." buttonText="See Available Quizzes" redirectPath="/">
4 </app-message>
5 </ng-template>
6 <div *ngIf="scoreResp$ | async as scoreResp;else noScore"
7 class="d-flex flex-column justify-content-center align-items-center text-center">
8 <app-title [title]="scoreResp?.quiz?.title" [subtitle]="scoreResp?.quiz?.description"></app-title>
9 <h5 class="font-weight-lighter pl-2 pr-2">
10 {{scoreResp.score.createdAt | date:'fullDate'}} - {{scoreResp.score.createdAt | date:'shortTime'}}
11 </h5>
12 <hr class="vw-25">
13 <h2 class="font-weight-bold">You scored:</h2>
14 <h1 class="d-none d-lg-flex display-3 font-weight-bold app-bg-light-green p-2 result rounded">
15 {{scoreResp.scoreTotal}}/{{scoreResp.questionCount}}</h1>
16 <h1 class="d-md-flex d-lg-none display-3 font-weight-bold app-bg-light-green p-2 result rounded">
17 {{scoreResp.scoreTotal}}/{{scoreResp.questionCount}}</h1>
18 <h2 class="mt-3 font-weight-bold">Your answers:</h2>
19 <table class="table app-bg-light-green">
20 <thead>
21 <tr>
22 <th scope="col">#</th>
23 <th scope="col">Picked</th>
24 <th scope="col">Right?</th>
25 <th scope="col">Correct</th>
26 </tr>
27 </thead>
28 <tbody>
29 <tr *ngFor="let answ of scoreResp.score.answers; index as no">
30 <th scope="row">{{no + 1}}</th>
31 <td>{{answ.value | uppercase}}</td>
32 <td>{{answ.correct ? '✅' : '❌'}}</td>
33 <td>{{(answ.correctValue || answ.value) | uppercase}}</td>
34 </tr>
35 </tbody>
36 </table>
37 </div>
In this template, the values changed in the component are updated to match. How a “no answers” error is displayed (in the event that answers are not provided) is also modified. app-message
is used to display the aforementioned error. A timestamp is also added and displayed for the score. Lastly, we need to update the styling.
Here’s a screenshot of the new Score page:
Here’s a screenshot of the error displayed when no answers are provided or a previous score cannot be found:
This page displays all the user’s scores for quizzes they attempted in the past. The component didn’t exist before because previous scores were not saved and tracked. Replace the content of src/app/features/quiz/pages/scores/scores.component.ts with:
1 import { Component } from '@angular/core';
2 import { ScoreService } from 'src/app/data/services/score.service';
3
4 @Component({
5 selector: 'app-scores',
6 templateUrl: './scores.component.html',
7 styleUrls: ['./scores.component.css']
8 })
9 export class ScoresComponent {
10 scores$ = this.score.getScores();
11
12 constructor(private score: ScoreService) { }
13 }
This component’s constructor has only one service injected, ScoreService
. You’ll use this service to fetch all the logged-in user’s scores. The scores$
observable manages these scores and is returned when the getScores
method of ScoreService
is called.
In the src/app/features/quiz/pages/scores/scores.component.html template, add the code below:
1 <ng-template #noScores>
2 <app-message message="Sorry. You haven't taken any quizzes yet. So there are no scores to show."
3 buttonText="See Available Quizzes to Try" redirectPath="/">
4 </app-message>
5 </ng-template>
6 <div *ngIf="scores$ | async as scores" class="d-flex flex-column justify-content-center align-items-center">
7 <app-title title="Your Scores" subtitle="Check out your performance"></app-title>
8 <div *ngIf="scores.length > 0;else noScores" class="list-group mt-3 mb-5">
9 <a *ngFor="let scoreResp of scores" class="list-group-item list-group-item-action score"
10 [routerLink]="['/scores', scoreResp.score.id]">
11 <div class="d-flex w-100 justify-content-between">
12 <h5 class="mb-1">{{scoreResp.quiz.title}}</h5>
13 <h5 class="font-weight-bold text-success">{{scoreResp.scoreTotal+'/'+scoreResp.questionCount}}</h5>
14 </div>
15 <p class="mb-1">{{scoreResp.quiz.description}}.</p>
16 <small>{{scoreResp.score.createdAt | date:'fullDate'}} - {{scoreResp.score.createdAt |
17 date:'shortTime'}}</small>
18 </a>
19 </div>
20 </div>
In this template, all the returned scores are iterated over and added to the page. If the user has no previous scores, a “No scores” message is displayed on the page. To style the template, add this to src/app/features/quiz/pages/scores/scores.component.css:
1 .score {
2 min-width: 35vw;
3 }
Here’s a screenshot of the scores page with a logged-in user’s scores:
Here’s a screenshot of the message displayed if the user has no past scores:
There is only one page in the Core module, the not-found page, and it was already present in the initial version of the app.
Now that the new MessageComponent
exists, you can take advantage of it to simplify how the not-found page displays its message. Replace the content of the src/app/core/pages/not-found/not-found.component.html template with:
1 <app-message title="404" subtitle="Page Not Found" message="There was a problem finding your page."
2 buttonText="Go to Home Page" redirectPath="/">
3 </app-message>
Here’s a screenshot of this page:
In this step, you will add routes for the new pages you created in step 7. You will also update module options like imports and exports. Lastly, you will put finishing touches on the app like updating AppComponent
.
Since the ToastComponent
, and MessageComponent
are used in other modules, you’ll need to export them from the CoreModule
by including them in CoreModule
's exports
option. In the src/app/core/core.module.ts file, import the ToastComponent
and MessageComponent
and add them to the exports
option as highlighted below. You’ll also need to add NgbModule
to the imports
option because the ToastComponent
relies on it to display toast messages.
1 import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
2 import { ToastComponent } from './components/toast/toast.component';
3 import { MessageComponent } from './components/message/message.component';
4
5 @NgModule({
6 declarations: [ HeaderComponent, NotFoundComponent, TitleComponent, ToastComponent, MessageComponent ],
7 imports: [
8 CommonModule,
9 RouterModule,
10 NgbModule
11 ],
12 exports: [
13 HeaderComponent,
14 NotFoundComponent,
15 TitleComponent,
16 ToastComponent,
17 MessageComponent
18 ]
19 })
20 export class CoreModule { }
This module is missing some imports that its components rely on. The login and signup pages use form controls, validators, and form groups from the ReactiveModule
. These pages also use the TitleComponent
from the CoreModule
to show page titles. So you’ll have to include those in the src/app/features/auth/auth.module.ts file. Add the highlighted parts to the aforementioned file.
1 import { ReactiveFormsModule } from '@angular/forms';
2 import { CoreModule } from 'src/app/core/core.module';
3
4 @NgModule({
5 declarations: [ LoginComponent, SignupComponent],
6 imports: [
7 CommonModule,
8 AuthRoutingModule,
9 ReactiveFormsModule,
10 CoreModule
11 ]
12 })
13 export class AuthModule { }
To set up routes for the login and signup pages, you’ll have to add them in the AuthRoutingModule
. Replace the content of the src/app/features/auth/auth-routing.module.ts file with the code below.
1 import { NgModule } from '@angular/core';
2 import { RouterModule, Routes } from '@angular/router';
3 import { SignupGuard } from 'src/app/core/guards/signup.guard';
4 import { LoginComponent } from './pages/login/login.component';
5 import { SignupComponent } from './pages/signup/signup.component';
6
7 const routes: Routes = [
8 { path: 'login', component: LoginComponent },
9 { path: 'signup', canActivate: [SignupGuard], component: SignupComponent }
10 ];
11 @NgModule({
12 imports: [RouterModule.forChild(routes)],
13 exports: [RouterModule]
14 })
15 export class AuthRoutingModule { }
The highlighted portions are the changes that were made to the file. The login page is available at the /login
path and the signup page at the /signup
path. These are just the latter parts of the paths for the auth pages. An /auth
prefix is added in a later section. The signup page uses the SignupGuard
to deny access to the page to authenticated users.
Since the TitleComponent
was moved to the CoreModule
, it is no longer accessible to the QuizModule
. So, you’ll have to add the CoreModule
to QuizModule
’s imports. Add the highlighted snippets below to the src/app/features/quiz/quiz.module.ts file.
1 import { CoreModule } from 'src/app/core/core.module';
2
3 @NgModule({
4 declarations: [ QuestionComponent, QuizzesComponent, QuizComponent, ScoreComponent, ScoresComponent],
5 imports: [
6 CommonModule,
7 QuizRoutingModule,
8 NgbModule,
9 ReactiveFormsModule,
10 CoreModule
11 ]
12 })
13 export class QuizModule { }
We need to add some routes to QuizRoutingModule
and modifications to some existing routes. The new and modified routes include:
/quizzes/{id}
for the QuizComponent
. This changed from /quiz/{id}
to better conform to REST resource naming conventions. /quizzes/{id}/score
for the ScoreComponent
used when creating new scores after taking a quiz. This changes from quiz/{id}/score
for the same reason supplied above. /scores/{id}
for the ScoreComponent
to show a saved score from a past quiz attempt. This is a new route. /scores
for the ScoresComponent
to display all of a user’s past scores. This is also a new route. All the routes are protected from unauthenticated user access using the LogggedInGuard
. This is because you need a user to create and fetch scores. Add the highlighted portions to the src/app/features/quiz/quiz-routing.module.ts file:
1 import { ScoresComponent } from './pages/scores/scores.component';
2 import { LoggedInGuard } from 'src/app/core/guards/logged-in.guard';
3
4 const routes: Routes = [
5 { path: '', component: QuizzesComponent },
6 {
7 path: 'quizzes', canActivate: [LoggedInGuard], children: [
8 { path: ':id', component: QuizComponent },
9 { path: ':id/score', component: ScoreComponent }
10 ]
11 },
12 {
13 path: 'scores', canActivate: [LoggedInGuard], children: [
14 { path: '', component: ScoresComponent },
15 { path: ':id', component: ScoreComponent }
16 ]
17 }
18 ];
The last route you have to add to the app is for the AuthModule
. It should be available at the /auth/
path. In the src/app/app-routing.module.ts file, add the highlighted portions.
1 const routes: Routes = [
2 {
3 path: 'auth',
4 loadChildren: () => import('./features/auth/auth.module').then(m => m.AuthModule)
5 },
6 {
7 path: '',
8 loadChildren: () => import('./features/quiz/quiz.module').then(m => m.QuizModule)
9 },
10 { path: '404', component: NotFoundComponent },
11 { path: '**', component: NotFoundComponent }
12 ];
One last finishing touch you have to make to the app is adding the ToastComponent
to AppComponent
. It helps to display toast messages on all the app’s pages. Add the highlighted code snippet below to the src/app/component/app.component.html file.
1 <div class="d-flex flex-column h-100 w-100 p-1 align-items-center">
2 <app-header></app-header>
3 <div class="d-flex flex-grow-1 align-items-center justify-content-center">
4 <router-outlet></router-outlet>
5 </div>
6 </div>
7 <app-toast class="toast-msg"></app-toast>
To position the toast component so that it does not obstruct the header, add this styling to the src/app/component/app.component.css file.
1 .toast-msg {
2 margin-top: 4rem;
3 }
Before you can run the Angular app, ensure that the Strapi app you created in part 1 is running. You can start it using the command below on your terminal within the strapi-quiz-app
directory.
1 npm run develop
To run the Angular app, navigate to its root directory and run the command below:
1 ng serve
The quiz app will be available at http://localhost:4200.
Here’s a video showing your finished app in action.
In this tutorial, you updated the quiz app from the initial “Build a Quiz App using a Strapi API with Angular” tutorial from Angular 11 to 13. You added new user, score, and answer data models to match the Strapi v4 content types created in part 1. Additionally, you created new services to interface with and modified data from the Strapi Score API built in part 1 as well. You then added services to handle authentication using Strapi. New support components like a toasts component were added. Other components like the header were modified to include login, signup, and profile buttons. Several new pages like the Scores, Login, and Signup pages were built, and routes were added for them on the app.
Suppose you’d like to further build on the app. In that case, you can add proper User Profile pages where users can modify their information, change passwords, delete their accounts, etc. You can also extend the Scores page so users can delete past scores. You can find the source code for this tutorial on this GitHub repository under the feat-scores branch. The [tut-feat-scores](https://github.com/zaracooper/quiz-app/commits/tut-feat-scores)
branch has commits that correspond to each step in this tutorial. To learn more about Strapi and its other superior features, check out the Strapi website and the Strapi documentation and resources site for v4.
Zara is a software developer and technical writer, using React Native, React, Rails, Node, Golang, Angular and many more technologies.