Building a Full Application

Project Introduction

At this point we have learned a lot about JavaScript. Hopefully you have read the previous chapters as well as worked through the practice exercises.

Now we will put together everything we have learned into a larger project. This will let us see how everything we have learned fits together in a bigger, more practical sense.

We will also learn a few new small things along the way that will help us when building complex applications.

When we build a full application it includes several moving parts. We will organize these parts into separate components like we learned about in the previous example.

What We Will Build

The application we build will let us search through images in the NASA image database. This will let us combine our skills of working with simple and complex data, writing reusable code, using the browser API, using the fetch API and organizing our code using component architecture.

Screenshot of the final application letting us search throught

The major parts of the application will include a search field, an area to display images, a lightbox modal to display individual images with data about the images, and modal navigation to let us easily move between images.

Getting Set Up

We will assume that you have already download the course files for this project at https://github.com/zgordon/javascript-explained and open the "07-project/starter" folder.

You can look through the package.json file and look at the setup. We are including parcel as well as moment, a library that helps easily formats dates. We also have a browserslist configuration that is there simply to prevent errors that appear by default with Parcel and async and await functions. However, there is not really too much new here.

Run the following command to get setup:

npm install

Then run the following command to start watching the files and open the development server:

npm start

You can then open http://localhost:1234/ in your browser to view the files as we begin coding.

Building the Search Component

The first component we will build is a Search component that includes the search box and functionality to make it work.

Create a new components folder in the src folder and a search folder inside of it. Then create an index.js and an index.css file inside the search folder.

At the top of the index.js file, import the index.css file and then create a search() function and an init() function. Set the search() function as the default export and export the init() function as well.

Inside the search() function create the following markup:

export default function search() {
return `
<h1>Search NASA Photos</h1>
<form name="search" id="search">
<p><label for="search-field">Enter Search Term Below:</label></p>
<input id="search-field" name="search" type="search" />
<input type="submit" id="submit" value="Search" />
</form>
`;
}

Then in the init() function, select the search form from the page using document.querySelector() and then add an event listener to it that calls a new function called doSearch when the form is submitted. The init() function should look something like this:

export function init() {
const search = document.querySelector(`#search`);
search.addEventListener(`submit`, doSearch);
}

The doSearch() function is going to prevents the default form submit behavior. Then have the function get the value of the search field and log it to the console.

To start, the function will look something like this:

function doSearch(event) {
event.preventDefault();
const term = document.querySelector(`#search-field`).value.toLowerCase();
console.log(term);
}

To finish the component, open the index.css file and add the following simple CSS:

#search {
margin-bottom: 1rem;
}
#search-field {
width: calc(100% - 110px);
}
#submit {
width: 100px;
}
#search input {
padding: 2px;
font-size: 1rem;
}

Now, open the main src/index.js file and import the search and init function like so:

import search, { init as initSearch } from "./components/search";

Then inside the init() function, create a markup variable and add the search() markup to it and display it on the page using insertAdjacentHTML. Then initialize the search form once it is on the page.

The final code should look like this:

import search, { init as initSearch } from "./components/search";
import "./index.css";
async function init() {
const markup = search();
document.querySelector(`#app`).insertAdjacentHTML(`beforeend`, markup);
initSearch();
}
init();

Now, when you open your browser you should see the search field. When you add a search term and click submit, it should display the search term in the console.

The Search Component and the Search Term logged in the Console

We will enhance this search form further in upcoming steps, but this is the basic functionality working.

Adding State Management

We are going to introduce a new concept in this project called "state." State refers to a design pattern where we store information that may change in a single object called "state."

In our application we have three major pieces of data that will change:

  1. The search term entered into the search field

  2. The images we get back from the NASA API

  3. The current image being displayed in the lightbox modal

Our state management system will be fairly simple. It will include a state object and a setState() function that updates the state. Any component will be able to access and update the state at any time.

To build our state management system create a new state.js file in our src folder.

Then add an object called state with the following properties:

const state = {
searchTerm: null,
images: null,
currentImage: null
};

Then create a function called setState() that takes two parameters: toSet and newValue. This will then update the state object with the new value like so:

