Categories
JavaScript Nodejs React

How to Create Word Documents with Node.js

Microsoft Word is a very popular word processor. It is a popular format for making and exchanging documents. Therefore, a feature of a lot of apps is to create Word documents from other kinds of documents. Creating Word documents is easy in Node.js apps with third-party libraries. We can make an app from it easily ourselves.

In this article, we will make an app that lets users enter their document in a rich text editor and generate a Word document from it. We will use Express for back end and React for front end.

Back End

We will start with the back end. To start, we will create a project folder with the backend folder inside. Then in the backend folder run npx express-generator to create the Express app. Then run npm i to install the packages Next we install our own packages. We need Babel for run the app with the latest version of JavaScript, CORS for cross domain requests with front end, HTML-DOCX-JS for converting HTML strings to Word documents, Multer for file upload, Sequelize for ORM, and SQLite3 for our database.

We install all of these by running npm i @babel/cli @babel/core @babel/node @babel/preset-env cors html-docx-js sequelize sqlite3 multer .

After that we change the scripts section of package.json to have:

"start": "nodemon --exec npm run babel-node --  ./bin/www",  
"babel-node": "babel-node"

so that we run our app with Babel instead of the regular Node runtime.

Then create a .babelrc file in the backend folder and add:

{
    "presets": [
        "@babel/preset-env"
    ]
}

to specify that we run our app with the latest version of JavaScript.

Next we add our database code. Run npx sequelize-cli init in the backend folder to create the Sequelize code.

We should have a config.js in the project now. In there, add:

{  
  "development": {  
    "dialect": "sqlite",  
    "storage": "development.db"  
  },  
  "test": {  
    "dialect": "sqlite",  
    "storage": "test.db"  
  },  
  "production": {  
    "dialect": "sqlite",  
    "storage": "production.db"  
  }  
}

to specify that we SQLite for our database.

Next create our model and migration by running:

npx sequelize-cli model:create --name Document --attributes name:string,document:text,documentPath:string

to create a Document model and Documents table.

We then run:

npx sequelize-cli db:migrate

to create the database.

Next we create our routes. Create a document.js file in the routes folder and add:

var express = require("express");
const models = require("../models");
var multer = require("multer");
const fs = require("fs");
var router = express.Router();
const htmlDocx = require("html-docx-js");
const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, "./files");
  },
  filename: (req, file, cb) => {
    cb(null, `${file.fieldname}_${+new Date()}.jpg`);
  }
});
const upload = multer({
  storage
});
router.get("/", async (req, res, next) => {
  const documents = await models.Document.findAll();
  res.json(documents);
});
router.post("/", async (req, res, next) => {
  const document = await models.Document.create(req.body);
  res.json(document);
});
router.put("/:id", async (req, res, next) => {
  const id = req.params.id;
  const { name, document } = req.body;
  const doc = await models.Document.update(
    { name, document },
    { where: { id } }
  );
  res.json(doc);
});
router.delete("/:id", async (req, res, next) => {
  const id = req.params.id;
  await models.Document.destroy({ where: { id } });
  res.json({});
});
router.get("/generate/:id", async (req, res, next) => {
  const id = req.params.id;
  const documents = await models.Document.findAll({ where: { id } });
  const document = documents[0];
  const converted = htmlDocx.asBlob(document.document);
  const fileName = `${+new Date()}.docx`;
  const documentPath = `${__dirname}/../files/${fileName}`;
  await new Promise((resolve, reject) => {
    fs.writeFile(documentPath, converted, err => {
      if (err) {
        reject(err);
        return;
      }
      resolve();
    });
  });
  const doc = await models.Document.update(
    { documentPath: fileName },
    { where: { id } }
  );
  res.json(doc);
});
router.post("/uploadImage", upload.single("upload"), async (req, res, next) => {
  res.json({
    uploaded: true,
    url: `${process.env.BASE_URL}/${req.file.filename}`
  });
});
module.exports = router;

We do the standard CRUD operations to the Documents table in the first 4 routes. We have GET for getting all the Documents , POST for create a Document from post parameters, PUT for updating Document by ID, DELETE for deleting a Document by looking it up by ID. We have the HTML in the document field for generating the Word document later.

The generate route is for generating the Word document. We get the ID from the URL and then use the HTML-DOCX-JS package to generate a Word document. We generate the Word document by converting the HTML document to a file stream object with the HTML-DOCX-JS package and then writing the stream to a file and saving the path to the file in the Document entry in with the ID in the URL parameter.

We also have a uploadImage route to let user upload images with CKEditor with the CKFinder plugin. The plugin expects uploaded and url in the response so we return those.

Then we need to add a files folder in the backend folder.

Next in app.js , we replace the existing code with:

require("dotenv").config();
var createError = require("http-errors");
var express = require("express");
var path = require("path");
var cookieParser = require("cookie-parser");
var logger = require("morgan");
var cors = require("cors");
var indexRouter = require("./routes/index");
var documentRouter = require("./routes/document");
var app = express();
// view engine setup
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "jade");
app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, "public")));
app.use(express.static(path.join(__dirname, "files")));
app.use(cors());
app.use("/", indexRouter);
app.use("/document", documentRouter);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
  next(createError(404));
});
// error handler
app.use(function(err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get("env") === "development" ? err : {};
// render the error page
  res.status(err.status || 500);
  res.render("error");
});
module.exports = app;

We expose the file folder with:

app.use(express.static(path.join(__dirname, "files")));

and expose the document route with:

var documentRouter = require("./routes/document");  
app.use("/document", documentRouter);

Front End

Now the back end is done, we can move onto the front end. Create the React app by running Create React App. We run npx create-react-app frontend in the root folder of the project.

We then install our packages. We will use CKEditor for our rich text editor, Axios for making HTTP requests, Bootstrap for styling, MobX for simple state management, React Router for routing URLs to components, and Formik and Yup for form value handling and form validation respectively.

Install all the packages by running npm i @ckeditor/ckeditor5-build-classic @ckeditor/ckeditor5-react axios bootstrap formik mobx mobx-react react-bootstrap react-router-dom yup .

With the packages installed, we can get started. In App.js , we replace the existing code with:

import React from "react";
import HomePage from "./HomePage";
import { Router, Route } from "react-router-dom";
import { createBrowserHistory as createHistory } from "history";
import TopBar from "./TopBar";
import { DocumentStore } from "./store";
import "./App.css";
const history = createHistory();
const documentStore = new DocumentStore();
function App() {
  return (
    <div className="App">
      <Router history={history}>
        <TopBar />
        <Route
          path="/"
          exact
          component={props => (
            <HomePage {...props} documentStore={documentStore} />
          )}
        />
      </Router>
    </div>
  );
}
export default App;

to add our top bar and route to the home page.

In App.css , we replace the existing code with:

.page {
  padding: 20px;
}
.content-invalid-feedback {
  width: 100%;
  margin-top: 0.25rem;
  font-size: 80%;
  color: #dc3545;
}
nav.navbar {
  background-color: green !important;
}

to add some padding to our page and style the validation message for the Rich text editor, and change the color of the navbar.

Next we create the form for adding and editing documents. Create a DocumentForm.js in the src file and add:

