Build a Progressive Web Application with Django and Angular Part 2: Frontend Interface with Angular

July 05, 2022
Written by
Robert Alford
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by
Mia Adjei
Twilion

Build a Progressive Web Application with Django and Angular Part 2: Frontend Interface with Angular

In the first part of this tutorial series, we began building a trivia game as a Progressive Web Application. We used the Django web framework and the Python programming language to create and populate our project’s database with trivia data, and the Django Rest Framework to rapidly develop a read-only API endpoint for our project’s frontend interface to consume along with a browsable web-based interface for viewing our API data in JSON format. Now in part two of this series, we will build our trivia game’s UI using the Angular frontend framework and TypeScript, a strongly-typed extension of the JavaScript programming language that is fully integrated into Angular. And finally we will convert our project into a Progressive Web Application (PWA) that can be installed alongside native applications on a mobile device such as a phone or tablet.

Screenshot of completed trivia app

Tutorial requirements

  • Python version 3.6 or higher. If you do not have Python installed on your computer, you can download a free installer here.
  • The Django backend web framework which we will install with Python’s pip package manager in the section below.
  • An active LTS or maintenance LTS version of Node.js. (This tutorial was written using version 14.17.3.) If you do not have Node installed on your computer, you can download it here.
  • The Angular frontend web framework which we will install with Node’s NPM package manager below.
  • Chrome web browser and the free, open-source Lighthouse extension for auditing websites and PWAs.
  • Ngrok and a free Ngrok account. See this recently published Twilio article on setting up Ngrok in 2022.

What makes an app a Progressive Web App?

The term 'Progressive Web Application' was originally coined by Google to describe web apps that meet a variety of requirements in order to function similarly to native mobile or desktop applications. MDN Web Docs provides a helpful list of these criteria to keep in mind when designing and building PWAs. PWAs should be discoverable by search engines, installable on a user's home screen, linkable via URL, and secured through the HTTPS protocol. They should be designed responsively for a variety of potential device sizes, progressively enhanced to function on both older and newer browser versions, and re-engageable through push messages and notifications used to interact with users even when they aren't logged into a web browser. The goal of this tutorial series is to give you the basic tools for creating your own PWAs with Django and Angular and to provide an improved user experience for your web applications' users.

Angular project setup

If this is your first Angular project, begin by installing the Angular CLI tool globally from NPM and then create a new Angular project for your PWA’s frontend web UI. From the outer djangular-trivia-pwa directory, alongside your Python virtual environment and djangular_trivia_api Django project, run the following commands:

 

$ npm install -g @angular/cli
$ ng new djangular-trivia-web

Answer ‘Yes’ to the prompt: ‘Would you like to add Angular routing?’, and select the stylesheet format of your choice. Now you can confirm that your Angular installation is working locally with the following commands:

$ cd djangular-trivia-web
$ ng serve --open

When the Angular dev server has started up, you should see the following message in your console: ‘Angular Live Development Server is listening on localhost:4200. And if you navigate to that URL in your local browser, you will be greeted with the Angular start screen.

Angular start screen

Build the Trivia Game UI with Angular

In the root folder of our Angular project at djangular-trivia-pwa/djangular-trivia-web, we can get started building out our UI by installing the Angular Material UI Component library. This toolkit will help us to rapidly develop a mobile-friendly interface for our web app, which is a requirement of a PWA.

Run the following command to install Angular Material locally in your project:

$ ng add @angular/material

Answer the prompts to select a prebuilt theme and answer ‘Y’ to the prompts for adding global typography styles and browser animations. Once you have done this, the Angular Material NPM package will be downloaded to your project’s node_modules folder.

With Angular Material installed, we can add a toolbar to our site by importing the MatToolbarModule into our djangular-trivia-web/src/app/app.module.ts file and including the module in the imports annotation on the AppModule class decorator like so:

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

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule,
    MatToolbarModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Then replace the auto-generated HTML code in djangular-trivia-web/src/app/app.component.html with this:

<mat-toolbar color="primary">
   <span>Trivia App</span>
 </mat-toolbar>

