5a - Writing a frontend

Let's write a frontend for our calculator project

Write a frontend

Pull the latest from main and create a new branch step5

git checkout main
git pull
git checkout -b 'step5'

Next, we are going to build out a frontend for our calculator app. We are going to be adding a few files to the web directory. You can do so by running

mkdir -p web/static/css
mkdir -p web/static/js

from the root directory. Since there are multiple files, they are aggregated in tabs below

{
  "env": {
    "test": {
      "plugins": ["@babel/plugin-transform-modules-commonjs"]
    }
  }
}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Codecov Server-side Calculator App</title>
    <link rel="stylesheet" href="/static/css/calculator.css">
</head>
<body>
    <div class="calculator">
        <div class="output">
            <div data-previous-operand class="previous-operand">123 +</div>
            <div data-current-operand class="current-operand">456</div>
        </div>
        <button class="span-3 disabled"></button>
        <button data-all-clear>C</button>
        <button data-number>7</button>
        <button data-number>8</button>
        <button data-number>9</button>
        <button data-operation>÷</button>
        <button data-number>4</button>
        <button data-number>5</button>
        <button data-number>6</button>
        <button data-operation>*</button>
        <button data-number>1</button>
        <button data-number>2</button>
        <button data-number>3</button>
        <button data-operation>-</button>
        <button data-number>.</button>
        <button data-number>0</button>
        <button data-equals>=</button>
        <button data-operation>+</button>
    </div>
    <script type="module" src="/static/js/calculatorView.js"></script>
</body>
</html>
{
  "name": "web",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "test": "jest --collectCoverage",
    "build": "babel --plugins @babel/plugin-transform-modules-commonjs script.js"
  },
  "keywords": [],
  "author": "",
  "license": "MIT",
  "dependencies": {
    "axios": "^0.25.0",
    "express": "^4.17.2"
  },
  "devDependencies": {
    "@babel/plugin-transform-modules-commonjs": "^7.16.8",
    "jest": "^27.4.7"
  }
}
const axios = require('axios')
const express = require("express");
const path = require("path");

const app = express();

const backendHost = 'http://localhost';
const backendPort = '8080';

app.use(express.json());
app.use("/static", express.static(path.resolve(__dirname, "static")));

app.post("/api/:operation", (req, res) => {
  axios.post(
    backendHost + ':' +  backendPort + '/api/' + req.params['operation'],
    req.body
  ).then(response => {
    res.json(response.data);
  }).catch(error => {
    console.log("Error: " + error);
  });
});

app.get("/", (req, res) => {
  res.sendFile(path.resolve("index.html"));
});
app.listen(process.env.PORT || 3000, () => console.log("Server running..."));

In that web/static folder, you can add the following files

*, *::before, *::after {
  box-sizing: border-box;
}

body {
  margin: 0;
  padding: 0;
  background-color: #ccc;
}

.calculator {
  align-content: center;
  display: grid;
  grid-template-columns: repeat(4, 100px);
  grid-template-rows: minmax(120px, auto) repeat(5, 100px);
  justify-content: center;
  min-height: 100vh;
}

.calculator > button {
  background-color: rbga(255, 255, 255, 0.75);
  border: 1px solid #FFFFFF;
  cursor: pointer;
  font-size: 32px;
  outline: none;
}

.calculator > button:hover {
  background-color: #aaaaaa;
}

.span-3 {
  grid-column: span 3;
}

.disabled:hover {
  background-color: rbga(255, 255, 255, 0.75);
  cursor: not-allowed;
}

.output{
  align-items: flex-end;
  background-color: rgba(0, 0, 0, 0.75);
  display: flex;
  flex-direction: column;
  grid-column: 1 / -1;
  justify-content: space-around;
  padding: 10px;
  word-break: break-all;
  word-wrap: break-word;
}

.output .previous-operand{
  color: rgba(255,255, 255, 0.75);
}