import React from "react";
import * as yup from "yup";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";
import Button from "react-bootstrap/Button";
import { observer } from "mobx-react";
import { Formik, Field } from "formik";
import { addDocument, editDocument, getDocuments, APIURL } from "./request";
import CKEditor from "@ckeditor/ckeditor5-react";
import ClassicEditor from "@ckeditor/ckeditor5-build-classic";
const schema = yup.object({
  name: yup.string().required("Name is required")
});
function DocumentForm({ documentStore, edit, onSave, doc }) {
  const [content, setContent] = React.useState("");
  const [dirty, setDirty] = React.useState(false);
  const handleSubmit = async evt => {
    const isValid = await schema.validate(evt);
    if (!isValid || !content) {
      return;
    }
    const data = { ...evt, document: content };
    if (!edit) {
      await addDocument(data);
    } else {
      await editDocument(data);
    }
    getAllDocuments();
  };
  const getAllDocuments = async () => {
    const response = await getDocuments();
    documentStore.setDocuments(response.data);
    onSave();
  };
  return (
    <>
      <Formik
        validationSchema={schema}
        onSubmit={handleSubmit}
        initialValues={edit ? doc : {}}
      >
        {({
          handleSubmit,
          handleChange,
          handleBlur,
          values,
          touched,
          isInvalid,
          errors
        }) => (
          <Form noValidate onSubmit={handleSubmit}>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="name">
                <Form.Label>Name</Form.Label>
                <Form.Control
                  type="text"
                  name="name"
                  placeholder="Name"
                  value={values.name || ""}
                  onChange={handleChange}
                  isInvalid={touched.name && errors.name}
                />
                <Form.Control.Feedback type="invalid">
                  {errors.name}
                </Form.Control.Feedback>
              </Form.Group>
            </Form.Row>
            <Form.Row>
              <Form.Group as={Col} md="12" controlId="content">
                <Form.Label>Content</Form.Label>
                <CKEditor
                  editor={ClassicEditor}
                  data={content || ""}
                  onInit={editor => {
                    if (edit) {
                      setContent(doc.document);
                    }
                  }}
                  onChange={(event, editor) => {
                    const data = editor.getData();
                    setContent(data);
                    setDirty(true);
                  }}
                  config={{
                    ckfinder: {
                      uploadUrl:
                        `${APIURL}/document/uploadImage`
                    }
                  }}
                />
                <div className="content-invalid-feedback">
                  {dirty && !content ? "Content is required" : null}
                </div>
              </Form.Group>
            </Form.Row>
            <Button type="submit" style={{ marginRight: 10 }}>
              Save
            </Button>
            <Button type="button">Cancel</Button>
          </Form>
        )}
      </Formik>
    </>
  );
}
export default observer(DocumentForm);

We wrap our React Bootstrap Form inside the Formik component to get the form handling function from Formik which we use directly in the React Bootstrap form fields. We cannot do the same with CKEditor, so we write our own form handlers for the rich text editor. We set the data prop in the CKEditor to set the value of the input of the rich text editor. The onInit function is used with users try to edit an existing document since we have to set the data prop with the editor initializes by running setContent(doc.document); . The onChange prop is the handler function for setting content whenever it is updated so the data prop will have the latest value, which we will submit when the user clicks Save.

We use the CKFinder plugin to upload images. To make it work, we set the image upload URL to the URL of the upload route in our back end.

The form validation schema is provided by the Yup schema object which we create at the top of the code. We check if the name field is filled in.

The handleSubmit function is for handling submitting the data to back end. We check both the content and the evt object to check both fields since we cannot incorporate the Formik form handlers directly into the CKEditor component.

If everything is valid, then we add a new document or update it depending on on whether the edit prop is true or not.

Then when saving is successful, we call getAllDocuments to populate the latest documents into our MobX store by running documentStore.setDocuments(response.data); .

Next we make our home page by creating HomePage.js in the src folder and add:

import React, { useState, useEffect } from "react";
import { withRouter } from "react-router-dom";
import DocumentForm from "./DocumentForm";
import Modal from "react-bootstrap/Modal";
import ButtonToolbar from "react-bootstrap/ButtonToolbar";
import Button from "react-bootstrap/Button";
import Table from "react-bootstrap/Table";
import { observer } from "mobx-react";
import { getDocuments, deleteDocument, generateDocument, APIURL } from "./request";
function HomePage({ documentStore, history }) {
  const [openAddModal, setOpenAddModal] = useState(false);
  const [openEditModal, setOpenEditModal] = useState(false);
  const [initialized, setInitialized] = useState(false);
  const [doc, setDoc] = useState([]);
  const openAddTemplateModal = () => {
    setOpenAddModal(true);
  };
  const closeAddModal = () => {
    setOpenAddModal(false);
    setOpenEditModal(false);
  };
  const cancelAddModal = () => {
    setOpenAddModal(false);
  };
  const cancelEditModal = () => {
    setOpenEditModal(false);
  };
  const getAllDocuments = async () => {
    const response = await getDocuments();
    documentStore.setDocuments(response.data);
    setInitialized(true);
  };
  const editDocument = d => {
    setDoc(d);
    setOpenEditModal(true);
  };
  const onSave = () => {
    cancelAddModal();
    cancelEditModal();
  };
  const deleteSingleDocument = async id => {
    await deleteDocument(id);
    getAllDocuments();
  };
  const generateSingleDocument = async id => {
    await generateDocument(id);
    alert("Document Generated");
    getAllDocuments();
  };
  useEffect(() => {
    if (!initialized) {
      getAllDocuments();
    }
  });
  return (
    <div className="page">
      <h1 className="text-center">Documents</h1>
      <ButtonToolbar onClick={openAddTemplateModal}>
        <Button variant="primary">Add Document</Button>
      </ButtonToolbar>
      <Modal show={openAddModal} onHide={closeAddModal}>
        <Modal.Header closeButton>
          <Modal.Title>Add Document</Modal.Title>
        </Modal.Header>
        <Modal.Body>
          <DocumentForm
            onSave={onSave.bind(this)}
            cancelModal={cancelAddModal.bind(this)}
            documentStore={documentStore}
          />
        </Modal.Body>
      </Modal>
      <Modal show={openEditModal} onHide={cancelEditModal}>
        <Modal.Header closeButton>
          <Modal.Title>Edit Document</Modal.Title>
        </Modal.Header>
        <Modal.Body>
          <DocumentForm
            edit={true}
            doc={doc}
            onSave={onSave.bind(this)}
            cancelModal={cancelEditModal.bind(this)}
            documentStore={documentStore}
          />
        </Modal.Body>
      </Modal>
      <br />
      <Table striped bordered hover>
        <thead>
          <tr>
            <th>Name</th>
            <th>Document</th>
            <th>Generate Document</th>
            <th>Edit</th>
            <th>Delete</th>
          </tr>
        </thead>
        <tbody>
          {documentStore.documents.map(d => {
            return (
              <tr key={d.id}>
                <td>{d.name}</td>
                <td>
                  <a href={`${APIURL}/${d.documentPath}`} target="_blank">
                    Open
                  </a>
                </td>
                <td>
                  <Button
                    variant="outline-primary"
                    onClick={generateSingleDocument.bind(this, d.id)}
                  >
                    Generate Document
                  </Button>
                </td>
                <td>
                  <Button
                    variant="outline-primary"
                    onClick={editDocument.bind(this, d)}
                  >
                    Edit
                  </Button>
                </td>
                <td>
                  <Button
                    variant="outline-primary"
                    onClick={deleteSingleDocument.bind(this, d.id)}
                  >
                    Delete
                  </Button>
                </td>
              </tr>
            );
          })}
        </tbody>
      </Table>
    </div>
  );
}
export default withRouter(observer(HomePage));

We have a React Bootstrap table for listing the documents with buttons to edit, delete documents and generate Word document. Also, there is a Open link for opening the Word document in each row. We have a create button on top of the table.

When the page loads, we call getAllDocuments and populate them in the MobX store. We open and close the add and edit modals with the openAddTemplateModal, closeAddModal, cancelAddModal, cancelEditModal functions.

Next create request.js in the src folder and add:

