Learn JS: FSFS – Section 7 – Live Search Feature


Courses

Updated Jul 5th, 2021

Section Table of Contents

Go to the complex app summary

Chapter Details

Chapter 88 – Staying Organized: Front-End JavaScript

On the fly. Search does not need to be live or async like this. could use zero browser based JavaScript. Really broken up into two tasks. Task one is front-end or browser-based task and task two is back-end or server side JavaScript. Use automated tool to bundle everything up. Create “frontend-js” folder and inside a “main.js” file and a “modules” subfolder. Create a “search.js” file in the “modules” subfolder.

In “search.js” create a Search class. In “main.js” import Search from ‘.modules/search’ and then create a new Search() to instantiate.

Webpack is the bundling tool used. You need to install “webpack” and “webpack-cli” and “@babel/core” and “@babel/preset-env” and “babel-loader” packages.

Create a “webpack.config.js” file in the root folder of project and paste in the code from the course github repo. This code will tell webpack where our files live, where it should export the bundle to, and any additional features we would like to use.

const path = require('path')
const webpack = require('webpack')
module.exports = {
  entry: './frontend-js/main.js',
  output: {
    filename: 'main-bundled.js',
    path: path.resolve(__dirname, 'public')
  },
  mode: "production",
  module: {
    rules: [
      {
        test: /\.js$/,
        exclude: /node_modules/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: ['@babel/preset-env']
          }}}]}}

Set up webpack-auto-rebundling in “package.json” file by tweaking the “watch” script to tell the nodemon script to ignore the “frontend-js” folder so we dont’ restarte the node server every time we update a front-end js file.

"watch": "start nodemon db --ignore frontend-js --ignore public/ && start webpack --watch"

Note that window users need to have a double ampersand instead of just one and have “start” at the beginning of the script and before webpack.

If on a team with everyone using different operating systems you would need a platform-agnostic way to set this up. Checkout concurrently package if this is the case.

Testing “npm run watch” command will create a main-bundled.js file in your “public” folder and you will need to make sure your templates load this file. Fortunately we only have to do this in one “footer.ejs” template file. Above the closing body tag add script tags and on opening tag have an “src” attribute that points to “/main-bundled.js”

We can use the import syntax in our front-end JavaScript files because babel is compiling it down for us to the older, Common.js, “require” syntax. IN addition, “module.exports,” can now become “export default.”

Chapter 89 – Showing and Hiding Search Overlay

Other languages call a blueprint a class. JavaScript does not have classes but it has this syntax for “syntactical sugar.” Behind the scenes JS converts the code into the typical code with prototypes.

export default class Search {
  // Select DOM elements, and keep track of any useful data
  constructor() {
    this.events()
  }
  // Events
  // Methods
}

Grab the search icon using querySelector and then add a click event listener. prevent the default then call a method called “openOverlay.”

What we ultimately want openOverlay to do is show a full-screen search interface with your cursor automatically in the search field and as you begin typing their is a loading spinner icon. The browser sends a request off to the server, the server talks to the database, and then responds with raw json data the appropriate result. The front-end parses the raw json data and outputs html.

Start by showing the interface. Crate a new method called injectHTML and in the function body have document.body.insertAdjacentHTML(a, b) where a is “beforeend” and b is backticks and inside paste in html from GitHub repo (code in the search-visible.html file). Call this function at the very top of the constructor function. Save and test. This works but we don’t it to be visible until the user clicks the search icon. In the html, in the main div, remove the class “search-overlay–visible” and we will add back programmatically in the “openOverlay” method. IN the constructor create anew property this.overlay and set equal to the div using document.querySelector(“.search-overlay”) and leverage using this.overlay.classList.add(“search-overlay–visible”). Remove the class when a user clicks the x-icon, via the “close-live-search” class. Add a new property of “this.closeIcon” to the constructor and grab onto the x-icon. In the events area create a click listener for the “closeIcon” that runs an arrow function that runs closeOverlay(). Create “closeOverlay” method to remove the “search-overlay–visible” class.

Chapter 90 – Responding to Key Press Events

Automatically move cursor to search input field using this.inputField.focus() but wait 50ms for browser compatibility by using setTimeout.

Prevent an error in the console for pages that don’t have search icon. In main.js wrap the instantiate of Search into an if statement looking for the “.header-search- icon.”