.output .current-operand{
  color: white;
  font-size: 42px;
}
export class Calculator {
  constructor(previousOperandTextElement, currentOperandTextElement) {
    this.previousOperandTextElement = previousOperandTextElement
    this.currentOperandTextElement = currentOperandTextElement
    this.clear()
  }

  clear() {
    this.currentOperand = ''
    this.previousOperand = ''
    this.operation = undefined
    this.validOperand = true
  }

  delete() {
    this.currentOperand = this.currentOperand.toString().slice(0, -1)
  }

  appendNumber(number) {
    if (!this.validOperand) return
    if (number === '.' && this.currentOperand.includes('.')) return
    this.currentOperand = this.currentOperand.toString() + number.toString()
  }

  chooseOperation(operation) {
    if (!this.validOperand) return
    if (this.currentOperand === '') return
    if (this.previousOperand !== '') {
      this.compute()
    }
    this.operation = operation
    this.previousOperand = this.currentOperand
    this.currentOperand = ''
  }

  compute() {
    if (!this.validOperand) {
      return;
    }

    const prev = parseFloat(this.previousOperand)
    const current = parseFloat(this.currentOperand)
    if (isNaN(prev) || isNaN(current)) return

    let operation
    switch (this.operation) {
      case '+':
        operation = 'add'
        break
      case '-':
        operation = 'subtract'
        break
      case '*':
        operation = 'multiply'
        break
      case '÷':
        operation = 'divide'
        break
      default:
        return
    }
    this.callApi(operation)
  }

  async callApi(operation) {
    const response = await fetch("/api/" + operation, {
      method: 'POST',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        x: this.previousOperand,
        y: this.currentOperand
      })
    })
    if (!response.ok) {
      throw new Error("Error: " + response.status);
    }
    this.currentOperand = await response.json();
    this.operation = undefined;
    this.previousOperand = '';
    this.updateDisplay();
  }

  getDisplayNumber(number) {
    const stringNumber = number.toString()
    const integerDigits = parseFloat(stringNumber.split('.')[0])
    const decimalDigits = stringNumber.split('.')[1]
    let integerDisplay
    if (isNaN(integerDigits)) {
      integerDisplay = stringNumber;
      if (stringNumber.length > 0) {
        this.validOperand = false;
      }
    } else {
      integerDisplay = integerDigits.toLocaleString('en', { maximumFractionDigits: 0 })
    }
    if (decimalDigits != null) {
      return `${integerDisplay}.${decimalDigits}`
    } else {
      return integerDisplay
    }
  }

  updateDisplay() {
    this.currentOperandTextElement.innerText =
      this.getDisplayNumber(this.currentOperand)
    if (this.operation != null) {
      this.previousOperandTextElement.innerText =
        `${this.getDisplayNumber(this.previousOperand)} ${this.operation}`
    } else {
      this.previousOperandTextElement.innerText = ''
    }
  }
}
import { Calculator } from './calculator.js';

const numberButtons = document.querySelectorAll('[data-number]')
const operationButtons = document.querySelectorAll('[data-operation]')
const equalsButton = document.querySelector('[data-equals]')
const allClearButton = document.querySelector('[data-all-clear]')
const previousOperandTextElement = document.querySelector('[data-previous-operand]')
const currentOperandTextElement = document.querySelector('[data-current-operand]')

const calculator = new Calculator(previousOperandTextElement, currentOperandTextElement)

numberButtons.forEach(button => {
  button.addEventListener('click', () => {
    calculator.appendNumber(button.innerText)
    calculator.updateDisplay()
  })
})

operationButtons.forEach(button => {
  button.addEventListener('click', () => {
    calculator.chooseOperation(button.innerText)
    calculator.updateDisplay()
  })
})

equalsButton.addEventListener('click', button => {
  calculator.compute()
})

allClearButton.addEventListener('click', button => {
  calculator.clear()
  calculator.updateDisplay()
})