export const APIURL = "http://localhost:3000";
const axios = require("axios");
export const getDocuments = () => axios.get(`${APIURL}/document`);
export const addDocument = data => axios.post(`${APIURL}/document`, data);
export const editDocument = data => axios.put(`${APIURL}/document/${data.id}`, data);
export const deleteDocument = id => axios.delete(`${APIURL}/document/${id}`);
export const generateDocument = id => axios.get(`${APIURL}/document/generate/${id}`);

to add the functions to make requests to our routes in the back end.

Then we create our MobX store. Create store.js in the src folder and put:

import { observable, action, decorate } from "mobx";
class DocumentStore {
  documents = [];
setDocuments(documents) {
    this.documents = documents;
  }
}
DocumentStore = decorate(DocumentStore, {
  documents: observable,
  setDocuments: action
});
export { DocumentStore };

We have the function setDocuments to put the photo data in the store, which we used in HomePage and DocumentForm and we instantiated it before exporting so that we only have to do it in one place.

This block:

DocumentStore = decorate(DocumentStore, {  
  documents: observable,  
  setDocuments: action  
});

designates the documents array in DocumentStore as the entity that can be watched by components for changes. The setDocuments function is designated as the function that can be used to set the documents array in the store.

Next we create the top bar by creating a TopBar.js file in the src folder and add:

import React from "react";
import Navbar from "react-bootstrap/Navbar";
import Nav from "react-bootstrap/Nav";
import { withRouter } from "react-router-dom";
function TopBar({ location }) {
  return (
    <Navbar bg="primary" expand="lg" variant="dark">
      <Navbar.Brand href="#home">Word App</Navbar.Brand>
      <Navbar.Toggle aria-controls="basic-navbar-nav" />
      <Navbar.Collapse id="basic-navbar-nav">
        <Nav className="mr-auto">
          <Nav.Link href="/" active={location.pathname == "/"}>
            Home
          </Nav.Link>
        </Nav>
      </Navbar.Collapse>
    </Navbar>
  );
}
export default withRouter(TopBar);

This contains the React Bootstrap Navbar to show a top bar with a link to the home page and the name of the app. We only display it with the token present in local storage. We check the pathname to highlight the right links by setting the active prop.

Next in index.html , we replace the existing code with:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    <link rel="apple-touch-icon" href="logo192.png" />
    <!--
      manifest.json provides metadata used when your web app is installed on a
      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
    -->
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <!--
      Notice the use of %PUBLIC_URL% in the tags above.
      It will be replaced with the URL of the `public` folder during the build.
      Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
      work correctly both with client-side routing and a non-root public URL.
      Learn how to configure a non-root public URL by running `npm run build`.
    -->
    <title>Word App</title>
    <link
      rel="stylesheet"
      href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
      integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
      crossorigin="anonymous"
    />
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <!--
      This HTML file is a template.
      If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
      The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
      To create a production bundle, use `npm run build` or `yarn build`.
    -->
  </body>
</html>

to add the Bootstrap CSS and change the title.

After writing all that code, we can run our app. Before running anything, install nodemon by running npm i -g nodemon so that we don’t have to restart back end ourselves when files change.

Then run back end by running npm start in the backend folder and npm start in the frontend folder, then choose ‘yes’ if you’re asked to run it from a different port.

Categories
JavaScript JavaScript Basics

Handy JavaScript Array Tips

JavaScript, like any other programming language, has many handy tricks that let us write our programs more easily. In this article, we will look at how to do different things that involve arrays, like checking if an object is an array and combining arrays.

Check if an object is an Array

There are multiple ways to check if a JavaScript object or primitive value is an array. The newest way to check is to use the Array.isArray method. It takes one argument, which is any object or primitive value that we want to check if it’s an array. It returns true if it’s an array and false otherwise. However, for TypedArray objects like Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array, BigInt64Array, and BigUint64Array . It always returns false . For example, if we write the following code:

console.log(Array.isArray([1, 2, 3]));
console.log(Array.isArray(0));
console.log(Array.isArray(null));
console.log(Array.isArray({
  a: 1
}));
console.log(Array.isArray(undefined));
console.log(Array.isArray(Infinity));
console.log(Array.isArray(new Uint8Array()));

We get the following output from the console.log statements:

true  
false  
false  
false  
false  
false  
false

It’s a very handy method for determining if an object is an array. An alternative way to check if an object is an array is to use the instanceof operator. It works by checking if Array.prototype is on an object’s prototype chain. The only situation that it fails but works when using Array.isArray is that instanceof will fail when checking objects across frames wince the object instance will likely be different from the one that’s used for the array test with the instanceof operator. We can use it as in the following code:

console.log([1, 2, 3] instanceof Array);
console.log(0 instanceof Array);
console.log(null instanceof Array);
console.log({
    a: 1
  }
  instanceof Array);
console.log(undefined instanceof Array);
console.log(Infinity instanceof Array);
console.log(new Uint8Array() instanceof Array);

The console.log output should be exactly the same as the ones before since we didn’t put any objects inside a frame. Array.isArray is the simplest and most robust solution since most modern browsers have this method built-in, and that it works across frames.

Combining Arrays

With modern JavaScript, combining arrays into one is easier than ever. Array objects have the concat method which is called on an array, and takes in one or more arguments, which one or more arrays or objects that we want to combine into the array which it’s being called on. Note that it can also take in other non-array values that we want to pass in to be added into the array that it’s being called on. It returns a new array instance with the new array values so that we chain a series of calls to the concat method to combine more arrays into one. For example, we can write the following code for the most basic use case:

const arr1 = [1, 2, 3];
const arr2 = ['a', 'b', 'c'];
const combinedArr = arr1.concat(arr2);
console.log(combinedArr);

Then we get that the value of combinedArr will be [1, 2, 3, “a”, “b”, “c”] . We can also pass in more than one argument each of which are arrays into the concat method like in the following code:

const arr1 = [1, 2, 3];
const arr2 = ['a', 'b', 'c'];
const arr3 = ['1', '2', '3'];
const arr4 = ['d', 'e', 'f'];
const arr5 = ['g', 'h', 'i'];
const arr6 = [null, Infinity, NaN];
const arr7 = [10, 11, 12];
const arr8 = [{}, {
  a: 1
}, {
  b: 2
}];
const combinedArr = arr1.concat(arr2, arr3, arr4, arr5, arr6, arr7, arr8);
console.log(combinedArr);

Then we get the following output from the console.log output:

[
  1,
  2,
  3,
  "a",
  "b",
  "c",
  "1",
  "2",
  "3",
  "d",
  "e",
  "f",
  "g",
  "h",
  "i",
  null,
  null,
  null,
  10,
  11,
  12,
  {},
  {
    "a": 1
  },
  {
    "b": 2
  }
]

As we can see, the concat method is smart enough to combine multiple arrays into the first array, and that we have all the entries from all the arrays in one new array. The array returned from the concat method does not reference the original arrays. Also, it works with multiple types of data. It doesn’t matter what we pass in it, should still work. For example, if we have the following code:

const arr1 = [1, 2, 3];
const arr2 = ['a', 'b', 'c'];
const arr3 = ['1', '2', '3'];
const arr4 = ['d', 'e', 'f'];
const arr5 = ['g', 'h', 'i'];
const arr6 = [null, Infinity, NaN];
const arr7 = [10, 11, 12];
const arr8 = [{}, {
  a: 1
}, {
  b: 2
}];
const combinedArr = arr1.concat(arr2, arr3, arr4, arr5, arr6, arr7, arr8, 1, 'a', {
  c: 3
});

Then we get the following output if we run console.log on combinedArr :

[  
  1,  
  2,  
  3,  
  "a",  
  "b",  
  "c",  
  "1",  
  "2",  
  "3",  
  "d",  
  "e",  
  "f",  
  "g",  
  "h",  
  "i",  
  null,  
  null,  
  null,  
  10,  
  11,  
  12,  
  {},  
  {  
    "a": 1  
  },  
  {  
    "b": 2  
  },  
  1,  
  "a",  
  {  
    "c": 3  
  }  
]

