How to Create a Simple Random Quote App with Angular
In this post I will show how to build a simple random quote page using Angular. I originally created this app using React for a freeCodeCamp project. The original React code can be found on my CodePen. This app will fetch quotes from a GitHub gist by camperbot
, and display a randomly chosen quote from that list when the page first loads. Then when the user clicks the New quote
button, a new quote is displayed. There is also a Tweet
button so that the user can tweet the current quote on Twitter.
Contents
- Tech Stack
- Getting Started
- Create New Angular App
- Start the Angular Development Server
- Modify Main HTML and CSS
- Modify App Component
- Generate QuoteBox Component
- Testing
- Final Thoughts
Tech Stack
- - Node.js
- - Angular
- - Sass (SCSS)
- - TypeScript
This post assumes some knowledge of HTML, CSS, and TypeScript/JavaScript. The source code for this app is on my GitHub.
Getting Started
The first thing to do would be to install Node.js and install Git. Once those are installed, the npm
(Node Package Manager) command will be available for installing various JavaScript packages. The first one we will install is @angular/cli
, the Angular Command Line Interface tool. The Angular CLI is a very handy and powerful program which can be used to generate a lot of boilerplate code, from creating a new Angular project to generating new components, modules, and services.
npm install -g @angular/cli
Create New Angular App
We'll start by generating a new project called fcc-random-quote-machine-angular
with the following command:
ng new fcc-random-quote-machine-angular
This will install some packages and set up a new Angular project with the initial files, directories, and dependencies all in place and ready to go. It even initializes a git repository and makes an initial commit.
Start the Angular Development Server
Angular CLI includes a serve
command so that we can preview any edits to the source code in the browser with live hot reloading. This is super convenient. The server does not need to be restarted on every change, and at worst the page might need to be refreshed, but most often it does not even need that. All changes made in this project will happen right before our eyes in the browser.
ng serve --open
# or the short version:
ng s -o
Modify Main HTML and CSS
The root template in Angular is src/index.html
. This is the main outermost template file, and is the place to set up such things in the head such as title, meta tags, stylesheets, as well as link external JavaScript. Replace the generated HTML with the following.
src/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>FreeCodeCamp Random Quote Machine (Angular)</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root id="root"></app-root>
</body>
</html>
Essentially, just a very basic bare-bones HTML file. Note the app-root
tag, which is where the Angular application will be inserted in the template.
The global stylesheet is at src/style.scss
. This is the stylesheet that would apply to the app as a whole. We will use it here to target only elements explicitly written in the src/index.html
file. Components will get their own separate styles later. I used the following simple styles here. This is also where external stylesheets will be imported at the app level.
/* Bootstrap 5 */
@import url('https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css');
/* Font Awesome */
@import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css');
/* Google Fonts */
@import url('https://fonts.googleapis.com/css2?family=Amiri&family=Indie+Flower&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Neucha&display=swap');
$blue: #58f;
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
#root {
display: flex;
justify-content: center;
align-items: center;
background-color: $blue;
height: 100%;
overflow-y: hidden;
}
As would be expected for such a basic main HTML template, this is a simple set of styles for our main Sass file.
Modify App Component
All Angular Components are made up of three files when generated by ng generate
:
- -
*.component.html
: the HTML template defining the UI of the component - -
*.component.css
: the private CSS stylesheet specifically for the component - -
*.component.ts
: the TypeScript file where the class defining the logic goes - -
*.component.spec.ts
: the TypeScript file where the component testing code lives
We'll start by updating the AppComponent
class. This is the root level Angular component, and in this case, it will be responsible for the logic for fetching the quote data and populating the variables that will be used for the quote box component we will generate later. Notice how every Component in Angular makes use of the @Component()
decorator, where some metadata is passed in about what the component's tag name in an HTML template will be, which file is the HTML template associated with this component, and which file is the associated stylesheet file. Angular CLI will always set things up so that these are all in separate files.
src/app/app.component.ts
import { Component, OnInit } from '@angular/core'
interface Quote {
quote: string
author: string
}
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
loading: boolean = true
quote!: Quote
quoteList!: Quote[]
tweetURL!: string
getNewQuote: () => void = (): void => {
const idx = Math.floor(Math.random() * this.quoteList.length)
const newQuote = this.quoteList[idx]
this.quote = newQuote
}
constructor() {}
ngOnInit() {
this.fetchData()
}
async fetchData(): Promise<void> {
const quotesURL =
'https://gist.githubusercontent.com/camperbot/5a022b72e96c4c9585c32bf6a75f62d9/raw/e3c6895ce42069f0ee7e991229064f167fe8ccdc/quotes.json'
const response = await fetch(quotesURL)
const quotes = await response.json()
const idx = Math.floor(Math.random() * quotes.quotes.length)
const newQuote = quotes.quotes[idx]
this.quoteList = quotes.quotes
this.quote = newQuote
this.setTweetURL(newQuote)
this.loading = false
}
setTweetURL(quote: Quote): void {
this.tweetURL = `https://twitter.com/intent/tweet?hashtags=quotes&related=freecodecamp&text=${quote.quote} --${quote.author}`
}
}
The HTML template for this component uses the *ngIf
directive. In Angular templates, this directive causes the loading text to only be rendered if the loading
property of the AppComponent
class is "truthy" (in our case, true
). This value will be true for the short duration of time while the component is fetching the data. When the fetchData()
method finishes inside the ngOnInit()
lifecycle hook, everything is fetched and populated, and the loading
variable will be set to false
. After loading, the loading text is replaced with the app-quote-box
instead.
src/app/app.component.html
<div *ngIf="loading; else content"><h1 id="loading">loading...</h1></div>
<ng-template #content>
<app-quote-box
[author]="quote.author"
[quote]="quote.quote"
[tweetURL]="tweetURL"
[getNewQuote]="getNewQuote"
></app-quote-box>
</ng-template>
Note the way attributes are set for the app-quote-box
. This is similar to how React does props in JSX for nested components. The square brackets represents that this attribute is binding to a class instance variable and the value in quotes are JavaScript expressions, in this case variable values coming from the AppComponent
class. This is how data is passed from a parent component to a child component in Angular.
The only styles the main app component really needs to be concerned about is the loading text rendered while loading. The rest will be handled by the QuoteBoxComponent
.
src/app/app.component.scss
$white: #fafafa;
#loading {
color: $white;
font-family: 'Amiri', serif;
}
Generate QuoteBox Component
Now we go to build the component that will be rendered in this app-quote-box
area of the app component template. The Angular CLI has a really convenient ng generate
command that can generate component boilerplate files and code for us, put everything where it needs to go in the project, and even automatically update the App Module declarations to include the newly generated component.
ng generate component QuoteBox
# or the short version:
ng g c QuoteBox
The QuoteBoxComponent
will be a super basic component with no methods and only some variables that will be used in the HTML template. It's essentially just a View component responsible for some UI. This reminds me of somewhat of basic React function components that only care about rendering UI given some props. Here, instead of receiving props in the constructor and setting the variables there, we have the Angular @Input()
decorator handling this.
src/app/quote-box/quote-box.component.ts
import { Component, Input } from '@angular/core'
@Component({
selector: 'app-quote-box',
templateUrl: './quote-box.component.html',
styleUrls: ['./quote-box.component.scss']
})
export class QuoteBoxComponent {
@Input() author!: string
@Input() quote!: string
@Input() tweetURL!: string
@Input() getNewQuote!: () => void
constructor() {}
}
Angular uses double curly brackets to interpolate variable values into templates when used as HTML tag inner text. Event handlers such as onClick have special syntax, like (click)
here. This binds the function call expression in the quotes to the onClick event for the button.
src/app/quote-box/quote-box.component.html
<div id="quote-box">
<h1 id="text"><i class="fa fa-quote-left"></i> {{ quote }}</h1>
<p id="author">- {{ author }}</p>
<div class="btn-row">
<button class="btn btn-primary" id="new-quote" (click)="getNewQuote()">
New quote
</button>
<a
id="tweet-quote"
href="{{ tweetURL }}"
target="_top"
class="btn btn-secondary"
>
<i class="fa fa-twitter"></i> Tweet
</a>
</div>
</div>
The quote box styles apply directly to the elements in the template for this component.
src/app/quote-box/quote-box.component.scss
$white: #fafafa;
$black: #3f3f3f;
#quote-box {
padding: 2em;
background-color: $white;
margin: 20%;
border-radius: 10px;
color: $black;
#text {
font-family: 'Amiri', serif;
}
#author {
font-family: 'Neucha', cursive;
font-size: 2.5em;
}
.btn-row {
display: flex;
flex-direction: row;
justify-content: flex-end;
#tweet-quote {
margin-left: 1em;
}
}
}
@media only screen and (max-width: 480px) {
#quote-box {
margin: 0;
overflow-y: auto;
}
}
Testing
Angular provides some great tooling out of the box for testing. Projects generated by the CLI come with component tests and end-to-end tests right out of the box.
Component Testing
Every component generated by Angular CLI comes with a *.component.spec.ts
file for testing the component via Jasmine. Here are some basic tests for the main app component.
src/app/app.component.spec.ts
import { TestBed } from '@angular/core/testing'
import { AppComponent } from './app.component'
import { QuoteBoxComponent } from './quote-box/quote-box.component'
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [AppComponent, QuoteBoxComponent]
}).compileComponents()
})
it('should create the app', () => {
const fixture = TestBed.createComponent(AppComponent)
const app = fixture.componentInstance
expect(app).toBeTruthy()
})
it('should render loading text when loading', () => {
const fixture = TestBed.createComponent(AppComponent)
const app = fixture.componentInstance
fixture.detectChanges()
const compiled = fixture.nativeElement
expect(app.loading).toBeTrue()
expect(compiled.querySelector('#loading').textContent).toEqual('loading...')
})
it('should render QuoteBoxComponent after loading', async () => {
const fixture = TestBed.createComponent(AppComponent)
const app = fixture.componentInstance
await app.fetchData()
fixture.detectChanges()
const compiled = fixture.nativeElement
expect(app.loading).toBeFalse()
expect(compiled.querySelector('app-root app-quote-box')).toBeDefined()
})
})
And for the quote box component, only a simple existence test:
src/app/quote-box/quote-box.component.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { QuoteBoxComponent } from './quote-box.component'
describe('QuoteBoxComponent', () => {
let component: QuoteBoxComponent
let fixture: ComponentFixture<QuoteBoxComponent>
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [QuoteBoxComponent]
}).compileComponents()
})
beforeEach(() => {
fixture = TestBed.createComponent(QuoteBoxComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should create', () => {
expect(component).toBeTruthy()
})
})
Run the following command to execute all the component tests.
ng test
End-to-end (e2e) Testing
Angular also has end-to-end (e2e) testing in every project out of the box as well. Rather than including the external freeCodeCamp testing JavaScript as a script tag in the main index.html
file, I thought it would be nice to rewrite them as e2e tests. We'll modify the following two files:
- -
e2e/src/app.e2e-spec.ts
- -
e2e/src/app.po.ts
The first of those files contains the test suite code and the second one contains a sort of page utility class used in the test suite, to keep things a little more organized.
e2e/src/app.e2e-spec.ts
import { browser, logging } from 'protractor'
import { AppPage } from './app.po'
describe('workspace-project App', () => {
describe('Content', () => {
let page: AppPage
beforeEach(() => {
page = new AppPage()
})
it('should display quote box', async () => {
await page.navigateTo()
expect(await page.getQuoteBox()).toBeTruthy()
})
it('should display text element inside quote box with random quote', async () => {
expect(await page.getQuoteBoxText()).toBeTruthy()
})
it(`should display author element inside quote box with quote's author`, async () => {
expect(await page.getQuoteBoxAuthor()).toBeTruthy()
})
it('should display "New quote" button inside quote box', async () => {
expect(await page.getNewQuoteButtonText()).toEqual('New quote')
})
it('should display "Tweet" button inside quote box', async () => {
expect(await page.getTweetButtonText()).toEqual('Tweet')
})
it('should fetch new quote when "New quote" button is clicked', async () => {
const initialQuoteText = await page.getQuoteBoxText()
await page.clickQuoteButton()
const newQuoteText = await page.getQuoteBoxText()
expect(initialQuoteText).toBeTruthy()
expect(newQuoteText).toBeTruthy()
expect(newQuoteText).not.toEqual(initialQuoteText)
})
it(`should update new quote's author when "New quote" button is clicked`, async () => {
const initialAuthor = await page.getQuoteBoxAuthor()
await page.clickQuoteButton()
const newAuthor = await page.getQuoteBoxAuthor()
expect(initialAuthor).toBeTruthy()
expect(newAuthor).toBeTruthy()
expect(newAuthor).not.toEqual(initialAuthor)
})
it('should open Twitter tweet intent when "Tweet" button is clicked', async () => {
expect(await page.getTweetURL()).toMatch(
/^https:\/\/twitter\.com\/intent\/tweet/
)
})
afterEach(async () => {
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER)
expect(logs).not.toContain(
jasmine.objectContaining({
level: logging.Level.SEVERE
} as logging.Entry)
)
})
}),
describe('Layout', () => {
let page: AppPage
beforeEach(() => {
page = new AppPage()
})
it('should display the quote box in the center horizontally', async () => {
const htmlElementBounds = await page.getHtmlElementBounds()
const quoteBoxBounds = await page.getQuoteBoxBounds()
const left = quoteBoxBounds.x0 - htmlElementBounds.x0
const right = htmlElementBounds.x1 - quoteBoxBounds.x1
expect(Math.abs(left - right)).toBeLessThan(20)
})
afterEach(async () => {
// Assert that there are no errors emitted from the browser
const logs = await browser.manage().logs().get(logging.Type.BROWSER)
expect(logs).not.toContain(
jasmine.objectContaining({
level: logging.Level.SEVERE
} as logging.Entry)
)
})
})
})
e2e/src/app.po.ts
import { browser, by, element, ElementFinder } from 'protractor'
interface ISize {
width: number
height: number
}
interface ILocation {
x: number
y: number
}
interface ElementXPair {
x0: number
x1: number
}
export class AppPage {
async navigateTo(): Promise<unknown> {
return browser.get(browser.baseUrl)
}
async getQuoteBox(): Promise<string> {
let quoteBox: ElementFinder = element(
by.css('app-root app-quote-box #quote-box')
)
let quoteBoxContent: string = await quoteBox.getText()
return quoteBoxContent
}
async getQuoteBoxText(): Promise<string> {
let quoteBoxText: ElementFinder = element(
by.css('app-root app-quote-box #quote-box #text')
)
let quoteBoxTextContent: string = await quoteBoxText.getText()
return quoteBoxTextContent
}
async getQuoteBoxAuthor(): Promise<string> {
let quoteBoxAuthor: ElementFinder = element(
by.css('app-root app-quote-box #quote-box #author')
)
let quoteBoxAuthorContent: string = await quoteBoxAuthor.getText()
return quoteBoxAuthorContent
}
async getNewQuoteButtonText(): Promise<string> {
let newQuoteButton: ElementFinder = element(
by.css('app-root app-quote-box #quote-box .btn-row #new-quote')
)
let newQuoteButtonText: string = await newQuoteButton.getText()
return newQuoteButtonText
}
async getTweetButtonText(): Promise<string> {
let tweetButton: ElementFinder = element(
by.css('app-root app-quote-box #quote-box .btn-row #tweet-quote')
)
let tweetButtonText: string = await tweetButton.getText()
return tweetButtonText
}
async clickQuoteButton(): Promise<void> {
let newQuoteButton: ElementFinder = element(
by.css('app-root app-quote-box #quote-box .btn-row #new-quote')
)
await newQuoteButton.click()
}
async clickTweetButton(): Promise<void> {
let tweetButton: ElementFinder = element(
by.css('app-root app-quote-box #quote-box .btn-row #tweet-quote')
)
await tweetButton.click()
}
async getTweetURL(): Promise<string> {
let tweetButton: ElementFinder = element(
by.css('app-root app-quote-box #quote-box .btn-row #tweet-quote')
)
let tweetButtonURL = await tweetButton.getAttribute('href')
return tweetButtonURL
}
async getHtmlElementBounds(): Promise<ElementXPair> {
let htmlElement: ElementFinder = element(by.tagName('html'))
let htmlElementSize: ISize = await htmlElement.getSize()
let htmlElementLocation: ILocation = await htmlElement.getLocation()
let htmlElementBounds: ElementXPair = {
x0: htmlElementLocation.x,
x1: htmlElementLocation.x + htmlElementSize.width
}
return htmlElementBounds
}
async getQuoteBoxBounds(): Promise<ElementXPair> {
let quoteBox: ElementFinder = element(
by.css('app-root app-quote-box #quote-box')
)
let quoteBoxSize: ISize = await quoteBox.getSize()
let quoteBoxLocation: ILocation = await quoteBox.getLocation()
let quoteBoxBounds: ElementXPair = {
x0: quoteBoxLocation.x,
x1: quoteBoxLocation.x + quoteBoxSize.width
}
return quoteBoxBounds
}
}
This one has all the methods used for getting certain text and other things from elements on the DOM.
To run all the e2e tests, run the following command. (make sure to quit the ng serve
command first, to free up port 4200)
ng e2e
This will open an automated instance of Chrome as it runs through the UI tests. Test results will be logged to the terminal.
Final Thoughts
I think this was an interesting little project for playing around with some basic Angular components, templates, directives, etc. In the next post, We'll compare and contrast the React code and Angular code for the same app. React and Angular are similar in that they are component-based, but take slightly different approaches to the same problem of creating the front-end of single-page applications.