const setState = (toSet, newValue) => {
state[toSet] = newValue;
};

Finally, export the state object and setState() functions together in one export. This will ensure that when we update the state object all files will receive the updated state value:

export { state, setState };

This is a very simple state management tool, but it will allow us to not have to store data in our HTML and will make for a much simpler application.

Most complex JavaScript application use a state management system similar to this to keep track of changing data.

Adding State to The Search Component

Now go back to the search component and import state and setState at the top of the file:

import { state, setState } from "../../state";
import "./index.css";

Then, inside of the doSearch() function we will update the search term in state and log that value to the console like this:

function doSearch(event) {
event.preventDefault();
const term = document.querySelector(`#search-field`).value.toLowerCase();
setState(`searchTerm`, term);
console.log(state.searchTerm);
}

When you test the search field in the browser it should work just like before.

Now we can use that search term in the NASA API.

Searching the NASA Photo API

Create a new src/data.js file. We will use this to store the function for searching the NASA Photo API.

At the top of the file import the state object from the state.js file like this:

import { state } from "./state";

The great thing about this is that when we update the state from our doSearch() function we will also get the updated state value in our data.js file. This prevents us from having to pass around the search term as a parameter from one file or function to another.

Create a function called fetchImages() and set it as the default export. Then add the following code inside of it:

export default function fetchImages() {
const url = `https://images-api.nasa.gov/
search?q=${state.searchTerm}&media_type=image`;
return fetch(url)
.then(res => res.json())
.then(data => data.collection.items)
.catch(error => console.error(error));
}

Note that the url must be written on one line. It is broken into two lines here for readability.

What this function does is get the search term from our state object and add it to the correct URL for accessing the NASA Photo API. Notice that we are adding it as a search term and then making sure we are only getting images back (and not videos too).

Then we are returning a fetch promise. This will allow us to await this function when we call it.

Notice that we are also not returning the data as is by default. We are actually returning data.collection.items. This is because of the structure of the data we get back.

Whenever you work with a new API you will have to log the data to the console to see what you are getting. In this case we don't need the full data results, only what is inside of the data.collection.items.

Connecting the Search Component to the NASA Photo API

Now that we have our data.js file setup we can import fetchImages() into our search component and call it inside of our doSearch function.

However, make sure to convert doSearch() to an async function! Once that is done, create a new variable called images and await the value of fetchImages().

We can then take the images and save it to state.

async function doSearch(event) {
event.preventDefault();
const term = document.querySelector(`#search-field`).value.toLowerCase();
setState(`searchTerm`, term);
const images = await fetchImages();
setState(`images`, images);
console.log(state.images);
}

Now when you test this in the browser you will see the images returned from the NASA Photo API search logged in the console.

Images from State Logged in the Console

Hopefully you can appreciate how the state management system makes our code cleaner. We do not have to pass data back and forth between components. This system will continue to help us as we move forward.

However, before we go further, it is important we look at the shape of the data we get back from the API.

Examining the Shape of the Results from the NASA Photos API

As mentioned previously, it is always important to examine the format or "shape" of data we get back from an API.

This is what the shape of an individual image looks like that we get back from the NASA Photo API.

Shape of the Data from the NASA Photo API

You'll notice that each image has two major parts: data and links. Each of these have some information we will need.

Inside of data we have things like the date_created, description_508 and the title. We will use description_508 instead of the normal description because the description may have HTML in it that could throw off our application.

Inside of links we have the href value with the link to the actual image.

You'll also notice that data and links contain arrays, not objects. So, in order to access the title we would have to type this:

images.forEach( image => image.data[0].title ) // Correct
images.forEach( image => image.data.title ) // Wrong

This is why it is important to look at the shape of your data.

Here is a helpful breakdown of the different pieces of data we will need and how to get them:

images.forEach( image => {
const title = image.data[0].title;
const description = image.data[0].description_508;
const url = image.links[0].href;
const data = image.data[0].date_created;
})

Now that we understand the shape of our data we can build out the grid of images. Eventually we will turn this into a lightbox with a modal popup. However, we will start with a simple image grid.