This is very useful since don’t have to worry about the data types of the objects that we pass in or how many arguments we passed into the concat method. It takes as many arguments as we pass in. Since the concat method returns a new array with the values combined into one array, we can also chain the calls of the concat method to combine multiple things into a single array-like in the following code:

const arr1 = [1, 2, 3];
const arr2 = ['a', 'b', 'c'];
const arr3 = ['1', '2', '3'];
const arr4 = ['d', 'e', 'f'];
const arr5 = ['g', 'h', 'i'];
const arr6 = [null, Infinity, NaN];
const arr7 = [10, 11, 12];
const arr8 = [{}, {
  a: 1
}, {
  b: 2
}];
const combinedArr = arr1
  .concat(arr2)
  .concat(arr3)
  .concat(arr4)
  .concat(arr5)
  .concat(arr6)
  .concat(arr7)
  .concat(arr8)
  .concat(1)
  .concat('a', {
    c: 3
  });

Then we should get the following out when we run console.log on combinedArr:

[  
  1,  
  2,  
  3,  
  "a",  
  "b",  
  "c",  
  "1",  
  "2",  
  "3",  
  "d",  
  "e",  
  "f",  
  "g",  
  "h",  
  "i",  
  null,  
  null,  
  null,  
  10,  
  11,  
  12,  
  {},  
  {  
    "a": 1  
  },  
  {  
    "b": 2  
  },  
  1,  
  "a",  
  {  
    "c": 3  
  }  
]

With ES6, we have the spread operator, which we can use to combine arrays into one by spreading the values of the array into another array, and we can do that for all the arrays in one array separated by a comma after each array is spread. The spread also works with all array-like objects like arguments, Sets, Maps, or any object that has a Symbol.iterator method, which return a generator so that we can iterate through the items in the object with the for...of loop. To combine arrays and objects together into one array with the spread operator, we can write something like the following code:

const arr1 = [1, 2, 3];
const arr2 = ['a', 'b', 'c'];
const arr3 = ['1', '2', '3'];
const combinedArr = [...arr1, ...arr2, ...arr3];

Then we get:

[
  1,
  2,
  3,
  "a",
  "b",
  "c",
  "1",
  "2",
  "3"
]

when we run console.log on combinedArr . It also works with overlapping values across different arrays like the concat method does. For example, we can write the following code:

const arr1 = [1, 2, 3];
const arr2 = ['a', 'b', 'c'];
const arr3 = [1, '2', '3'];
const combinedArr = [...arr1, ...arr2, ...arr3];

and get that the value of combinedArr would be:

[  
  1,  
  2,  
  3,  
  "a",  
  "b",  
  "c",  
  1,  
  "2",  
  "3"  
]

As we can see, we have the value 1 in both arr1 and arr3 , but they both made it into combinedArr , which is good. We can also put objects into our new array that before, after or between the spread operations like in the following code:

const arr1 = [1, 2, 3];
const arr2 = ['a', 'b', 'c'];
const arr3 = [1, '2', '3'];
const combinedArr = ['c', ...arr1, 'c', ...arr2, 'c', ...arr3];

Then we get the following value for combinedArr :

[  
  "c",  
  1,  
  2,  
  3,  
  "c",  
  "a",  
  "b",  
  "c",  
  "c",  
  1,  
  "2",  
  "3"  
]

This means that the feature of the concat method can easily be replicated by the spread operator, and we don’t have to pass in a long list of arguments or chain multiple calls of the concat method together to combine arrays and other types of objects into one array.

There are multiple ways to check if a JavaScript object or primitive value is an array. The newest way to check is to use the Array.isArray method, but we can also use the old instanceof operator to check if an object is an array. Array.isArray works across frames so it’s more robust than the instanceof operator. With modern JavaScript, combining arrays into one is easier than ever. Array objects have the concat method which is called on an array and takes in one or more arguments, which one or more arrays or objects that we want to combine into the array which it’s being called on. Note that it can also take in other non-array values that we want to pass in to be added into the array that it’s being called on. It returns a new array instance with all the combined values included in the new array.

Categories
JavaScript Vue

How to Add Virtual Scrolling to a Vue App

To display large amount of data in your app, loading everything at once is not a good solution. Loading a big list is taxing on the user’s computer’s resources. Therefore, we need a better solution. The most efficient solution is to load a small amount of data at a time. Only whatever is displayed on the screen should be loaded. This solution is called virtual scrolling.

With Vue.js, we can use the vue-virtual-scroll-list package located at https://www.npmjs.com/package/vue-virtual-scroll-list to add virtual scrolling to our Vue.js apps. It is one of the easiest package to use for this purpose.

In this article, we will make an app that lets us generate a large amount of fake data and display them in a virtual scrolling list. It will ask how many entries the user wants to create and then create it when the user submits the number.

To get started, we create the Vue.js project with the Vue CLI. We run npx @vue/cli create data-generator to create the app. In the wizard, we select ‘Manually select features’, then choose to include Babel and Vue-Router.

Next, we need to install some packages. We need BootstrapVue for styling, Faker for creating the fake data, Vee-Validate for validating user input, and Vue-Virtual-Scroll-List for displaying the list of items in a virtual scrolling list. We install all of them by running:

npm i bootstrap-vue faker vee-validate vue-virtual-scrolling-list

After we install the packages, we add our pages. First, we create Address.vue in the views folder and add:

<template>
  <div class="page">
    <h1 class="text-center">Generate Addresses</h1>
    <ValidationObserver ref="observer" v-slot="{ invalid }">
      <b-form @submit.prevent="onSubmit" novalidate>
        <b-form-group label="Number" label-for="number">
          <ValidationProvider
            name="number"
            rules="required|min_value:1|max_value:100000"
            v-slot="{ errors }"
          >
            <b-form-input
              :state="errors.length == 0"
              v-model="form.number"
              type="text"
              required
              placeholder="Number"
              name="number"
            ></b-form-input>
            <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
          </ValidationProvider>
        </b-form-group>
        <b-button type="submit" variant="primary">Generate</b-button>
      </b-form>
    </ValidationObserver>
<br />
<h2>Addresses</h2>
<virtual-list :size="itemHeight" :remain="3">
      <div v-for="(item, index) of list" :key="index" class="result-row">
        <div class="index">{{index + 1}}</div>
        <div class="column">{{item.streetAddress}}</div>
        <div class="column">{{item.streetName}}</div>
        <div class="column">{{item.city}}</div>
        <div class="column">{{item.county}}</div>
        <div class="column">{{item.state}}</div>
        <div class="column">{{item.country}}</div>
        <div class="column">{{item.zipCode}}</div>
      </div>
    </virtual-list>
  </div>
</template>
<script>
const faker = require("faker");
import virtualList from "vue-virtual-scroll-list";
export default {
  name: "home",
  data() {
    return {
      form: {},
      list: [],
      itemHeight: 80
    };
  },
  components: { "virtual-list": virtualList },
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }
      this.list = Array.from({ length: this.form.number }).map((l, i) => {
        return {
          city: faker.address.city(),
          streetName: faker.address.streetName(),
          streetAddress: faker.address.streetAddress(),
          county: faker.address.county(),
          state: faker.address.state(),
          country: faker.address.country(),
          zipCode: faker.address.zipCode()
        };
      });
    }
  }
};
</script>
<style scoped>
.column {
  padding-right: 20px;
  width: calc(80vw / 7);
  overflow: hidden;
  text-overflow: ellipsis;
}
.result-row {
  height: 80px;
}
</style>