Prevent results area from showing right away and instead show a spinning progress loader icon. In the Search constructor select results area using (“live-search-results”) and in the html remove the live-search-results–is-visible class. In the Search constructor add a this.loaderIcon propoerty and set equal to document.querySelector(“.circle-loader”). Add an event that listens for a “keyup” on this.inputField that triggers a keyPressHandler method that will add a class to show the spinner icon.

Create the keyPressHandler() method that will run another method call showLoaderIcon and it is this method that adds the “circle-loader–visible” class.

When do we send off the async request to the server? We don’t want to send it after every single keystroke (slow typers) but instead wait 500-700ms. In the constructor create a this.typingWaitTimer property and for now don’t set it to anything.

We also want to handle the user hitting the left or right arrows since there will still not be anything in the field. We will want to keep track of the previous value and only if the current value is different then the previous value, make the request. So create another property in the constructor this.previousValue and for now set to an empty string.

In the keyPressHandler method create a value variable and set it to this.inputField.value. Then have an if statement and after that have this.previousValue = value. For the condition of the if statement have (value != “” && value != this.previousValue) then call this.showLoaderIcon() and begin a timer with this,typingWaitTimer and set to setTimeout(a, 700ms) where a is an arrow function that calls a method we will create called this.sendRequest(). BUt we also need to clear the previous timer so at the beginning of the if block have clearTimeout(this.typingWaitTimer). Create a new sendRequest method with an alert to test out.

Instead of using the browser’s built in fetch api, install the axios package and import in at the top of Search.js file. Back in the sendRequest method send a axios.post() request to “/search” and a data object with {searchTerm: this.inputField.value}. Axios returns a promise so add .then() and .catch() and give those both arrow functions.

Chapter 91 – Back-End Aspect of Search

In router.js file create a new matching route to handle your async post request to “/search” that runs a postController.search function. Create this exports.search function that calls Post.search and pass it req.body.searchTerm (remember we sent this searchTerm as part of the request in the data object). Add a .then() and .catch() since we will be creating the Post.search function to return a promise. Give these both arrow functions with posts as a parameter in the .then() arrow function and in the body have res.json(posts) and put res.json([]) in the catch block.

In the “Post.js” model file create a Post.search function that receives searchTerm and returns a new Promise. Inside the promise parentheses set up an arrow function with parameters of resolve and reject. In the function body have an if/else and as the condition make sure the incoming search term is a string (using typeof) and not an object and also not undefined. In the else block reject(). In the if block let posts = await reusablePostQuery() and then resolve(posts). Pass reusablePostQuery an array of unique operations so:

[
{$match: {$text: {$search: searchTerm}}},
{$sort: {score: {$meta: "textScore"}}},
]

Create an text-based index in mongoDB Atlas for the title and body fields. This should speed up the searching.

Now back in the “search.js” file, in the sendRequest method, in the .then() function for the request pass response as a parameter. As a test use console.log(response.data) to try things out.

As of right now you should be getting a nasty error message and this is due to a mongoDB update because $sort must come after $project if you are sorting by “textScore.” And the reusablePostQuery function is adding the uniqueOperations onto the beginning of the array. Wow.

Solve this by going to Post.js file and finding the Post.reusablePostQuery function and add on a third parameter, finalOperations and set to an empty array. Down after the existing array in concat add a second .concat() function and pass it final operations.

Now go back down to the Post.search function and adjust the way you are calling reusablePostQuery by cutting the $sort line into the clipboard and add undefined as second argument and have an empty array as third argument and inside of this paste in your clipboard. This way $sort will come after $project and crises due to mongoDB update is averted.

After testing your console.log() and viewing the raw.json that is returned you will see the authorID property is viewable, and although not a huge vulnerability, the general public should not be able to see the id of another account. A good rule of thumb is that your model should not expose anything that it doesn’t need to. The front-end has no need for this data.

Back in the “Post.js” model file, in the Post.reusablePostQuery function, after the post.isVisitorOwner = post.authorId.equals(visitorId) we want to remove authorId. We could use “delete post.authorId” but studies have shown this is a rather sow operation if you are going to be looping through an array and performing it multiple times. Instead set post.authorId = undefined.

Chapter 92 – How to Create DB Indexes from Within Node.js Code

Hello everyone, this is a quick note about MongoDB indexes; if you’re not particularly curious about MongoDB you can safely skip this article and move onto the next lesson. If you’d rather create indexes on your database collections without going into the Atlas website you can use the code below:

postsCollection.createIndex({title: "text", body: "text"})

For example, you could include this code towards the top of your Post.js model file.  MongoDB will only create the index if one doesn’t already exist; so leaving this in your code wouldn’t be a huge problem; although just to be clean and safe you could comment it out once you know the index has been created.