document.addEventListener('keydown', function (event) {
  let patternForNumbers = /[0-9]/g;
  let patternForOperators = /[+\-*\/]/g
  if (event.key.match(patternForNumbers)) {
    event.preventDefault();
    calculator.appendNumber(event.key)
    calculator.updateDisplay()
  }
  if (event.key === '.') {
    event.preventDefault();
    calculator.appendNumber(event.key)
    calculator.updateDisplay()
  }
  if (event.key.match(patternForOperators)) {
    event.preventDefault();
    calculator.chooseOperation(event.key)
    calculator.updateDisplay()
  }
  if (event.key === 'Enter' || event.key === '=') {
    event.preventDefault();
    calculator.compute()
  }
});

We can test out our frontend by running

cd web
npm install
node server.js

Be sure to run the backend server as well in a different terminal.
Go to http://localhost:3000 to view the calculator

Feel free to play around with the interface.

Writing frontend tests

Let’s now add tests to our calculator.js file

const calc = require('./calculator');

describe('Calculator test suite', () => {
  test('calculator clears', () => {
    const calculator = new calc.Calculator();
    calculator.clear();

    expect(calculator.currentOperand).toBe('');
    expect(calculator.previousOperand).toBe('');
    expect(calculator.operation).toBe(undefined);
    expect(calculator.validOperand).toBe(true);
  })

  test('calculator can input numbers', () => {
    const calculator = new calc.Calculator();
    calculator.appendNumber(1);
    expect(calculator.currentOperand).toBe('1');

    calculator.appendNumber(2);
    expect(calculator.currentOperand).toBe('12');
  })

  test('calculator can deletes', () => {
    const calculator = new calc.Calculator();
    calculator.appendNumber(1);
    expect(calculator.currentOperand).toBe('1');

    calculator.appendNumber(2);
    expect(calculator.currentOperand).toBe('12');

    calculator.delete();
    expect(calculator.currentOperand).toBe('1');
  })
})

Run the tests

We can run tests locally with the following command

npm run test

and we should see the following output

PASS  static/js/calculator.test.js
  Calculator test suite
    ✓ calculator clears (3 ms)
    ✓ calculator can input numbers
    ✓ calculator can deletes

---------------|---------|----------|---------|---------|-------------------
File           | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
---------------|---------|----------|---------|---------|-------------------
All files      |   18.03 |     9.09 |   44.44 |   19.64 |
 calculator.js |   18.03 |     9.09 |   44.44 |   19.64 | 26-113
---------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        0.967 s

Add a frontend CI workflow

Let’s add a workflow to run our frontend tests

GitHub Actions

name: Frontend workflow

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    name: Test JS frontend
    defaults:
      run:
        working-directory: ./web
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-node@v2
      with:
        node-version: '16'
    - name: Install requirements
      run: npm install
    - name: Run tests and collect coverage
      run: npm run test
    - name: Upload coverage reports to Codecov with GitHub Action
      uses: codecov/[email protected]
      env:
        CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

CircleCI

... is used to demark previous code, be sure add in the applicable lines in the appropriate areas.

jobs:
  ...
  test-frontend:
    docker:
      - image: cimg/node:17.6.0
    steps:
      - checkout
      - run:
          name: Install requirements
          command: cd web && npm install
      - run:
          name: Run tests and collect coverage
          command: cd web && npm run test
      - codecov/upload

workflows:
  ...
  build-test:
    jobs:
      ...
      - test-frontend

This workflow does a similar set of steps as our API workflow. If we commit this code,

cd ..  # back to the root directory
git add .
git commit -m 'step5: add frontend and tests'
git push origin step5

and open a PR, we see that our code coverage has greatly decreased! With the addition of the new, but poorly tested frontend, our status checks fail.

We also see that in the Codecov comment.

We see this type of situation happen all the time. For example, some projects in a monorepository might be starting code coverage for the first time, while other parts have been using Codecov for months or years. Adding code coverage like this can greatly decrease the percentage for the whole project.

However, what you may not have noticed is that Codecov automatically merges coverage reports together, regardless of the language or CI workflow. This happens under the hood and requires no additional configuration from you.

In the next section, we'll show you how to use Codecov Flags to help group coverage reports and to set different coverage standards for different parts of your project.