In this page, we let users generate fake addresses by letting them enter a number from 1 to 100000 and then once the user enters the number, onSubmit is called to generate the items. The Faker library is used for generating the items. Form validation is done by wrapping the form in the ValidationObserver component and wrapping the input in the ValidationProvider component. We provide the rule for validation in the rules prop of ValidationProvider . The rules will be added in main.js later.

The error messages are displayed in the b-form-invalid-feedback component. We get the errors from the scoped slot in ValidationProvider . It’s where we get the errors object from.

When the user submits the number, the onSubmit function is called. This is where the ValidationObserver becomes useful as it provides us with the this.$refs.observer.validate() function to check for form validity.

If isValid resolves to true , then we generate the list by using the Array.from method to map generate an array with the length the user entered (this.form.number ), and then map each entry to the fake address rows.

We add the virtual-list component from Vue-Virtual-Scroll-List in the script section so we can use it in our template. The items are in the virtual-list component so that we only show a few at a time. The remain prop is where we specify the number of items to show on the screen at a time. The size prop is for setting each row’s height.

Next in Home.vue , we replace the existing code with:

<template>
  <div class="page">
    <h1 class="text-center">Generate Emails</h1>
    <ValidationObserver ref="observer" v-slot="{ invalid }">
      <b-form @submit.prevent="onSubmit" novalidate>
        <b-form-group label="Number" label-for="number">
          <ValidationProvider
            name="number"
            rules="required|min_value:1|max_value:100000"
            v-slot="{ errors }"
          >
            <b-form-input
              :state="errors.length == 0"
              v-model="form.number"
              type="text"
              required
              placeholder="Number"
              name="number"
            ></b-form-input>
            <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
          </ValidationProvider>
        </b-form-group>
        <b-button type="submit" variant="primary">Generate</b-button>
      </b-form>
    </ValidationObserver>
    <br />
    <h2>Emails</h2>
    <virtual-list :size="itemHeight" :remain="30">
      <div v-for="(item, index) of list" :key="index" class="result-row">
        <div class="index">{{index + 1}}</div>
        <div>{{item}}</div>
      </div>
    </virtual-list>
  </div>
</template>
<script>
const faker = require("faker");
import virtualList from "vue-virtual-scroll-list";
export default {
  name: "home",
  data() {
    return {
      form: {},
      list: [],
      itemHeight: 30
    };
  },
  components: { "virtual-list": virtualList },
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }
      this.list = Array.from({ length: this.form.number }).map((l, i) => {
        return faker.internet.email();
      });
    }
  }
};
</script>

It works very similarly to Address.vue, except that we are generating emails instead of addresses.

Next create a Name.vue file in the views folder and add:

<template>
  <div class="page">
    <h1 class="text-center">Generate Names</h1>
    <ValidationObserver ref="observer" v-slot="{ invalid }">
      <b-form @submit.prevent="onSubmit" novalidate>
        <b-form-group label="Number" label-for="number">
          <ValidationProvider
            name="number"
            rules="required|min_value:1|max_value:100000"
            v-slot="{ errors }"
          >
            <b-form-input
              :state="errors.length == 0"
              v-model="form.number"
              type="text"
              required
              placeholder="Number"
              name="number"
            ></b-form-input>
            <b-form-invalid-feedback :state="errors.length == 0">{{errors.join('. ')}}</b-form-invalid-feedback>
          </ValidationProvider>
        </b-form-group>
        <b-button type="submit" variant="primary">Generate</b-button>
      </b-form>
    </ValidationObserver>
    <br />
    <h2>Names</h2>
    <virtual-list :size="itemHeight" :remain="30">
      <div v-for="(item, index) of list" :key="index" class="result-row">
        <div class="index">{{index + 1}}</div>
        <div>{{item.firstName}} {{item.lastName}}</div>
      </div>
    </virtual-list>
  </div>
</template>
<script>
const faker = require("faker");
import virtualList from "vue-virtual-scroll-list";
export default {
  name: "home",
  data() {
    return {
      form: {},
      list: [],
      itemHeight: 30
    };
  },
  components: { "virtual-list": virtualList },
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }
      this.list = Array.from({ length: this.form.number }).map((l, i) => {
        return {
          firstName: faker.name.firstName(),
          lastName: faker.name.lastName()
        };
      });
    }
  }
};
</script>

We generate fake first and last names in this file after the user enters the number of items they want.

Then in App.vue , replace the existing code with:

<template>
  <div id="app">
    <b-navbar toggleable="lg" type="dark" variant="info">
      <b-navbar-brand to="/">Data Generator</b-navbar-brand>
<b-navbar-toggle target="nav-collapse"></b-navbar-toggle>
<b-collapse id="nav-collapse" is-nav>
        <b-navbar-nav>
          <b-nav-item to="/" :active="path  == '/'">Home</b-nav-item>
          <b-nav-item to="/name" :active="path  == '/name'">Name</b-nav-item>
          <b-nav-item to="/address" :active="path  == '/address'">Address</b-nav-item>
        </b-navbar-nav>
      </b-collapse>
    </b-navbar>
    <router-view />
  </div>
</template>
<script>
export default {
  data() {
    return {
      path: this.$route && this.$route.path
    };
  },
  watch: {
    $route(route) {
      this.path = route.path;
    }
  }
};
</script>
<style lang="scss">
.page {
  padding: 20px;
}
.result-row {
  display: flex;
  height: calc(50vh / 10);
}
.index {
  padding-right: 20px;
  min-width: 100px;
}
</style>

to add our BootstrapVue navigation bar with the links to our pages. In the top bar, we set the active prop for the links so that we highlight the link of the current page that is displayed. In the scripts section, we watch the $route object provided by Vue Router for the current path of the app and assign it to this.path so that we can use it to set the active prop.

Next in main.js , we replace the existing code with:

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import BootstrapVue from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required } from "vee-validate/dist/rules";
import { min_value } from "vee-validate/dist/rules";
import { max_value } from "vee-validate/dist/rules";
extend("required", required);
extend("min_value", min_value);
extend("max_value", max_value);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.use(BootstrapVue);
Vue.config.productionTip = false;
new Vue({
  router,
  store,
  render: h => h(App)
}).$mount("#app");

We added the validation rules that we used in the previous files here, as well as included all the libraries we use in the app. We registered ValidationProvider and ValidationObserver by calling Vue.component so that we can use them in our components. The validation rules provided by Vee-Validate are included in the app so that they can used by the templates by calling extend from Vee-Validate. We called Vue.use(BootstrapVue) to use BootstrapVue in our app.

In router.js we replace the existing code with:

import Vue from "vue";
import Router from "vue-router";
import Home from "./views/Home.vue";
import Name from "./views/Name.vue";
import Address from "./views/Address.vue";
Vue.use(Router);
export default new Router({
  mode: "history",
  base: process.env.BASE_URL,
  routes: [
    {
      path: "/",
      name: "home",
      component: Home
    },
    {
      path: "/name",
      name: "name",
      component: Name
    },
    {
      path: "/address",
      name: "address",
      component: Address
    }
  ]
});

to include the pages we created in the routes so that users can access them via the links in the top bar or typing in the URLs directly.

Next in index.html , we replace the existing code with:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
    <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
    <title>Data Generator</title>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but vue-virtual-scroll-tutorial-app doesn't work properly
        without JavaScript enabled. Please enable it to continue.</strong
      >
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

to change the title of the app.

Categories
JavaScript Vue

How to Create a Progressive Web App with Vue.js

Progressive web app (PWAs) is a web app that does somethings that a native app does. It can work offline and you can install it from a browser with one click.

PWAs should run well on any device. They should be responsive. Performance should be good in any device. People can link to it easily, and it should have an icon for different size devices.

To build a PWA, we have to register service workers for handling installation and adding offline capabilities to make a regular web app a PWA. Service works also lets PWAs use some native capabilities like notifications, caching for when the device is offline, and app updates.