This way if you ever move to a new database you can just uncomment the line and the index will be created automatically.

If you’d like to see a list of all the indexes you currently have on a collection you can use this code:

async function checkIndexes() {
  const indexes = await postsCollection.indexes()
  console.log(indexes)
}
checkIndexes()

You can delete an index by taking note of its name from the above list, and then you’d pass the name of the index as an argument like this:

postsCollection.dropIndex("namehere")

Chapter 93 – Generating HTML for Search Results (Part 1)

Use raw data to show results.

In the sendRequest method, in the .then() function block and after calling console.log(response.data), call a method this.renderResultsHTML(response.data)

Create c and pass it posts as a parameter. In the function body begin with an if/else statement and for the condition check post.length and in if block have this.resultsArea.innerHTML = x and in the else block have this.resultsArea.innerHTML = y. Down in the injectHTML method, in the html, hollow out the live-search-results div by cutting that code to your clipboard and pasting inside of backticks in the x. For the y in the code above set it to backticks and inside paragraph tags have “Sorry, we could not find any results for that search.” On the opening p tag give it a class of “alert alert-danger text-danger shadow-sm.”

Still in the renderResultsHTML method, but after the if/else statement, call the this.hideLoaderIcon() method we will create a this.showResultsArea and this.hideResultsArea methods which we will also create.

To create the hideLoaderIcon() method, duplicate showLoadericon and use .remove() instead of .add(), and for the showResultsArea and hideResultsArea methods use classList.add() and .remove() of (“.live-search-results–visible”) to this.resultsArea.

In the keyPressHandler method, under the line of this.showLoaderIcon() add this.hideResultsArea.

We also need to handle the case in which someone types out a search and then empties out the text field entirely. Within keyPressHandler, above the if/else, set up another if statement:

if (value == "") {
  clearTimeout(this.typingWaitTimer)
  this.hideLoaderIcon()
  this.hideResultsAea()
}

Chapter 94 – Generating HTML for Search Results (Part 2)

Adjust the html to use raw data instead of static data by creating a dynamic teplate for each “a” element. Copy one and delete all. Use ${posts.map().join(”)} and pass map an arrow function with a parameter of post. Within the body of the function have return and more backticks and paste in the clipboard and hollow out certain values, (src attribute now set to “post.author.avatar”, title becomes post.title, author becomes post.author.username).

To prevent having to create a new date function, before the return line in the map function, have the following code:

let postDate = new Date(post.createdDate)

In the html swap the date of 06/19/2019 for

${postDate.getMonth() + 1} / ${postDate.getDate()} / ${postDate.getFullYear()}
// + 1 is because getMonth() has zero-based-index so Jan is 0

Handle list group that show blank items found (including plural versus non-plural) based on the number of posts. 4 items found becomes:

${posts.length > 1 ? `${posts.length} items found` : "1 item found"}

Make sure the link in your map function has an href set to “/post/${post._id}.” Test out to make sure the avatar image in the search results are accurate by updating the source.

Chapter 95 – Fixing the Month for Post Dates in Search Results

The date for a post when displayed in search results will currently show an incorrect month value. This is because months are zero based in JavaScript (January is represented as a 0 instead of a 1) and the author forgot to add 1 to the the month value. This is reflected in the chapter details above.

Chapter 96 – Sanitizing User Generated HTML on the Front-End

Cross-Site-Scripting attacks are serious and you need to assume the worst. Assume the back-end was compromised so the front-end still does not create malicious JavaScript. Testing by adding a test post and in the database backend, manually editing the title to be:

<div onmouseover="alert("Muhahaha, evil JS here")">Test Post</div>

If you save and view on the front-end you can see the ejs template, doing its job, and escaping the values. We see the code but it will not execute. However, if you run a search and hover you will see the alert execute. This is because we are generating this HTML using our own JavaScript using template literals. Install, import and leverage the “dompurify” package.

import DOMPurify from "dompurify"

In the renderResultsHTML method cut the html and containing backticks into your clipboard. Call DOMPurify.sanitize() and inside paste back in your code. And now you are safe!

We could use “sanitize-html” package (shown in the simple app security video) on the front-end instead of “dompurify” however that package is over 600kb unminified whereas “dompurify” is only 40kb unminified. And since this is browser-based we need to send these libraries to the front-end to be downloaded. And remember, this is only our last line of defense. The server should already be removing any html from the content.