Creating a Grid of Images

Create a new folder in the components folder called lightbox. Inside of that include an index.js file and an index.css file.

In the index.js file, import state from state.js and index.css.

import { state } from "../../state";
import "./index.css";

Then create a new function called lightbox() and set it as the default export. This function will create the markup for the photo grid.

We will start with a <div class="lightbox"></div>. Then inside of that we will have the following markup for each image: <div class="thumbnail"><img src="${url}" alt="${title}" /></div>. The extra thumbnail div is so we can apply some CSS magic that will make make all the images appear as perfect squares regardless of size and shape.

The full lightbox() function will look something like this:

export default function lightbox() {
let markup = `<div class="lightbox">`;
state.images.forEach(image => {
const url = image.links[0].href;
const title = image.data[0].title;
markup += `<div class="thumbnail">
<img src="${url}" alt="${title}" />
</div>`;
});
markup += `</div>`;
return markup;
}

Before we load this photo grid, add the following CSS to the index.css file:

.lightbox {
display: flex;
flex-wrap: wrap;
}
.lightbox .thumbnail {
width: 145px;
height: 145px;
overflow: hidden;
margin: 2px;
}
.lightbox img {
width: 100%;
height: 100%;
object-fit: cover;
}
.lightbox img:hover {
cursor: pointer;
}

To get this working, we will open the search component and import the lightbox at the top.

import { state, setState } from "../../state";
import fetchImages from "../../data";
import lightbox from "../lightbox";
import "./index.css";

Then inside of doSearch() we are going to do some simple validation. If a search term does not return any images we will display an alert message letting them know. If there are images returned we will call the lightbox() function.

async function doSearch(event) {
event.preventDefault();
const term = document.querySelector(`#search-field`).value.toLowerCase();
setState(`searchTerm`, term);
const images = await fetchImages();
setState(`images`, images);
if (state.images.length === 0) {
alert(`There are no results for "${state.searchTerm}"`);
setState(`searchTerm`, null);
document.querySelector(`#search-field`).value = state.searchTerm;
} else {
const markup = lightbox();
document.querySelector(`#app`).insertAdjacentHTML(`beforeend`, markup);
}
}

In the updated code above we check to see if the length of state.images is equal to 0. This means that no results were returned.

If this is the case we display an error message and then clear the search term in state and clear the search field in the browser.

If there are images we get the markup from the lightbox() function and display them on the page.

This will result in the following if there are no search results:

No Search Results

Then if there are search results, we will see the images displayed in a grid.

Search Results Displayed in Photo Grid

Now we have the major portion of our application built!

We are using state management to manage data between components and we have a grid of images displaying.

In the next steps we will add in a pop up modal displaying a single image and then add modal navigation to move between images.

Initializing The Lightbox

Before we can create the actual modal, we need to attach event listeners to each image in the grid and update the state.currentImage with the correct image.

To do this we will first create and export a new function called init() in our lightbox index.js file. This function will select each image and attach an event listener that calls a function called openLightbox() when the image is clicked.

export function init() {
const images = Array.from(document.querySelectorAll(`.lightbox img`));
images.forEach(image => {
image.addEventListener(`click`, openLightbox);
});
}

Then our openLightbox() function will look something like this to start:

function openLightbox(event) {
event.preventDefault();
console.log(`Current image: ${event.target}`);
}

However, rather than store the actual <img /> that was clicked in state, we want to store the index of the image. The index is the number that the image appears in the list of images, for example, the 1st image or the 10th or the 47th, etc.

We can then use this index to get the pure data form of the image from state.images[currentImage].

To do this we can use a function like the one below:

function getCurrentImageIndex(image) {
const images = Array.from(document.querySelectorAll(`.lightbox img`));
let currentImageIndex = images
.map(img => img.outerHTML)
.findIndex(img => img == image.outerHTML);
return currentImageIndex;
}

This function will take the image the user clicked on and find the index of the image from the grid of images. We can then save that value in state in our openLightbox() function like below.

NOTE: In order for this to work, you will have to make sure setState is imported at the top of the lightbox index.js file.