Building a PWA with Vue CLI 3.x is easy. There is the @vue/cli-plugin-pwa plugin. All we have to do is to run vue add pwa to convert our web app to a progressive web app. Optionally, we can configure our app with the settings listed at https://www.npmjs.com/package/@vue/cli-plugin-pwa.

In this article, we will build a progressive web app that displays article snippets from the New York Times API and a search page where users can enter a keyword to search the API.

The desktop layout will have a list of item names on the left and the article snippets on the right. The mobile layout will have a drop down for selecting the section to display and the cards displaying the article snippets below it.

The search page will have a search form on top and the article snippets below it regardless of screen size.

To start building the app, we start by running the Vue CLI. We run:

npx @vue/cli create nyt-app

to create the Vue.js project. When the wizard shows up, we choose ‘Manually select features’. Then we choose to include Vue Router and Babel in our project.

Next we add our own libraries for styling and making HTTP requests. We use BootstrapVue for styling, Axios for making requests, VueFilterDateFormat for formatting dates and Vee-Validate for form validation.

To install all the libraries, we run:

npm i axios bootstrap-vue vee-validate vue-filter-date-format

After all the libraries are installed, we can start building our app.

First, we use slots yo build our layouts for our pages. Create BaseLayout.vue in the components folder and add:

<template>
  <div>
    <div class="row">
      <div class="col-md-3 d-none d-lg-block d-xl-none d-xl-block">
        <slot name="left"></slot>
      </div>
      <div class="col">
        <div class="d-block d-sm-none d-none d-sm-block d-md-block d-lg-none">
          <slot name="section-dropdown"></slot>
        </div>
        <slot name="right"></slot>
      </div>
    </div>
  </div>
</template>
<script>
export default {
  name: "BaseLayout"
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

In this file, we make use of Vue slots to create the responsive layout for the home page. We have the left , right , and section-dropdown slots in this file. The left slot only displays when the screen is large since we added the d-none d-lg-block d-xl-none d-xl-block classes to the left slot. The section-dropdown slot only shows on small screens since we added the d-block d-sm-none d-none d-sm-block d-md-block d-lg-none classes to it. These classes are the responsive utility classes from Bootstrap.

We make use of Vue.js slots in this file. Slots is a useful feature of Vue.js that allows you to separate different parts of a component into an organized unit. With your component compartmentalized into slots, you can reuse components by putting them into the slots you defined. It also makes your code cleaner since it lets you separate the layout from the logic of the app.

Also, if you use slots, you no longer have to compose components with the parent-child relationship since you can put any components into your slots.

The full list of responsive utility classes are at https://getbootstrap.com/docs/4.0/utilities/display/

Next, create a SearchLayout.vue file in the components folder and add:

<template>
  <div class="row">
    <div class="col-12">
      <slot name="top"></slot>
    </div>
    <div class="col-12">
      <slot name="bottom"></slot>
    </div>
  </div>
</template>
<script>
export default {
  name: "SearchLayout"
};
</script>

to create another layout for our search page. We have the top and bottom slots taking up the whole width of the screen.

Then we create a mixins folder and in it, create a requestsMixin.js file and add:

const axios = require("axios");
const APIURL = "https://api.nytimes.com/svc";
export const requestsMixin = {
  methods: {
    getArticles(section) {
      return axios.get(
        `${APIURL}/topstories/v2/${section}.json?api-key=${process.env.VUE_APP_API_KEY}`
      );
    },
searchArticles(keyword) {
      return axios.get(
        `${APIURL}/search/v2/articlesearch.json?api-key=${process.env.VUE_APP_API_KEY}&q=${keyword}`
      );
    }
  }
};

to create a mixin for making HTTP requests to the New York Times API. process.env.VUE_APP_API_KEY is the API key for the New York Times API, and we get it from the .env file in the project’s root folder, with the key of the environment variable being VUE_APP_API_KEY.

Next in Home.vue , replace the existing code with:

<template>
  <div class="page">
    <h1 class="text-center">Home</h1>
    <BaseLayout>
      <template v-slot:left>
        <b-nav vertical pills>
          <b-nav-item
            v-for="s in sections"
            :key="s"
            :active="s == selectedSection"
            @click="selectedSection = s; getAllArticles()"
          >{{s}}</b-nav-item>
        </b-nav>
      </template>
<template v-slot:section-dropdown>
        <b-form-select
          v-model="selectedSection"
          :options="sections"
          @change="getAllArticles()"
          id="section-dropdown"
        ></b-form-select>
      </template>
<template v-slot:right>
        <b-card
          v-for="(a, index) in articles"
          :key="index"
          :title="a.title"
          :img-src="(Array.isArray(a.multimedia) && a.multimedia.length > 0 && a.multimedia[a.multimedia.length-1].url) || ''"
          img-bottom
        >
          <b-card-text>
            <p>{{a.byline}}</p>
            <p>Published on: {{new Date(a.published_date) | dateFormat('YYYY.MM.DD hh:mm a')}}</p>
            <p>{{a.abstract}}</p>
          </b-card-text>
<b-button :href="a.short_url" variant="primary" target="_blank">Go</b-button>
        </b-card>
      </template>
    </BaseLayout>
  </div>
</template>
<script>
// @ is an alias to /src
import BaseLayout from "@/components/BaseLayout.vue";
import { requestsMixin } from "@/mixins/requestsMixin";
export default {
  name: "home",
  components: {
    BaseLayout
  },
  mixins: [requestsMixin],
  data() {
    return {
      sections: `arts, automobiles, books, business, fashion,
      food, health, home, insider, magazine, movies, national,
      nyregion, obituaries, opinion, politics, realestate, science,
      sports, sundayreview, technology, theater,
      tmagazine, travel, upshot, world`
        .split(",")
        .map(s => s.trim()),
      selectedSection: "arts",
      articles: []
    };
  },
  beforeMount() {
    this.getAllArticles();
  },
  methods: {
    async getAllArticles() {
      const response = await this.getArticles(this.selectedSection);
      this.articles = response.data.results;
    },
    setSection(ev) {
      this.getAllArticles();
    }
  }
};
</script>
<style scoped>
#section-dropdown {
  margin-bottom: 10px;
}
</style>

We use the slots defined in BaseLayout.vue in this file. In the left slot, we put the list of section names in there to display the list on the left when we have a desktop-sized screen.

In the section-dropdown slot, we put the drop-down that only shows in mobile screens as defined in BaseLayout.

Then in the right slot, we put the Bootstrap cards for displaying the article snippets, also as defined in BaseLayout.

We put all the slot contents inside BaseLayout and we use v-slot outside the items we want to put into the slots to make the items show in the designated slot.

In the script section, we get the articles by section by defining the getAllArticles function from requestsMixin.

Next create a Search.vue file and add:

<template>
  <div class="page">
    <h1 class="text-center">Search</h1>
    <SearchLayout>
      <template v-slot:top>
        <ValidationObserver ref="observer" v-slot="{ invalid }">
          <b-form @submit.prevent="onSubmit" novalidate id="form">
            <b-form-group label="Keyword" label-for="keyword">
              <ValidationProvider name="keyword" rules="required" v-slot="{ errors }">
                <b-form-input
                  :state="errors.length == 0"
                  v-model="form.keyword"
                  type="text"
                  required
                  placeholder="Keyword"
                  name="keyword"
                ></b-form-input>
                <b-form-invalid-feedback :state="errors.length == 0">Keyword is required</b-form-invalid-feedback>
              </ValidationProvider>
            </b-form-group>
<b-button type="submit" variant="primary">Search</b-button>
          </b-form>
        </ValidationObserver>
      </template>