<router-outlet></router-outlet>

Fire up the Angular dev server with ng serve --open and you should be greeted with the following blank canvas for your app:

Trivia app UI so far

Our Angular application will be composed of a single Angular component containing an HTML template file for rendering our UI, a TypeScript file for defining our UI’s behavior, and a CSS file for adding custom styles. You can create those files and automatically add the component to the declarations annotation on your global AppModule class by running the following command:

ng generate component trivia

Once you run that command, you should see a new folder at djangular-trivia-web/src/app/trivia/ containing your component files. You can then wire up your component to the Angular router at the root of your site by updating the Routes array in djangular-trivia-web/src/app/app-routing.module.ts:

…
import { TriviaComponent } from "./trivia/trivia.component";

const routes: Routes = [
  { path: '', component: TriviaComponent },
];
…

Now if you refresh your browser at http://localhost:4200/ while running the Angular dev server, you should see the text ‘Trivia works!’ displayed via the <router-outlet></router-outlet> element in djangular-trivia-web/src/app/app.component.html.

With our trivia component all wired up and ready to go, we can now create an Angular service to facilitate communication with our web app’s backend API server. Angular services provide a way to separate our application’s component presentation logic from its data sources, resulting in a nicely composable and decoupled architecture and project structure. We can add a trivia service to our app by running the following command:

ng generate service trivia

This command creates a new file at djangular-trivia-web/src/app/trivia.service.ts, alongside its associated test file trivia.service.spec.ts.

Before writing our service code to consume data from our backend API, we should create contracts for our custom data types by defining TypeScript interfaces for the objects that our API will serve. Create a new file at djangular-trivia-web/src/app/trivia.ts and add the following contents:

export interface Answer {
  answer: string,
  is_correct: boolean;
}

interface AnswerArray extends Array<Answer> { }

export interface Question {
  question: string,
  category: string,
  answers: AnswerArray
}

export interface QuestionArray extends Array<Question> { }

These interface definitions match the JSON schema of our backend API, and provide a layer of validation and type-checking which we can leverage in our service and component files where we will work with that data.

In order to communicate with our application’s backend API, we must first import the Angular HttpClientModule into our djangular-trivia-web/src/app/app.module.ts file and add the module to the imports annotation of the AppModule class in that file:

…
import { HttpClientModule } from "@angular/common/http";