export function openLightbox(event) {
event.preventDefault();
const currentImageIndex = getCurrentImageIndex(event.target);
setState(`currentImage`, currentImageIndex);
console.log(state.currentImage);
}

Now, we can open our search component, import our lightbox init() function as initLightbox() and initialize it after it is loaded to the page.

import lightbox, { init as initLightbox } from "../lightbox";

In our doSearch() function we will then initialize our lightbox.

async function doSearch(event) {
event.preventDefault();
clearLightbox();
const term = document.querySelector(`#search-field`).value.toLowerCase();
setState(`searchTerm`, term);
const images = await fetchImages();
setState(`images`, images);
if (state.images.length === 0) {
alert(`There are no results for "${state.searchTerm}"`);
setState(`searchTerm`, null);
document.querySelector(`#search-field`).value = state.searchTerm;
} else {
const markup = lightbox();
document.querySelector(`#app`).insertAdjacentHTML(`beforeend`, markup);
initLightbox();
}
}

When we test this in the browser, we should see the index of the image we clicked on displayed in the console.

Clicking on the 4th Image will Log an Index of 3

At this point we have everything we need running to build our modal window. However, if you conduct two searches in a row, you will notice a bug. The current images are not cleared when a new search is conducted.

Clearing the Lightbox Between Searches

To fix the problem of images not clearing between searches, we are going to create an export a new function called clearLightbox().

This function will select the lightbox from the page and remove it if it exists. It is important we check to see if the lightbox exists or it will cause an error if no images are currently on the page.

This is what the function should look like:

export function clearLightbox() {
const lightbox = document.querySelector(`.lightbox`);
if (lightbox) lightbox.remove();
}

Then we can come back into our search component and import the function:

import lightbox, { init as initLightbox, clearLightbox } from "../lightbox";

Finally, at the top of the doSearch() function we can clear the lightbox like so:

async function doSearch(event) {
event.preventDefault();
clearLightbox();
// Rest of the function stays the same
}

Now when we try a new search, it will clear out the results from the previous search first.

Building a Simple Modal Window

We will create our modal window in a new component folder called components/modal/index.js. We will also create an index.css file in the same folder.

To start off, we will import state, setState() and index.css file.

Then we will write a simple modal() function that creates the markup for our modal window. However, we will not populate the modal with any data in this function.

import { state, setState } from "../../state";
import moment from "moment";
import "./index.css";
export function modal() {
let markup = `
<div id="overlay">
<div id="modal">
<article>
<h1></h1>
<img src="" alt="" />
<p class="description"></p>
<p><em>Date Created: <span class="date"></span></em></p>
</article>
<button id="close" href="#">X</button>
<button id="prev" href="#">&lt;</button>
<button id="next" href="#">&gt;</button>
</div>
</div>
`;
return markup;
}

The reason we will not add any data to our modal in this function is that we want to be able to easily update the data in the modal anytime someone clicks on the left and right navigation we will build in a future step. If the original modal() function hardcoded data, it would make it more difficult for us to update the data later on when needed.

So, instead, we will write a second function called updateModalContent() that will take care of updating the modal with whatever the current image is in state.

function updateModalContent() {
const image = state.images[state.currentImage];
const title = image.data[0].title;
const url = image.links[0].href;
const description = image.data[0].description_508;
const date = moment(image.data[0].date_created).format(`MMM. Do, YYYY`);
const titleEl = document.querySelector(`#modal h1`);
const imageEl = document.querySelector(`#modal img`);
const descriptionEl = document.querySelector(`#modal .description`);
const dateEl = document.querySelector(`#modal .date`);
titleEl.innerHTML = title;
imageEl.src = url;
imageEl.alt = title;
descriptionEl.innerHTML = description;
dateEl.innerHTML = date;
}

You can see that this function consists of three main parts:

  1. Get the image data from state

  2. Select the modal parts from the browser

  3. Update the modal parts with the image data

NOTE: We are also using a JavaScript library called moment that will help us easily format the date created.

Finally we can write an open() function that will actually add the modal to the page and update the content:

export function open() {
const container = document.querySelector(`#app`);
container.insertAdjacentHTML("beforeend", modal());
updateModalContent();
}

For all of this to look right in the browser, we will need to add the following CSS to our modal index.css file:

#overlay {
background-color: rgba(0, 0, 0, 0.5);
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
#modal {
background: white;
width: 600px;
max-height: 90%;
padding: 1rem;
border: 2px #ccc solid;
position: relative;
margin: 0 auto;
z-index: 200;
top: 50%;
transform: translateY(-50%);
overflow: visible;
overflow-y: scroll;
}
#modal img {
margin: 0 auto;
width: 100%;
}
#modal em {
font-size: 0.8rem;
color: var(--light);
}

Now we can come back into our lightbox component and import our open() function as openModal() and call it at the end of our openLightbox() function.

function openLightbox(event) {
event.preventDefault();
const currentImageIndex = getCurrentImageIndex(event.target);
setState(`currentImage`, currentImageIndex);
openModal();
}

When we test this in the browser, we should now see the image we click on display in a modal window.

An Image Displaying in Our Modal Window

Although we now have a working modal window, we have no way to close the modal or navigate between images. We will begin to tackle that next.

Adding Ability to Close the Modal Window

We are going to add three ways to close our modal window:

  1. A close button

  2. Clicking anywhere outside of the modal

  3. Pressing the escape key

Each of these actions will call the same function. So before we even create our event listeners and event handler functions, lets create a generic close() function that will close the modal:

function close() {
const overlay = document.querySelector(`#overlay`);
overlay.remove();
}

We will add to this function as we go along, because each event listener we create for the modal window navigation will also have to be removed when the modal window is closed.

Let's start with creating an init() function to put our event listeners inside of.

function init() {
const closeBtn = document.querySelector(`#modal #close`);
closeBtn.addEventListener("click", close);
}

Inside of this we will select our close button, which is added with the modal() function and call close() if it is clicked.

We can then add in our click function if the overlay for our modal is clicked like this:

function init() {
const closeBtn = document.querySelector(`#modal #close`);
closeBtn.addEventListener("click", close);
const overlay = document.querySelector(`#overlay`);
overlay.addEventListener("click", handleCloseClick);
}

This will call a function called handleCloseClick() with the following code that tests to see if what was clicked on has an ID of ovelay. We do this because if the ID is equal to modal it would mean they clicked on the actual modal and we don't want to close the modal window if that is the case.

function handleCloseClick(event) {
if (event.target.id == "overlay") {
close();
}
}

Finally we can add an event listener to see if the Escape key is pressed. That would include adding the following to our init() function:

function init() {
const closeBtn = document.querySelector(`#modal #close`);
closeBtn.addEventListener("click", close);
const overlay = document.querySelector(`#overlay`);
overlay.addEventListener("click", handleCloseClick);
document.addEventListener("keyup", handleKeys);
}

The function that calls, handleKeys(), would check to see if the correct key was pressed like this:

function handleKeys(event) {
if (event.key === "Escape") close();
}

To get all of this working, we can to call init() at the end of our open() function, after the modal is loaded on the page and the modal content is updated.

export function open() {
const container = document.querySelector(`#app`);
container.insertAdjacentHTML("beforeend", modal());
updateModalContent();
init();
}

We can also go ahead and add the following CSS to our modal index.css to style our close button as well as our previous and next buttons.

#modal #close,
#modal #prev,
#modal #next {
background: var(--lightest);
border: 1px var(--light) solid;
border-radius: 50%;
position: absolute;
z-index: 500;
padding: 0.5rem 0.6rem;
font-size: 10px;
color: #222;
text-decoration: none;
top: 50%;
transform: translateY(-50%);
}
#modal #close {
top: 5px;
right: 5px;
transform: none;
}
#modal #prev {
left: 0;
}
#modal #next {
right: 0;
}
#modal #close:hover,
#modal #prev:hover,
#modal #next:hover {
color: black;
box-shadow: 0 0 2px 1px gray;
cursor: pointer;
}

You can test this out in three ways:

  1. Open the modal and click the close button

  2. Open the modal and click on the page outside the modal

  3. Open the modal and press the "Escape" key