<template v-slot:bottom>
        <b-card v-for="(a, index) in articles" :key="index" :title="a.headline.main">
          <b-card-text>
            <p>By: {{a.byline.original}}</p>
            <p>Published on: {{new Date(a.pub_date) | dateFormat('YYYY.MM.DD hh:mm a')}}</p>
            <p>{{a.abstract}}</p>
          </b-card-text>
<b-button :href="a.web_url" variant="primary" target="_blank">Go</b-button>
        </b-card>
      </template>
    </SearchLayout>
  </div>
</template>
<script>
// @ is an alias to /src
import SearchLayout from "@/components/SearchLayout.vue";
import { requestsMixin } from "@/mixins/requestsMixin";
export default {
  name: "home",
  components: {
    SearchLayout
  },
  mixins: [requestsMixin],
  data() {
    return {
      articles: [],
      form: {}
    };
  },
  methods: {
    async onSubmit() {
      const isValid = await this.$refs.observer.validate();
      if (!isValid) {
        return;
      }
      const response = await this.searchArticles(this.form.keyword);
      this.articles = response.data.response.docs;
    }
  }
};
</script>
<style scoped>
</style>

It’s very similar to Home.vue. We put the search form in the top slot by putting it inside the SearchLayour, and we put our slot content for the top slot by putting our form inside the <template v-slot:top> element.

We use the ValidationObserver to validate the whole form, and ValidationProvider to validate the keyword input. They are both provided by Vee-Validate.

Once the Search button is clicked, we call this.$refs.observer.validate(); to validate the form. We get the this.$refs.observer since we wrapped the ValidationObserver outside the form.

Then once form validation succeeds, by this.$refs.observer.validate() resolving to true , we call searchArticles from requestsMixin to search for articles.

In the bottom slot, we put the cards for displaying the article search results. It works the same way as the other slots.

Next in App.vue , we put:

<template>
  <div>
    <b-navbar toggleable="lg" type="dark" variant="info">
      <b-navbar-brand href="#">New York Times App</b-navbar-brand>
      <b-navbar-toggle target="nav-collapse"></b-navbar-toggle>
      <b-collapse id="nav-collapse" is-nav>
        <b-navbar-nav>
          <b-nav-item to="/" :active="path == '/'">Home</b-nav-item>
          <b-nav-item to="/search" :active="path == '/search'">Search</b-nav-item>
        </b-navbar-nav>
      </b-collapse>
    </b-navbar>
    <router-view />
  </div>
</template>
<script>
export default {
  data() {
    return {
      path: this.$route && this.$route.path
    };
  },
  watch: {
    $route(route) {
      this.path = route.path;
    }
  }
};
</script>
<style>
.page {
  padding: 20px;
}
</style>

to add the BootstrapVue b-navbar here and watch the route as it changes so that we can set the active prop to the link of the page the user is currently in.

Next we change main.js ‘s code to:

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import BootstrapVue from "bootstrap-vue";
import "bootstrap/dist/css/bootstrap.css";
import "bootstrap-vue/dist/bootstrap-vue.css";
import VueFilterDateFormat from "vue-filter-date-format";
import { ValidationProvider, extend, ValidationObserver } from "vee-validate";
import { required } from "vee-validate/dist/rules";
Vue.use(VueFilterDateFormat);
Vue.use(BootstrapVue);
extend("required", required);
Vue.component("ValidationProvider", ValidationProvider);
Vue.component("ValidationObserver", ValidationObserver);
Vue.config.productionTip = false;
new Vue({
  router,
  store,
  render: h => h(App)
}).$mount("#app");

We import all the app-wide packages we use here, like BootstrapVue, Vee-Validate and the calendar and date-time picker widgets.

The styles are also imported here so we can see them throughout the app.

Next in router.js , replace the existing code with:

import Vue from "vue";
import Router from "vue-router";
import Home from "./views/Home.vue";
import Search from "./views/Search.vue";
Vue.use(Router);
export default new Router({
  mode: "history",
  base: process.env.BASE_URL,
  routes: [
    {
      path: "/",
      name: "home",
      component: Home
    },
    {
      path: "/search",
      name: "search",
      component: Search
    }
  ]
});

to set the routes for our app, so that when users enter the given URL or click on a link with it, they can see our page.

Finally, we replace the code in index.html with:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
    <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
    <title>New York Times App</title>
  </head>
  <body>
    <noscript>
      <strong
        >We're sorry but vue-slots-tutorial-app doesn't work properly without
        JavaScript enabled. Please enable it to continue.</strong
      >
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>

to change the app’s title.

Next to make our app a progressive web app by running vue add pwa. Then we run npm run build to build the app. After it runs we can use browser-sync package to serve our app. Run npm i -g browser-sync to install the server. Then we run browser-sync start — server from the dist folder of our project folder, which should be created when we run npm run build.

Then on the top right corner of your browser, you should get an install button on the right side of the URL input in Chrome.

You should also get an entry in the chrome://apps/ page for this app.

Categories
JavaScript JavaScript Basics

Debugging JavaScript Apps with the Console Object

JavaScript has great debugging tools built into it. One simple but powerful way to debug is by using the console object. This object has numerous methods which allow us to inspect the state of our program at any given time. The specifics of how the console works vary from browser to browser, but in the most popular browsers the console object has become standardized.

Console Methods

The console object is a global object. It’s a property of window in most browsers, and can be referenced via window.console or simply console. The console object has the following methods:

  • console.assert(boolStatement, message) log a message and the stack trace to the console if the first argument is false.
  • console.clear() clears the console.
  • console.count(label) log the number of times this method has been called with the given label.
  • console.countReset() resets the value of the counter for the given label.
  • console.debug(message) log a message to the console with the log level ‘debug’.
  • console.dir(obj) list the properties of the given JavaScript object. The content will have triangles to show the content of child objects.
  • console.dirxml(obj) displays an HTML or XML representation of the object if possible.
  • console.error(message) log an error message to the console. We can use string substitution and additional arguments to format the log message.
  • console.group() creates a group of console messages, indented by levels. We can move out of a level with groupEnd().
  • console.groupCollapsed() creates a group of console messages, indented by levels with the items collapsed. We can move out of a level with groupEnd()
  • console.groupEnd() exits the current inline group.
  • console.info(message) log informational messages. We can use string substitution and additional arguments to format the log message.
  • console.log(message) used for general logging of information. We can use string substitution and additional arguments to format the log message.
  • console.table() log and display data in a tabular format.
  • console.time(name) starts a timer with the name specified in the parameter. 10000 simultaneous timers can be run on a given page.
  • console.timeEnd(name) stop the specified timer and log the elapsed time in second since it started.
  • console.timeLog() logs the value of the specified timer to the console.
  • console.trace() logs a stack trace.
  • console.warn(message) log a warning message to the console. We can use string substitution and additional arguments to format the log message.

Different Levels of Logging

Logging data is the most common use for the console object. To do this we can use console.log(), console.info(), console.warn() or console.error() depending on the kind of information you’re logging. They’re all styled differently.

console.error() is styled with a red background in Chrome, console.warn() is styled with a yellow background in Chrome, and console.log() has no special background in Chrome.

We can pass in any JavaScript data into them. For example, we can write something like:

const obj = {a: 1, b: 2, c:3};
const message = { message: 'Error occurred' };
console.log(obj);
console.info('Info logged', 123); 
console.warn('Warning logged');
console.error('Error', message);

We can output as many things as we want in one line since the console.log(), console.info(), console.warn(), or console.error() methods all can take more than one argument. So we can write something like:

const obj = {  
  a: 1,  
  b: 2,  
  c: 3  
};  
const message = {  
  message: 'Error occurred'  
};  
console.log(obj, obj, message);  
console.info('Info logged', 123, obj, message);  
console.warn('Warning logged', obj, message);  
console.error('Error', obj, message);