@NgModule({
  declarations: [
    AppComponent,
    TriviaComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule,
    MatToolbarModule,
    HttpClientModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Then open your service file at  djangular-trivia-web/src/app/trivia.service.ts and replace its contents with the following code:

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

import { QuestionArray } from "./trivia";


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

 constructor(private http: HttpClient) { }

 getTrivia(): Observable<QuestionArray> {
   return this.http.get('http://127.0.0.1:8000/api/v1/questions/') as Observable<QuestionArray>;
 }
}

Here we use Angular’s dependency injection pattern to inject the HttpClient into our TriviaService class. In the getTrivia() method, we then use the HTTP client to call our backend API and return an RxJS Observable of QuestionArray objects. Now head over to your djangular-trivia-web/src/app/trivia/trivia.component.ts file and edit the file to look like this:

import { Component, OnInit } from '@angular/core';
import { TriviaService } from "../trivia.service";

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

  constructor(private triviaService: TriviaService) { }

  ngOnInit(): void {
    this.getTrivia();
  }

  getTrivia() {
    this.triviaService.getTrivia().subscribe({
        next: (data) => {
          console.log(data);
        },
        error: (error) => {
          console.log(error);
        }
      }
    )
  }

}

Here we inject our TriviaService class in the constructor() method of the TriviaComponent and define a new getTrivia() method to subscribe to the Observable of trivia data returned by TriviaService.getTrivia(). Then we add a couple of logging statements to the next and error properties of the returned object so we can confirm that everything is working.

You can do that now by opening a Chrome web browser and navigating to http://localhost:4200/ with the Angular dev server running. Be sure that the dev server for the Django project that we built in part one of this series is also running on your local machine at http://127.0.0.1:8000. Right click anywhere on the screen of the open browser tab, and select ‘Inspect’ from the menu. This will open the Chrome Developer Tools panel alongside your active browser tab. Refresh the page and select the Console tab at the top of the Developer Tools panel. Expand the log line from the trivia.component.ts file that reads Array(10), and you should see the data returned from your backend API:

Chrome dev tools Console tab, displaying data

With our frontend application successfully consuming data from our backend API, it’s time to start building our actual user interface. Let’s begin by displaying a single trivia question on the screen.

In djangular-trivia-web/src/app/trivia/trivia.component.ts add two new attributes to the top lines of your TriviaComponent class, one for triviaData and one for question:

import { Question, QuestionArray} from "../trivia";
…
export class TriviaComponent implements OnInit {
  triviaData: QuestionArray = [];
  question: Question|null = null;

Then remove the console.log(data) statement from your component’s getTrivia() method and update it with the following code:

  getTrivia() {
    this.triviaService.getTrivia().subscribe({
        next: (data) => {
          this.triviaData = data;
          this.getNextQuestion();
        },
        error: (error) => {
          console.log(error);
        }
      }
    )
  }

Define this new getNextQuestion() method on your component class:

  getNextQuestion() {
    if (this.triviaData.length) {
      const index = Math.floor(Math.random() * this.triviaData.length);
      this.question = this.triviaData[index];
      this.triviaData.splice(index, 1);
    } else {
      this.question = null;
    }
  }

Here we create a triviaData attribute to hold all of the questions for our quiz after they are received from our triviaService dependency in the getTrivia() method. We define a question attribute which we will use shortly to display the current question via our HTML template. And we implement a getNextQuestion() method that selects and removes a random question from the triviaData array and sets it as the current value of the question attribute.

Now open djangular-trivia-web/src/app/trivia/trivia.component.html and replace the contents with this code:

<div id="question-container">
  <ng-container *ngIf="question; else quizComplete">
    <p><b>{{ question.category }}</b></p>
    <p [innerHTML]="question.question"></p>
  </ng-container>
  <ng-template #quizComplete>
    <p>We're all out of questions!</p>
  </ng-template>
</div>

In our template code, we use the Angular ngIf directive on an ng-container element to conditionally display our first trivia question once the component’s question attribute contains a value. The [innerHTML] property binding provides a safe way to decode the HTML entities included in the API response and display our questions as plain text. And we provide a fallback message in the ng-template element that will display if there are no more questions to answer. Finally, add a little padding to your component in djangular-trivia-web/src/app/trivia/trivia.component.css:

#question-container {
  padding: 20px;
}

Refresh your local browser that is running the app and you should see a random trivia question displayed on the screen:

Trivia app displaying a random question

Great! Now let’s move on to displaying our multiple choice answers. Angular Material provides a radio button component that will work nicely for presenting our multiple choice and true or false answer options.

Head back over to the djangular-trivia-web/src/app/app.module.ts file and add the following import statements to the top of the file. Then add the modules to the imports annotation on the AppModule class below as you did with your other globally imported modules:

…
import { FormsModule } from "@angular/forms";
import { MatRadioModule } from "@angular/material/radio";

@NgModule({
  declarations: [
    AppComponent,
    TriviaComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule,
    MatToolbarModule,
    HttpClientModule,
    FormsModule,
    MatRadioModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

In your djangular-trivia-web/src/app/trivia/trivia.component.ts add an answer attribute to your TriviaComponent class along with an import of your Answer interface from djangular-trivia-web/src/app/trivia.ts:

…
import { QuestionArray, Question, Answer } from '../trivia';
…
export class TriviaComponent implements OnInit {
  triviaData: QuestionArray = [];
  question: Question|null = null;
  answer: Answer|null = null;

Then define a new getCorrectAnswer() method on the component class that looks like this:

  getCorrectAnswer() {
    if (this.question) {
      return this.question.answers.filter(answer => answer.is_correct)[0].answer;
    }
    return '';
  }

Update your djangular-trivia-web/src/app/trivia/trivia.component.html template code to use MatRadioModule along with the Angular FormsModule’s ngModel directive:

<div id="question-container">
  <ng-container *ngIf="question; else quizComplete">
    <p><b>{{ question.category }}</b></p>
    <label id="question-radio-group-label" [innerHTML]="question.question"></label>
    <mat-radio-group
      aria-labelledby="question-radio-group-label"
      class="question-radio-group"
      [(ngModel)]="answer">
      <mat-radio-button class="question-radio-button" *ngFor="let answer of question.answers" [value]="answer">
        {{answer.answer}}
      </mat-radio-button>
    </mat-radio-group>

    <div *ngIf="answer">
      <div *ngIf="answer.is_correct; else incorrect">
        <p>Correct!</p>
      </div>
      <ng-template #incorrect>
        <p>Wrong! The correct answer is {{ getCorrectAnswer() }}</p>
      </ng-template>
    </div>
  </ng-container>
  <ng-template #quizComplete>
    <p>We're all out of questions!</p>
  </ng-template>
</div>

We’ve now moved our trivia question to an HTML label tag for our radio button form. We’ve added a mat-radio-group component and used Angular’s ngModel directive to bind the value of our answer component attribute to the currently selected answer in the form. Then we’ve used the ngIf directive to display a success message if the currently selected answer is correct, or to display that correct answer if the user chooses the wrong answer.

Now we can add the following CSS rules to djangular-trivia-web/src/app/trivia/trivia.component.css to stack the radio buttons in a vertical column and add some spacing to our layout:

.question-radio-group {
   display: flex;
   flex-direction: column;
   margin: 15px 0;
   align-items: flex-start;
 }

 .question-radio-button {
   margin: 5px;
 }

Return to your browser window, and the UI for your trivia game should look something like this:

Question with an incorrect multiple-choice answer selected

Not bad! Next let’s add a button for the user to submit their answer and move to the next question. First include the MatButtonModule in djangular-trivia-web/src/app/app.module.ts

…
 import { MatButtonModule } from "@angular/material/button";

@NgModule({
  declarations: [
    AppComponent,
    TriviaComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule,
    MatToolbarModule,
    HttpClientModule,
    FormsModule,
    MatRadioModule,
    MatButtonModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Then add a button element to the bottom of your ng-container element in djangular-trivia-web/src/app/trivia/trivia.component.html:

<button mat-flat-button color="primary" id="next-button" [disabled]="disableNextButton" (click)="getNextQuestion()">Next Question</button>

We want the button to be disabled until the user selects an answer, and then to load the next question via our component’s getNextQuestion() method when clicked. We should also disable the radio buttons after the user selects their answer choice. You can add two new attributes to the TriviaComponent class in djangular-trivia-web/src/app/trivia/trivia.component.ts to track these disabled states:

disableRadioButtons: boolean = false;
disableNextButton: boolean = true;

And you can define the following method on your component to update the disabled states' values when an answer is selected, importing the MatRadioChange event object at the top of the file:

import { MatRadioChange } from "@angular/material/radio";
…
  answerSelected(event: MatRadioChange) {
    this.disableRadioButtons = true;
    this.disableNextButton = false;
  }

You can then call the answerSelected() method in your template by adding a (change) event listener to your mat-radio-button element, as well as a property binding on the disabled attribute of your mat-radio-group element so that it looks like this:

    <mat-radio-group
      aria-labelledby="question-radio-group-label"
      class="question-radio-group"
      [disabled]="disableRadioButtons"
      [(ngModel)]="answer">
      <mat-radio-button class="question-radio-button" *ngFor="let answer of question.answers" [value]="answer" (change)="answerSelected($event)">
        {{answer.answer}}
      </mat-radio-button>
    </mat-radio-group>

Finally, you will want to reset the state of your component inside your getNextQuestion() method when the Next Question button is clicked:

  getNextQuestion() {
    if (this.triviaData.length) {
      const index = Math.floor(Math.random() * this.triviaData.length);
      this.question = this.triviaData[index];
      this.triviaData.splice(index, 1);
    } else {
      this.question = null;
    }
    this.answer = null;
    this.disableRadioButtons = false;
    this.disableNextButton = true;                                
  }

Now when a question loads on the screen, the Next Question button will appear disabled until an answer is selected:

Multiple-choice question with the Next Question button disabled

Select an answer, and the button is enabled but the radio select options are locked:

Multiple-choice question with one answer selected, and the radio buttons disabled

As a finishing touch for our trivia game’s UI, let’s add a header to our HTML template to display the number of questions answered as well as our game score. Add the following code to djangular-trivia-web/src/app/trivia/trivia.component.html just above your ng-container element:

<div id="question-header">
    <h3>Question {{ questionNumber + 1 }}</h3>
    <h3 class="score">Score: {{ correctAnswers }}/{{ questionNumber }}</h3>
  </div>

Then add two new attributes to the TriviaComponent class in djangular-trivia-web/src/app/trivia/trivia.component.ts and initialize them to zero:

questionNumber: number = 0;
correctAnswers: number = 0;

Update your getNextQuestion() method to increment these values according to the user’s response, so that the final version of the method looks like this:

  getNextQuestion() {
    if (this.triviaData.length) {
      const index = Math.floor(Math.random() * this.triviaData.length);
      this.question = this.triviaData[index];
      this.triviaData.splice(index, 1);
    } else {
      this.question = null;
    }

    if (this.answer) {
      this.questionNumber++;
      if (this.answer.is_correct) {
        this.correctAnswers++;
      }
    }

    this.answer = null;
    this.disableRadioButtons = false;
    this.disableNextButton = true;
  }

Then add the following CSS rule to djangular-trivia-web/src/app/trivia/trivia.component.css to align the question number and score elements of our header on the left and right side of the screen:

#question-header {
  display: flex;
  justify-content: space-between;
}

Now when your users play your trivia game they can keep track of their score and what number question that they are answering in the quiz:

Trivia game UI showing current question number and score

Nice work! We’ll call that good for our trivia game’s UI at this point, and move on to converting our application to a PWA.

Convert your app to a Progressive Web Application

Angular provides a command line utility for converting your project to a Progressive Web Application, allowing for offline caching of HTML files and static assets as well as the ability to install your application on the homescreen of a mobile device for easy access via a touch icon.

Let’s take it for a spin by running the following command:

 

$ ng add @angular/pwa --project djangular-trivia-web

Now if you open djangular-trivia-web/src/app/app.module.ts you will see a new entry in the imports annotation on the AppModule class that looks like this:

ServiceWorkerModule.register('ngsw-worker.js', {
  enabled: environment.production,
  // Register the ServiceWorker as soon as the application is stable
  // or after 30 seconds (whichever comes first).
  registrationStrategy: 'registerWhenStable:30000'
}),

One of the key components of a PWA is the ability to utilize service workers, which are JavaScript scripts that provide a proxy between the browser, your web application, and the network. Service workers have the ability to intercept your application’s HTTP requests and return locally cached files and assets when available, allowing a PWA to function offline, and the scripts can be customized in whatever way is needed to provide the most robust and user-friendly caching mechanism for your application.

The ng add @angular/pwa command you just ran installs the @angular/service-worker package which comes with a default caching strategy already in place. This strategy can be modified by editing the ngsw-config.json config file that is also added to your project when you run the command. If you take a look in that file at djangular-trivia-web/ngsw-config.json, you will see settings under the assetGroups property defining which files and file types should be cached locally, including our project’s static CSS and JavaScript files, image files, index.html file, and a file called manifest.webmanifest. Other files and file patterns may be added here according to your project’s needs.

The manifest.webmanifest file referenced in the service worker configuration is another key component in converting your application to a PWA. This file was also created by the ng add @angular/pwa command, and loaded into your application via the <link rel="manifest" href="manifest.webmanifest"> element in the <head> of your djangular-trivia-web/src/index.html file. This Web App Manifest is written in a JSON-like format and contains all of the necessary data for downloading your app to a user’s device so that it may be accessed in the same way as a native application, including links to images that will serve as touch icons for your PWA on mobile devices. The @angular/pwa utility also provided you with some starter icons in your project’s djangular-trivia-web/src/assets/ folder. However, in order for your PWA touch icons to work on Apple devices, it is necessary for you to add the following line to the <head> of your index.html file:

<link rel="apple-touch-icon" href="assets/icons/icon-192x192.png">

Because Angular’s PWA features don’t work in dev mode via ng serve, if you want to test these features locally, you’ll need to build your project and serve it with the http-server package from NPM. You can install http-server globally and run your PWA by entering the following commands:

$ npm install --global http-server
$ ng build 
$ cd dist/djangular-trivia-web 
$ http-server -o

You will also need to add "http://127.0.0.1:8080" to the CORS_ALLOWED_ORIGINS setting in the djangular_trivia_api/djangular_trivia_api/settings.py file of your Django project, as this is the default port that http-server uses to serve from localhost.

Now it’s the moment of truth where we will find out if our application is indeed a fully functioning PWA. With your Chrome web browser pointed to http://127.0.0.1:8080, open the Developer Tools console and select the Lighthouse tab (see the Tutorial requirements section for a link to install the Lighthouse Chrome browser extension). Under ‘Categories’ check only ‘Progressive Web Application’ and for ‘Device’ select ‘Mobile’. Then, click the Generate report button. You will see a message indicating that Lighthouse is auditing your application for PWA compatibility followed by this screen once the audit is complete:

Completed PWA audit report

Ok! It looks like we have successfully converted our application to a PWA. For a bit more confirmation, head over to the Application tab of the Developer Tools console. Here you will see a menu that includes Manifest and Service Workers near the top of the screen. Click into those tabs to view the data that the browser has stored from our manifest.webmanifest and ngsw-worker.js files. You can also click on the Cache => Cache Storage menu item to view the files from the service worker config file that have been successfully cached by the browser.

Dev tools opened to view cache storage

You can take your testing one step further by heading over the Network tab and selecting ‘Offline’ mode from the dropdown that reads ‘No Throttling’. Now if you refresh your browser window, instead of Chrome’s standard ‘There is no internet connection’ message, you should see the trivia app load with the fallback message that shows when no remaining trivia questions are available. Switch back to ‘No Throttling’ and your app will load successfully upon refreshing your browser.

One of the coolest things about PWA’s is the ability to add custom touch icons to a mobile device’s home screen for the same kind of quick and easy access available to native applications. To try out this functionality, you can use Ngrok to serve your local Angular build at a public HTTPS-protected URL. With ngrok installed on your system, and your local http-server running the PWA build on port 8080, run ngrok http 8080 and you should see some output like this in your terminal:

Ngrok output

Copy the HTTPS address under the bottom Forwarding entry and add it temporarily to the CORS_ALLOWED_ORIGINS settings in djangular_trivia_api/djangular_trivia_api/settings.py. Now if you visit this web address in your mobile phone’s browser, you can add your application to the device’s home screen.

On my iPhone, this option is available via the ‘Share’ menu of the Safari browser, and as you can see from these screenshots, the data and icons from our manifest.webmanifest file are used by the device to provide our PWA with a seamless appearance alongside the other touch icons from native applications.

Adding your PWA to the home screen
PWA app icon on the home screen

Conclusion

In this tutorial series, you’ve learned the basic steps for creating Progressive Web Applications with Django and Angular, and built a fun little trivia app while you were at it. But we’ve only scratched the surface of what you can achieve with PWA technologies. Check out Mozilla’s extensive documentation covering all of the many features available to PWAs, including the Push and Notifications Web APIs. These can be combined with the Twilio Notify API to add re-engageability to your PWA through device-targeted push messages even when your users are not logged into your app. As these technologies advance, the opportunities for web developers to create native-like experiences for their users will continue to grow — a true win-win for web application users and developers alike.  

Robert Alford is a software developer, writer, and educator based in Seattle. You can find him on GitHub and LinkedIn.