Each of these should close the modal window. Now we are ready to add the modal navigation feature to move between images.

Adding Modal Navigation

There are a few types of modal navigation options we could implement. One option is to only display a forward or back button if there is a next or previous image. Another option is to always display a previous and next button and simply circle back to the beginning or end of the images if there is no previous or next image.

For our example we are going to display image 100 if they click previous on the first image and display image 1 if they click next on image 100.

Our first step will be to select the previous and next buttons inside our modal init() function and call corresponding handlePrev() and handleNext() functions.

function init() {
const closeBtn = document.querySelector(`#modal #close`);
closeBtn.addEventListener("click", close);
const overlay = document.querySelector(`#overlay`);
overlay.addEventListener("click", handleCloseClick);
document.addEventListener("keyup", handleKeys);
const prev = document.querySelector(`#modal #prev`);
const next = document.querySelector(`#modal #next`);
prev.addEventListener(`click`, handlePrev);
next.addEventListener(`click`, handleNext);
}

Then we will create our handlePrev() function. Inside of this we will first check to see if an event triggered this function, and if so we will prevent the default behavior.

NOTE: There are some event handlers that are triggered from an event taking place like a click. However, there are also times when the same function may be called directly, which we will see later with our handlePre() and handleNext() functions.

This is referred to sometimes as programmatically calling event handlers.

When a function may be triggered by an even or called programmatically, it is always a good idea to check if an event object is present if the default behavior needs to be prevented.

Inside of handlePrev(), if the current image minus one is less than zero we will subtract one from the length of all the images. In our example this will result in an index of 99, which is the last image. (Remember JavaScript is zero indexed).

If the current image minus one is not less than zero we will simply subtract one to get the previous image.

In both cases, we will update state.currentImage with the new value and then call updateModalContent().

Here is what that code will look like:

function handlePrev(event) {
if (event) event.preventDefault();
if (state.currentImage - 1 < 0) {
setState(`currentImage`, state.images.length - 1);
} else {
setState(`currentImage`, state.currentImage - 1);
}
updateModalContent();
}

For handleNext() we are going to do the opposite of this, which will look like the following:

function handleNext(event) {
if (event) event.preventDefault();
if (state.currentImage + 1 >= state.images.length) {
setState(`currentImage`, 0);
} else {
setState(`currentImage`, state.currentImage + 1);
}
updateModalContent();
}

Finally we are going to update the handleKey() function from earlier to see if the user pressed the left or right arrow keys.

function handleKeys(event) {
if (event.key === "Escape") close();
if (event.key === "ArrowLeft") handlePrev();
if (event.key === "ArrowRight") handleNext();
}

In this example above you can see that we are calling handlePrev() and handleNext() programmatically, which is why we checked for the event object in both of those functions.

The final result of this updated code should be the ability to navigate between images with the click of a button or the press of the left and right arrow keys.

Removing Event Listeners

To complete our application we have one final step to complete. We need to remove all of our event listeners once the modal is removed.

Since some of our event listeners are not attached to the modal, but rather to keys being pressed, it is an important step to make sure to remove them in our close() function.

function close() {
const overlay = document.querySelector(`#overlay`);
const prev = document.querySelector(`#modal #prev`);
const next = document.querySelector(`#modal #next`);
overlay.remove();
document.removeEventListener("keyup", handleKeys);
overlay.removeEventListener("keyup", handleCloseClick);
prev.removeEventListener(`click`, handlePrev);
next.removeEventListener(`click`, handleNext);
}

This will ensure we do not get an error if we press Escape after the modal window is closed, for instance.

Taking the Project Further

At this point we have brought our application to a very solid point. We have implemented a search feature, API integration, state management, a lightbox, and modal navigation including keyboard navigation.

While this is a great application, there is always more we could do. If you would like to take the application further, you could try some options like the following:

  • Adding thumbnail navigation instead of arrow navigation

  • Remove the previous and next buttons when you are at the beginning or end of the images

  • Implementing live search

  • Adding a clear search button

This may require you to do some online searching, but this is how real developers continue to learn and get better.