And everything will be logged. We get the following logged:

{a: 1, b: 2, c: 3} {a: 1, b: 2, c: 3} {message: "Error occurred"}
Info logged 123 {a: 1, b: 2, c: 3} {message: "Error occurred"}
Warning logged {a: 1, b: 2, c: 3} {message: "Error occurred"}
Error {a: 1, b: 2, c: 3} {message: "Error occurred"}

String Substitutions

With string substitutions, we can format our console in a way that’s easier to read. It works by replacing placeholders in a string with the arguments that you pass into the right of the string you’ve passed in. The following placeholders can be used for string substitutions:

  • %o or %O this placeholder will be replaced with a JavaScript object. You can click on the object name to get more data about the object when you click on the object in the console.
  • %d or %i this placeholder will be replaced with an integer. We can use this placeholder to format numbers. We can write something like %.3d to round numbers to 3 significant digits with leading 0s.
  • %s this placeholder will be replaced with a string
  • %f this placeholder will be replaced with a floating-point value. We can use this placeholder to format numbers. We can write something like %.3d to round numbers to 3 significant digits with leading 0s.
const obj = {
  a: 1,
  b: 2,
  c: 3
};
const message = {
  message: 'Error occurred'
};
console.log('Object %o', obj);
console.info('Number is %.3d', 123.456);
console.warn('Warning: number is: %.3d', 123.456);
console.error('Error - Error is %o. Error message is %s', message, message.message);

If we run the code above, wherever we have the %o placeholder and log an object, we can click the triangle to see more data. Whenever we have %.3d with a number, we see our number in the argument rounded to 3 significant digits. Strings stay as strings.

Styling Messages

Console messages can also be styled with CSS with the %c directive. Any text after the %c directive will be styled with whatever styles are specified. We pass in the string with the %c directive as the first argument and the string with the styles in the second argument. For example, we can write:

console.log("This is a %cMy colorful message", "color: lightgreen; font-style: italic; background-color: black;padding: 2px");

The following syntax is allowed in the style string:

  • background and its longhand equivalents. Note that background-image doesn’t seem to work in Firefox, although it does work in Chrome.
  • border and its longhand equivalents
  • border-radius
  • box-decoration-break
  • box-shadow
  • clear and float
  • color
  • cursor
  • display
  • font and its longhand equivalents
  • line-height
  • margin
  • outline and its longhand equivalents
  • padding
  • text-* properties such as text-transform
  • white-space
  • word-spacing and word-break
  • writing-mode

Console Grouping

We can group console output by using the console.group() to create the group and the console.groupEnd() method to end the group. The console.groupCollapsed() method is similar to the console.group() method but creates a collapsed group. To view the output of a collapsed group, you can click the triangle button to open the output. For example, we can create multiple console log groups with the following code:

console.log("Outer level");
console.group("Group 1");
console.log("Message in group 1");
console.group("Group 2");
console.log("Message in group 2");
console.group("Group 3");
console.log("Message in group 3");
console.group("Group 4");
console.log("Message in group 4");
console.groupEnd();
console.groupEnd();
console.groupEnd();
console.log("Back in group 3");
console.groupEnd();
console.log("Back in group 2");

Find How Long Something Takes to Run

To calculate the time that some code takes to run, we can use console timer objects. We can use the console.time() method to start a timer with a string that you pass in as the label of the timer. Then you can call console.timeLog() to log the time of the timer when it’s running and the console.timeEnd() to end the timer. For example, we can write the following code to see the what time a loop loops through an element:

const arr = Array.from({
  length: 5
}, (v, i) => i);
(async () => {
  console.time("loop time");
  for await (let i of arr) {
    console.log(i)
    console.timeLog("loop time");
    await new Promise((resolve, reject) => setTimeout(() => resolve(i), 1000))
  }
  console.timeEnd("loop time");
})();

We can have multiple timers set simultaneously, like in the following code:

const arr = Array.from({
  length: 5
}, (v, i) => i);
const arr2 = Array.from({
  length: 10
}, (v, i) => i);
const asyncLoop1 = async () => {
  console.time("loop time");
  for await (let i of arr) {
    console.log(i)
    console.timeLog("loop time");
    await new Promise((resolve, reject) => setTimeout(() => resolve(i), 1000))
  }
  console.timeEnd("loop time");
}
const asyncLoop2 = async () => {
  console.time("loop time 2");
  for await (let i of arr2) {
    console.log(i)
    console.timeLog("loop time 2");
    await new Promise((resolve, reject) => setTimeout(() => resolve(i), 1000))
  }
  console.timeEnd("loop time 2");
}
(async () => {
  await asyncLoop1();
  await asyncLoop2();
})()

If the 2 timers have different names, then they’re 2 different timers. The elapsed time of each timer will be logged individually. They can run at the same time like in the following code:

const arr = Array.from({
  length: 5
}, (v, i) => i);
const arr2 = Array.from({
  length: 10
}, (v, i) => i);

const asyncLoop1 = async () => {
  console.time("loop time");
  for await (let i of arr) {
    console.log(i)
    console.timeLog("loop time");
    await new Promise((resolve, reject) => setTimeout(() => resolve(i), 1000))
  }
  console.timeEnd("loop time");
}
const asyncLoop2 = async () => {
  console.time("loop time 2");
  for await (let i of arr2) {
    console.log(i)
    console.timeLog("loop time 2");
    await new Promise((resolve, reject) => setTimeout(() => resolve(i), 1000))
  }
  console.timeEnd("loop time 2");
}
asyncLoop1();
asyncLoop2();

Then we see that the 2 timers are simultaneously from the console’s log.

Log Stack Trace

We can also log the stack trace of function calls with the console.trace() method. We just put it inside the function we want to get the stack trace from and we can get the entire chain of function calls. For example, we can write:

const a = () => console.trace()  
const b = () => a()  
const c = () => b()  
const d = () => c()  
const e = () => d()e();

Log Objects and Arrays Values in Tables

To display log data in a table when we log objects, we can use the console.table command. It works with both objects and arrays. For objects, it will log the properties in the row and column if the object is 2 levels deep. For example, if we have:

const family = {  
  son: {  
    firstName: 'John',  
    lastName: 'Jones'  
  },  
  daughter: {  
    firstName: 'Jane',  
    lastName: 'Jones'  
  }  
}

console.table(family)

If we have a single level object, then we get the keys on the left and values on the right. For example, if we run:

const son = {  
  firstName: 'John',  
  lastName: 'Jones'  
}

console.table(son)

console.table() also works for arrays. We can use it to log 1-D and 2-D arrays. For one dimensional arrays, we get the indexes of the array on the left side and the values on the right side. For example, if we have:

const arr = [1, 2, 3];  
console.table(arr)

If we have use console.table() to log a two-dimensional array, we get the indexes of the top-level array on the left and the indexes of the nested array on the top of the table, with the values of each entry in the table. For example, if we have:

const arr = [
  ['a', 1],
  ['b', 2],
  ['c', 3]
];
console.table(arr);

The console.table() method has a second argument that restricts the columns displayed by setting an array with the keys of an object that we want to display or the indexes of an array that we want to log. This only works with nested objects that are 2 levels deep. For example, we can write:

const family = {
  son: {
    firstName: 'John',
    lastName: 'Jones'
  },
  daughter: {
    firstName: 'Jane',
    lastName: 'Jones'
  }
}
console.table(family, ['firstName'])

Then we only get the firstName column and the corresponding values displayed. This only works with the second level properties for nested objects.

As we can see, the console object is very useful for logging data in our JavaScript programs. We can have different levels of logging with console.log(), console.info(), console.warn(), and console.error() methods. We can display objects and arrays in a table with console.table(). We can see how long code takes to run with console.time() and associated methods, and we can group messages with console.group() and associated methods.