DEV Community

Cover image for How to Create a JavaScript SPA Using the MVC Architecture (Part 2)
Taz
Taz

Posted on • Edited on

How to Create a JavaScript SPA Using the MVC Architecture (Part 2)

Table of Contents: [NEW]

Welcome to the final part of this two-part post series! I want to say that I’m truly grateful for some of the feedback received in the previous post.

So far, the application that we’ve been creating thus far is finally coming altogether now. If your unsure on what’s going on here then please refer back to the first post in this series, however to give a quick summary of the previous post, we’ve finished creating the full design of our application which we were working on which consisted of different webpages that would be used in it. For this application to now fully work it needs logic which is what we will be covering in this post.

To not make this introduction any lengthier, we will now begin implementing the app logic.

User Stories

  • User is able to track days they have been productive
  • User can set weekly goals to complete
  • User is able retrieve appropriate information on a person
  • User is able to add a person to their bookmarks
  • User is able to bookmark given quote

Updated Environment

This an updated version of the previous post’s environment setup:

dir.
|   index.html
|   package.json
|   package-lock.json
|
\---src
    \---scripts
    |   |   config.js
    |   |   controller.js
    |   |   helpers.js
    |   |   model.js
    |   |   templates.js
    |   |
    |   \---views
    |           articleView.js
    |           modelBookmarkView.js
    |           quoteBookmarkView.js
    |           calendarView.js
    |           menuView.js
    |           quoteView.js
    |           searchView.js
    |           targetView.js
    |
    \---stylesheets
            animations.css
            icons.css
            reset.css
            style.css
Enter fullscreen mode Exit fullscreen mode

In this updated version, you may have noticed a "package.json" file that has been added. This file in particular will allow us to install third-party packages which we can use in our application. Besides that, I’ve also added the views we expect to render in the application and that which will be briefly looking at soon.

Routing

Firstly, we begin by building the routing system for our application in order for it to function properly like a SPA would expect to.

Here is a code snapshot of that:

import * as model from "./model.js";
import templates from "./templates.js";

// Content placeholder
const contentMain = document.querySelector(".main__content");

// HTML templates
const routes = {
  "/dashboard": () => controlDashboard(),
  "/article": () => controlArticle(),
  "/quotes": () => controlQuotes(),
  "/models": () => controlModels(),
};

function handleLocation() {
  // Get current url pathname
  const pathName = window.location.pathname;
  // Clear HTML placeholder
  contentMain.innerHTML = "";
  // Find HTML template based on path
  const htmlTemplate = routes[pathName];
  // Now generate the HTML template markup in placeholder
  contentMain.innerHTML = htmlTemplate();
}

function init() {
  // Add event listeners
  window.addEventListener("popstate", handleLocation);
  window.addEventListener("load", handleLocation);
}

init();
Enter fullscreen mode Exit fullscreen mode

The way this routing system works is via event listeners that listen to events when the page has been loaded or when the window's history changes. The events trigger the handleLocation function which in turn obtains the URL pathname of the current webpage and then based on that path renders a template.

TIP: I found there to be two methods that I found online where one involved using the "hash method" and the other being the "history method", both methods served the purpose of routing, however I chose the later. This is because it looked visually appealing and this method of constructing a URL path is much clear to understand and read. There is not one best method of implementing routing, but the downside to this method is that it’s slightly more difficult to implement, nevertheless personally it’s worth it for the benefits it provides.

Views

In the following sub-sections, I will be covering the views that would be created for this application and the logic used in our model to supply the relevant state data to the appropriate views, however I also need to quickly point out that I will be using the module design pattern in order to create the relevant views for this application.

If you came to this tutorial expecting the use of ES6 classes, like most posts nowadays, or object constructors for those still behind ES6, then I’m going to suggest you go visit another post because I’m going to disappoint - if you are still here I’m glad :¬)

Of course ES6 classes provide nice "syntactic sugar" for using object constructors, but they do remain to hide the fact that JavaScript is a “pure object-oriented language”, and using classes not only leads the JavaScript community down a weird, strange path, it also leads to even more confusion.

Here is an amazing article by Eric Elliot that I think you should check out that explores the nuance of this topic with great coverage.

Menu View

Here’s a code snapshot of the menu view:

function menuView() {
  // Selecting HTML elements
  const itemEl = document.querySelector(".content__nav-item");
  const tabsEl = document.querySelector(".content__nav-tabs");
  // Private methods
  const toggleTabs = () => tabsEl.classList.toggle("hidden");
  // Add event listeners
  itemEl.addEventListener("click", toggleTabs);
  // Public methods
  // Add handler functions
  const addHandlerClick = (handler) => {
    tabsEl.addEventListener("click", (e) => {
      const clicked = e.target.closest(".item-link");
      if (!clicked) return;
      toggleTabs();
      handler(e);
    });
  };
  // Public API
  const publicApi = {
    addHandlerClick,
  };
  return publicApi;
}

export default menuView();
Enter fullscreen mode Exit fullscreen mode

For the this view, it was relatively easy to make since the HTML elements I want to target already are in the main HTML page, so therefore I don’t need to render this view – only target it.

Calendar View

Loading Data

For the calendar view, I first need to get data from the model and use that data to render a table on its view. The logic involved would look like something like this:

import _ from "lodash";

const nameMonths = new Map([
  [0, "JAN"],
  {...}
]);

const currDate = new Date();

// Helper functions
const getFirstDayIndex = (year, month) => new Date(year, month).getDay();
const getLastDay = (year, month) => new Date(year, month, 0).getDate();

// Export functions
export function loadCalendar() {
  // Getting current month and year
  const day = currDate.getDate();
  const year = currDate.getFullYear();
  const month = currDate.getMonth();
  // Set first day of current month
  state.calendar.firstDayIndex = getFirstDayIndex(year, month);
  // Set previous month last day
  state.calendar.prevLastDay = getLastDay(year, month + 1);
  // Set current month last day
  state.calendar.lastDay = getLastDay(year, month + 1);
  // Set current month and year
  state.calendar.year = year;
  state.calendar.month = month;
  state.calendar.formatDate = `${month}-${day}`;
  state.calendar.formatMonth = nameMonths.get(month);
}
Enter fullscreen mode Exit fullscreen mode

When the application runs for the first time, we run the loadCalendar function from the controller so that there is already existing data on the calendar instead of it being empty. The data populated consists of the current month of that year, for instance, at the time of writing, this would be April, 2022. The calendar view would then use this data to build its view based on it.

Pagination Buttons

Each time the pagination button is triggered from the calendar view, I need to also update the state of the calendar, so that when the calendar is re-rendered it should display the correct month that is has been navigated to. Here’s what I mean:

const nameMonths = new Map([
  [0, "JAN"],
  {...}
]);

// Helper functions
const getFirstDayIndex = (year, month) => new Date(year, month).getDay();
const getLastDay = (year, month) => new Date(year, month, 0).getDate();

export function setCalendar(month, reverse) {
  // Set previous month last day
  state.calendar.prevLastDay = getLastDay(
    state.calendar.year,
    reverse ? state.calendar.month - 1 : state.calendar.month + 1
  );
  // Go back a year
  if (state.calendar.month === 0 && month === 11) state.calendar.year--;
  // Go foward a year
  if (state.calendar.month === 11 && month === 0) state.calendar.year++;
  // Set first day of selected month
  state.calendar.firstDayIndex = getFirstDayIndex(state.calendar.year, month);
  // Set selected month last day
  state.calendar.lastDay = getLastDay(state.calendar.year, month + 1);
  // Set selected month
  state.calendar.month = month;
  state.calendar.formatMonth = nameMonths.get(month);
}
Enter fullscreen mode Exit fullscreen mode

The pagination buttons contains the attribute “goto” which is passed to the setCalendar function here to be used to manually set the calendar. It then calculates which month and year we are on based on the current state of the calendar.

Marked Days

The whole purpose of building this calendar is so that we can fulfil one of our user stories - ‘user is able to track days they have been productive’. Since that is the case, we use the calendar to “mark down” those days and whenever they’re marked we should keep track of them in our state and persist that data somewhere so that the data is not lost. Here’s the logic for that:

import _ from "lodash";

// Local Storage
function persistMarkedDays() {
  localStorage.setItem("markedDays", JSON.stringify(state.calendar.markedDays));
}

export function updateMarkedDays(date, remove) {
  const [month, day] = date.split("-");
  const dateObj = { month, day };
  if (!remove) state.calendar.markedDays.push(dateObj);
  if (remove) {
    const idx = _.findIndex(state.calendar.markedDays, dateObj);
    state.calendar.markedDays.splice(idx, 1);
  }
  persistMarkedDays();
}

export function restoreMarkedDays() {
  const storage = localStorage.getItem("markedDays");
  if (storage) state.calendar.markedDays = JSON.parse(storage);
}
Enter fullscreen mode Exit fullscreen mode

Anytime a day is marked on the calendar, the updateMarkedDays function will be called to update the state of the markedDays as well as the persisted data (i.e. on localstorage) using the persistMarkedDays function so that we get an up-to-date calendar that is consistent with our calendar state.

Note: For simplicity purposes, I’ve used the localstorage API. If however, you need the data to persist for even longer, then I suggest to you use some sort of database to store that data, for instance - MongoDB or MySQL.

Result

Here’s the calendar view once we apply the logic for it and its template from the previous post:

import Handlebars from "handlebars/dist/handlebars";
import _ from "lodash";

import templates from "../templates";

function generateCalendarMarkup({…}) {
  const calendarData = {{…}};
  const template = Handlebars.compile(templates.calendar());
  return template(calendarData);
}

function generateNavMarkup({ month }) {
  const prevMonth = month === 0 ? 11 : month - 1;
  const nextMonth = month === 11 ? 0 : month + 1;
  const navInput = {{…}};
  const template = Handlebars.compile(templates.calendarPagination());
  return template(navInput);
}

function buildCalendarView() {
  // Create base parent
  const base = document.createElement("div");
  base.classList.add("content__wrapper--hero");
  // Create header
  const headerEl = document.createElement("h1");
  headerEl.classList.add("content__wrapper-header");
  // Create table
  const tableEl = document.createElement("table");
  tableEl.classList.add("content__wrapper-table");
  // Create nav
  const navEl = document.createElement("nav");
  navEl.classList.add("content__wrapper-nav");
  // Add children to base parent
  base.appendChild(headerEl);
  base.appendChild(tableEl);
  base.appendChild(navEl);
  // Public methods
  const renderTable = (data) => {
    if (!_.isObject(data)) return;
    const tableMarkup = generateCalendarMarkup(data);
    tableEl.innerHTML = tableMarkup;
  };
  const renderNav = (data) => {
    if (!_.isObject(data)) return;
    const navMarkup = generateNavMarkup(data);
    navEl.innerHTML = navMarkup;
  };
  // Updates calendar header
  const updateHeader = (month, year) =>
    (headerEl.textContent = `${month}, ${year}`);
  // Add handler functions
  const addHandlerToggle = (handler) => {
    if (tableEl.getAttribute("data-event-click") !== "true") {
      tableEl.setAttribute("data-event-click", "true");
      tableEl.addEventListener("click", (e) => {
        const clicked = e.target.closest(".data__item-dot");
        if (!clicked) return;
        clicked.classList.toggle("data__item--active");
        const cell = clicked.closest(".row-data");
        const cellDate = cell.getAttribute("data-date");
        const removeDate = clicked.classList.contains("data__item--active")
          ? false
          : true;
        handler(cellDate, removeDate);
      });
    }
  };
  const addHandlerClick = (handler) => {
    if (navEl.getAttribute("data-event-click") !== "true") {
      navEl.setAttribute("data-event-click", "true");
      navEl.addEventListener("click", (e) => {
        let reverse = false;
        const clicked = e.target.closest(".nav__btn");
        if (!clicked) return;
        if (clicked.classList.contains("nav__btn--prev")) reverse = true;
        const goToMonth = +clicked.getAttribute("data-goto");
        handler(goToMonth, reverse);
      });
    }
  };
  // Public API
  const publicApi = {
    renderTable,
    renderNav,
    updateHeader,
    addHandlerToggle,
    addHandlerClick,
    base,
  };
  return publicApi;
}

export default buildCalendarView;
Enter fullscreen mode Exit fullscreen mode

Note: You may have noticed that I’ve not included the raw template markup for the calendar view, that is because in the previous post we’ve covered that already there, so be sure to check that out if your interested.

Here’s a screenshot of the rendered calendar:

Rendered calendar view

Search View

Fetching & Parsing Data

One of the user stories of this application is that the "user is able retrieve appropriate information on a person" and what I mean by that is to get a wiki search result on them. This is the main purpose of the search bar, so that when a user enters their search query into the form it will return a wiki-page of that individual. Here’s what the logic for this would look like:

import _ from "lodash";

import { getJSON } from "./helpers.js";
import { API_URL } from "./config.js";

export function loadSearchList(currentSearch) {
  if (_.isEmpty(state.search.list)) {
    const authorSet = new Set(
      Quotesy.parse_json().map((quote) => quote.author)
    );
    state.search.list = Array.from(authorSet);
  }
  state.search.suggestions = state.search.list.filter((suggestion) =>
    suggestion.toLocaleLowerCase().startsWith(currentSearch.toLocaleLowerCase())
  );
}

export async function getSearchResult(query) {
  state.error = null;
  state.search.query = query;
  const searchParams = new URLSearchParams({
    origin: "*",
    action: "query",
    prop: "extracts|pageimages",
    piprop: "original",
    titles: query.toLowerCase(),
    redirects: true,
    converttitles: true,
    format: "json",
  });
  const url = `${API_URL}?${searchParams}`;
  const data = await getJSON(url);
  const { pages } = data.query;
  const [page] = Object.values(pages);
  if (page?.missing === "") {
    state.error = true;
    return;
  }
  state.search.title = page.title;
  state.search.imageSrc = page.original?.source ?? "";
  // Parse data
  const parser = new DOMParser();
  const htmlDOM = parser.parseFromString(page.extract, "text/html");
  const bodyDOM = htmlDOM.body;
  state.search.pageContents = [...bodyDOM.children].filter(
    (el) => el.nodeName === "H2"
  );
  state.search.pageText = [...bodyDOM.children].map((el) => el.outerHTML);
  state.error = false;
}
Enter fullscreen mode Exit fullscreen mode

Each time a query is made on the search bar, the getSearchResult function uses that query to load data into the model search state so that it can be used to render the search view. The loadSearchList function is responsible for providing search suggestions based on the user’s query search for every keystroke which are derived from a curated list.

Result

Here’s the search view once we apply the logic for it and its template from the previous post:

import Handlebars from "handlebars/dist/handlebars";
import _ from "lodash";

import templates from "../templates";

function generateListMarkup({…}) {
  const listData = {{…}};
  const template = Handlebars.compile(templates.searchList());
  return template(listData);
}

function searchView() {
  let listItems = [];
  // Selecting HTML elements
  const headEl = document.querySelector(".head--top");
  const formEl = document.querySelector(".wrapper__form-search");
  const iconEl = document.querySelector(".form__icon--search");
  const inputEl = document.querySelector(".form__input--field");
  const listEl = document.querySelector(".form__list--autofill");
  const headHeight = headEl.getBoundingClientRect().height;
  // Add event listeners
  window.addEventListener("scroll", function () {
    const currentScrollPos = window.pageYOffset;
    if (currentScrollPos > headHeight + 100) {
      headEl.classList.add("head--hide");
    } else {
      headEl.classList.remove("head--hide");
    }
  });
  // Public methods
  const renderList = (data) => {
    if (!_.isObject(data)) return;
    const listMarkup = generateListMarkup(data);
    listEl.innerHTML = listMarkup;
    listItems = listEl.querySelectorAll("li");
    listEl.classList.remove("hide");
  };
  // Add handler functions
  const addHandlerInput = (handler) => {
    inputEl.addEventListener("keyup", () => {
      const searchInput = inputEl.value;
      if (!searchInput) return listEl.classList.add("hide");
      listEl.classList.remove("hide");
      handler(searchInput);
    });
  };
  const addHandlerSearch = (handler) => {
    formEl.addEventListener("submit", (e) => {
      e.preventDefault();
      const query = inputEl.value;
      if (query === "") return inputEl.focus();
      inputEl.value = "";
      handler(query);
    });
    iconEl.addEventListener("click", (e) => {
      e.preventDefault();
      const query = inputEl.value;
      if (query === "") return inputEl.focus();
      inputEl.value = "";
      handler(query);
    });
    listEl.addEventListener("click", (e) => {
      const clicked = e.target.closest("li");
      if (!clicked) return;
      inputEl.value = "";
      listItems.forEach((item) => (item.style.display = "none"));
      handler(clicked.textContent);
    });
  };
  // Public API
  const publicApi = {
    renderList,
    addHandlerInput,
    addHandlerSearch,
  };
  return publicApi;
}

export default searchView();
Enter fullscreen mode Exit fullscreen mode

Here’s a demo of the searchbar in action:

Search view demo

Note: The demo has a few rendering issues; I tried my best to reduce this to a minimum

Target View

Adding & Updating Quotas

Another user story we need to work on is the ability for the user to add their weekly goals which they can set out to complete: "user can set weekly goals to complete".

// Local Storage
function persistTargets() {
  localStorage.setItem("targets", JSON.stringify(state.targets));
}

const createTargetQuota = ({
  id = String(Date.now()).slice(10),
  quota,
  checked = false,
}) => ({
  id,
  quota,
  checked,
  toggleChecked() {
    this.checked = !checked;
    return this;
  },
});

export function addTargetQuota(newTargetQuota) {
  if (!newTargetQuota) return;
  const object = createTargetQuota({ quota: newTargetQuota });
  state.targets.push(object);
  persistTargets();
}

export function updateTargetQuotas(id, remove) {
  if (remove) {
    state.targets = [];
    localStorage.removeItem("targets");
  } else {
    const targetQuota = _.find(
      state.targets,
      (targetQuota) => targetQuota.id === id
    );
    targetQuota.toggleChecked();
    persistTargets();
  }
}

export function restoreTargets() {
  const storage = localStorage.getItem("targets");
  if (!storage) return;
  const targets = JSON.parse(storage);
  for (const target of targets) {
    const object = createTargetQuota({
      quota: target.quota,
      checked: target.checked,
    });
    state.targets.push(object);
  }
}
Enter fullscreen mode Exit fullscreen mode

Whenever a user completes the form in the target view, this would cause for the addTargetQuota function to be executed which would then use our createTargetQuotafactory function to create a new quota, and would then be added to the model targets state. And whenever we toggle a quota to be checked, this would be updated using the updateTargetQuotafunction. This data all of course will be persisted each time a new quota is added or updated using persistTargetsfunction and not therefore lost. Finally, the restoreTargetsfunction is executed each time the application is loaded for the first time to re-populate the model targets state.

Note: For this in particular, I’ve decided to alter the name of "goals" and instead to "quota", and store these into a "targets" state because I thought it would make it much better for naming distinguish – this is personal preference.

Result

Here’s the target view once we apply the logic for it and its template from the previous post:

import Handlebars from "handlebars/dist/handlebars";
import _ from "lodash";

import templates from "../templates";

function generateTargetQuotaMarkup({…}) {
  const targetQuotaData = {…};
  const template = Handlebars.compile(templates.quota());
  return template(targetQuotaData);
}

function buildTargetView() {
  let formEl, inputEl;
  // Create base parent
  const base = document.createElement("div");
  base.classList.add("content__container--aside");
  // Create head
  const headEl = document.createElement("header");
  headEl.classList.add("content__container-head");
  // Create list
  const listEl = document.createElement("ul");
  listEl.classList.add("content__container-list");
  // Add children to base parent
  base.appendChild(headEl);
  base.appendChild(listEl);
  // Private methods
  const clearList = () => (listEl.innerHTML = "");
  // Add event listeners
  headEl.addEventListener("click", (e) => {
    const clicked = e.target.closest(".gg-add");
    if (!clicked) return;
    formEl = document.querySelector(".head__form--inline");
    formEl.classList.toggle("head__form--display");
  });
  // Public methods
  const renderHead = () => {
    headEl.innerHTML = `
      <header class="content__container-head">
        <h1 class="head__header">Weekly Targets</h1>  
        <i class="head__icon--add gg-add" title="Add a target quota"></i>
        <form class="head__form--inline">
          <input
            class="form__input--quota"
            type="text"
            placeholder="Type in a target quota"
            required
          />
          <i
            class="form__icon--enter gg-enter"
            title="Submit target quota"
          ></i>
        </form>
      </header>
    `;
    formEl = headEl.querySelector(".head__form--inline");
  };
  const renderList = (data) => {
    if (!_.isObject(data)) return;
    const markup = generateTargetQuotaMarkup(data);
    clearList();
    listEl.insertAdjacentHTML("afterbegin", markup);
  };
  // Add handler functions
  const addHandlerSubmit = (handler) => {
    if (
      formEl.getAttribute("data-event-submit") !== "true" &&
      formEl.getAttribute("data-event-click") !== "true"
    ) {
      formEl.setAttribute("data-event-submit", "true");
      formEl.addEventListener("submit", (e) => {
        e.preventDefault();
        inputEl = document.querySelector(".form__input--quota");
        const quota = inputEl.value;
        inputEl.value = "";
        formEl.classList.toggle("head__form--display");
        handler(quota);
      });

      formEl.setAttribute("data-event-submit", "true");
      formEl.addEventListener("click", (e) => {
        e.preventDefault();
        inputEl = document.querySelector(".form__input--quota");
        const clicked = e.target.closest(".gg-enter");
        if (!clicked) return;
        const quota = inputEl.value;
        inputEl.value = "";
        formEl.classList.toggle("head__form--display");
        handler(quota);
      });
    }
  };
  const addHandlerToggle = (handler) => {
    if (listEl.getAttribute("data-event-toggle") !== "true") {
      listEl.addEventListener("click", (e) => {
        const elementClicked = e.target;
        elementClicked.setAttribute("data-event-toggle", "true");
        const clicked = e.target.closest(".item__input");
        if (!clicked) return;
        const toggleBoxes = Array.from(listEl.querySelectorAll(".item__input"));
        const allChecked = toggleBoxes.every((toggleBox) => toggleBox.checked);
        const id = clicked.closest(".list-item").getAttribute("data-id");
        handler(id, allChecked);
      });
    }
  };
  // Public API
  const publicApi = {
    renderHead,
    renderList,
    addHandlerSubmit,
    addHandlerToggle,
    base,
  };
  return publicApi;
}

export default buildTargetView;
Enter fullscreen mode Exit fullscreen mode

Here’s a preview of the target view:

Rendered target view

Quote View

Library Quotes

For this application, instead of using another API to fetch data, in this case quotes, I will be using a library called "Quotesy". Some of the benefits of this is that I don’t need to rely on the client having constant access to the Internet to be able to application, so therefore parts of the application such as this view will continue to work no matter what since the quotes are already stored, however that is the downside to this since we do this is that the client’s bundle-size would be much larger depending the size of the quotes dataset. Regardless, we end up with simple logic to retrieve data from this quote collection:

import Quotesy from "quotesy/lib/index";

// Local Storage
function persistQuoteBookmarks() {
  localStorage.setItem(
    "quoteBookmarks",
    JSON.stringify(state.bookmarks.quotes)
  );
}

export async function loadQuote() {
  if (!_.isEmpty(state.quote)) return;
  state.quote = Quotesy.random();
}

export function addQuoteBookmark(newQuote) {
  const foundDuplicate = _.findIndex(state.bookmarks.quotes, newQuote);
  if (foundDuplicate === -1) state.bookmarks.quotes.push(newQuote);
  else state.bookmarks.quotes.splice(foundDuplicate, 1);
  persistQuoteBookmarks();
}

export function restoreQuoteBookmarks() {
  const storage = localStorage.getItem("quoteBookmarks");
  if (storage) state.bookmarks.quotes = JSON.parse(storage);
}
Enter fullscreen mode Exit fullscreen mode

When the application is executed for the first time, a random quote is retrieved from the Quotesy library using the loadQuote function which updates the model quote state for the quote view to render it. The addQuoteBookmark function feature is included as one of the user-stories - "user is able to bookmark given quotes" – which allows the user to add that specified quote that has been rendered in the quote view, and this data is persisted using the persistQuoteBookmarks function. And finally, therestoreQuoteBookmarks function each time the application is initialised for the first time.

Result

Here’s the quote view once we apply the logic for it and its template from the previous post:

import Handlebars from "handlebars/dist/handlebars";
import _ from "lodash";

import templates from "../templates";

let bookmarkText, bookmarkAuthor;

function generateQuoteMarkup({…}) {
  const quoteInput = {…};
  [bookmarkText, bookmarkAuthor] = [text, author];
  const template = Handlebars.compile(templates.quote());
  return template(quoteInput);
}

function buildQuoteView() {
  // Create base parent
  const base = document.createElement("div");
  base.classList.add("content__container--top");
  // Create icon element
  const iconEl = document.createElement("i");
  iconEl.classList.add("content__container-icon--bookmark,gg-bookmark");
  iconEl.setAttribute("title", "Add to quotes");
  // Create quote label
  const quoteLbl = document.createElement("q");
  quoteLbl.classList.add("container__label", "container__label--quote");
  // Create author label
  const authorLbl = document.createElement("p");
  authorLbl.classList.add("container__label", "container__label--author");
  // Add children to base parent
  base.appendChild(iconEl);
  base.appendChild(quoteLbl);
  base.appendChild(authorLbl);
  // Private methods
  const clear = () => (base.innerHTML = "");
  // Public methods
  const render = (data) => {
    if (!_.isObject(data)) return;
    const quoteMarkup = generateQuoteMarkup(data);
    clear();
    base.innerHTML = quoteMarkup;
  };
  // Add handler functions
  const addHandlerToggle = (handler) => {
    if (base.getAttribute("data-event-click") !== "true") {
      base.setAttribute("data-event-click", "true");
      base.addEventListener("click", (e) => {
        const clicked = e.target.closest(".gg-bookmark");
        if (!clicked) return;
        clicked.classList.toggle("bookmark--active");
        const data = {
          text: bookmarkText,
          author: bookmarkAuthor,
        };
        handler(data);
      });
    }
  };
  // Public API
  const publicApi = {
    render,
    addHandlerToggle,
    base,
  };
  return publicApi;
}

export default buildQuoteView;
Enter fullscreen mode Exit fullscreen mode

Here’s a demo of rendered quote:
Quote view demo

Article View

Bookmarking

This article view will represent the entire "models" webpage. The logic for this view is somewhat similar to the quote view, only that we need to only add functionalities for bookmarking a person as part of our user-stories: "user is able to bookmark given quotes". Here’s that logic:

import _ from "lodash";

// Local Storage
function persistModelBookmarks() {
  localStorage.setItem(
    "modelBookmarks",
    JSON.stringify(state.bookmarks.models)
  );
}

export function addModelBookmark(newModel) {
  const foundDuplicate = _.findIndex(state.bookmarks.models, newModel);
  if (foundDuplicate === -1) state.bookmarks.models.push(newModel);
  else state.bookmarks.models.splice(foundDuplicate, 1);
  persistModelBookmarks();
}

export function restoreModelBookmarks() {
  const storage = localStorage.getItem("modelBookmarks");
  if (storage) state.bookmarks.models = JSON.parse(storage);
}
Enter fullscreen mode Exit fullscreen mode

As said, similar to the quote view, we add a “model” to our bookmarks using the addModelBookmark function and persist this data using the persistModelBookmarks function. And as always, restore this bookmarks using the restoreModelBookmarks function each we load our application for the first time.

Result

Here’s the article view once we apply the logic for it and its template from the previous post:

import Handlebars from "handlebars/dist/handlebars";
import _ from "lodash";

import templates from "../templates";

let bookmarkTitle, bookmarkImageSrc;

function generateArticleMarkup({…}) {
  const articleInput = {…};
  [bookmarkTitle, bookmarkImageSrc] = [title, imageSrc];
  const template = Handlebars.compile(templates.article());
  return template(articleInput);
}

function buildArticleView() {
  let iconEl, listEl;
  // Create base parent
  const base = document.createElement("div");
  // Private methods
  const clear = () => (base.innerHTML = "");
  // Add event handlers
  const handleClick = (e) => {
    e.preventDefault();
    const clicked = e.target.closest(".list__item-link");
    if (!clicked) return;
    const hash = clicked.hash.slice(1);
    document.getElementById(hash).scrollIntoView({ behavior: "smooth" });
  };
  // Public methods
  const render = (data) => {
    if (!_.isObject(data)) return;
    const articleMarkup = generateArticleMarkup(data);
    clear();
    base.innerHTML = articleMarkup;
    iconEl = base.querySelector(".head__icon--bookmark");
    listEl = base.querySelector(".head__list");
    listEl.addEventListener("click", handleClick);
  };
  const renderError = () => {
    const markup = `
      <p class="error__label">
        Page couldn't be found, or is missing
      </p>
    `;
    base.innerHTML = markup;
  };
  // Add handler functions
  const addHandlerToggle = (handler) => {
    if (iconEl.getAttribute("data-event-click") !== "true") {
      iconEl.setAttribute("data-event-click", "true");
      iconEl.addEventListener("click", () => {
        iconEl.classList.toggle("bookmark--active");
        const data = {
          name: bookmarkTitle,
          imageSrc: bookmarkImageSrc,
        };
        handler(data);
      });
    }
  };
  // Public API
  const publicApi = {
    render,
    renderError,
    addHandlerToggle,
    base,
  };
  return publicApi;
}

export default buildArticleView;
Enter fullscreen mode Exit fullscreen mode

Unfortunately, I couldn't provide a demo of how the article view works due issues uploading it, but please check out the live demo of the website here if you want to see for your self :¬)

Bookmark Views

For the following views, I also won't be providing a demo, so please do visit the link in the previous sub-subsection if your interested.

Model

Since the logic for the model bookmark view has been already applied prior, the getSearchResult function, here’s the applied template from the previous post:

import Handlebars from "handlebars/dist/handlebars";
import _ from "lodash";

import templates from "../templates";

function generateModelBookmarkMarkup({…}) {
  const modelBookmarkData = {…};
  const template = Handlebars.compile(templates.modelBookmark());
  return template(modelBookmarkData);
}

function buildModelBookmarkView() {
  // Create base parent
  const base = document.createElement("div");
  // Public methods
  const render = (data) => {
    if (!_.isObject(data)) return;
    const modelBookmarkMarkup = generateModelBookmarkMarkup(data);
    base.innerHTML = modelBookmarkMarkup;
  };
  // Add handler functions
  const addHandlerClick = (handler) => {
    if (base.getAttribute("data-event-click") !== "true") {
      base.setAttribute("data-event-click", "true");
      base.addEventListener("click", (e) => {
        const clicked = e.target.closest("li");
        if (!clicked) return;
        const data = clicked.getAttribute("data-title");
        handler(data);
      });
    }
  };
  // Public API
  const publicApi = {
    render,
    addHandlerClick,
    base,
  };
  return publicApi;
}

export default buildModelBookmarkView;
Enter fullscreen mode Exit fullscreen mode

Quote

Since there is no logic for the quote bookmark view in the model, here’s just the applied template from the previous post:

import Handlebars from "handlebars/dist/handlebars";
import _ from "lodash";

import templates from "../templates";

function generateQuoteBookmarkMarkup({…}) {
  const quoteBookmarkData = {…};
  const template = Handlebars.compile(templates.quoteBookmark());
  return template(quoteBookmarkData);
}

function buildQuoteBookmarkView() {
  // Create base parent
  const base = document.createElement("div");
  // Public methods
  const render = (data) => {
    if (!_.isObject(data)) return;
    const quoteBookmarkMarkup = generateQuoteBookmarkMarkup(data);
    base.innerHTML = quoteBookmarkMarkup;
    base.addEventListener("click", (e) => {
      const clicked = e.target.closest("li");
      if (!clicked) return;
      const list = clicked.querySelector(".item__list");
      list.classList.toggle("hide");
    });
  };
  // Public API
  const publicApi = {
    render,
    base,
  };
  return publicApi;
}

export default buildQuoteBookmarkView;
Enter fullscreen mode Exit fullscreen mode

Controller

After adding the logic for handling the state and persisting/restoring data from the model, adding the appropriate DOM-related logic for each of the views, we finally end up with this "controller.js" file that is responsible for the interaction between the model and views without them directly communicating with each other:

import * as model from "./model";
import templates from "./templates";
// Views
import menuView from "./views/menuView";
import searchView from "./views/searchView";
// --- Build Functions
import buildArticleView from "./views/articleView";
import buildTargetView from "./views/targetView";
import buildCalendarView from "./views/calendarView";
import buildQuoteView from "./views/quoteView";
import buildModelBookmarkView from "./views/modelBookmarkView";
import buildQuoteBookmarkView from "./views/quoteBookmarkView";

const contentMain = document.querySelector(".main__content");
// Build app views
const articleView = buildArticleView();
const targetView = buildTargetView();
const calendarView = buildCalendarView();
const quoteView = buildQuoteView();
const quoteBookmarkView = buildQuoteBookmarkView();
const modelBookmarkView = buildModelBookmarkView();

////////////////////////////////////////////////
////// Routing + Initialise App
///////////////////////////////////////////////

function route(evt = window.event) {
  evt.preventDefault();
  window.history.pushState({}, "", evt.target.href);
  handleLocation();
}

function handleLocation(redirect = false) {
  // Get current url pathname
  const pathName = redirect ? "/dashboard" : window.location.pathname;
  // Now generate the HTML template markup in placeholder
  contentMain.innerHTML = "";
  // Render pathname view
  switch (pathName) {
    case "/dashboard":
      return controlDashboard();
    case "/article":
      return controlArticle();
    case "/quotes":
      return controlQuotes();
    case "/models":
      return controlModels();
  }
}

function init() {
  // Load calendar
  model.loadCalendar();
  // Add event handlers
  menuView.addHandlerClick(controlMenu);
  searchView.addHandlerInput(controlSearchSuggestions);
  searchView.addHandlerSearch(controlSearch);
  // Add event listeners
  window.addEventListener("popstate", handleLocation);
  // Restore local storage data
  model.restoreTargets();
  model.restoreMarkedDays();
  model.restoreModelBookmarks();
  model.restoreQuoteBookmarks();
  // Redirect to homepage
  handleLocation(true);
}

init();

////////////////////////////////////////////////
////// Control Functionalities
///////////////////////////////////////////////

function controlMenu(event) {
  route(event);
}

async function controlArticle() {
  contentMain.insertAdjacentHTML("beforeend", templates.spinner());
  await model.getSearchResult(model.state.search.query);
  if (model.state.error) {
    articleView.renderError();
  } else {
    articleView.render(model.state.search);
    articleView.addHandlerToggle(controlBookmarkArticle);
  }
  contentMain.innerHTML = "";
  contentMain.classList.remove("content--flex");
  contentMain.insertAdjacentElement("beforeend", articleView.base);
}

function controlModels() {
  contentMain.insertAdjacentHTML("beforeend", templates.spinner());
  modelBookmarkView.render(model.state.bookmarks);
  modelBookmarkView.addHandlerClick(controlSearch);
  contentMain.innerHTML = "";
  contentMain.classList.remove("content--flex");
  contentMain.insertAdjacentElement("beforeend", modelBookmarkView.base);
}

function controlBookmarkArticle(newModel) {
  model.addModelBookmark(newModel);
}

async function controlSearch(query) {
  await model.getSearchResult(query);
  window.history.pushState({}, "", "/article");
  handleLocation();
}

function controlSearchSuggestions(currentSearch) {
  model.loadSearchList(currentSearch);
  searchView.renderList(model.state.search);
}

async function controlDashboard() {
  contentMain.classList.add("content--flex");
  // Target
  targetView.renderHead();
  targetView.renderList(model.state);
  targetView.addHandlerSubmit(controlAddTarget);
  targetView.addHandlerToggle(controlUpdateTargets);
  contentMain.insertAdjacentElement("beforeend", targetView.base);
  // Calendar
  calendarView.renderTable(model.state.calendar);
  calendarView.renderNav(model.state.calendar);
  calendarView.updateHeader(
    model.state.calendar.formatMonth,
    model.state.calendar.year
  );
  calendarView.addHandlerToggle(controlCalendar);
  calendarView.addHandlerClick(controlCalendarPagination);
  contentMain.insertAdjacentElement("beforeend", calendarView.base);
  // Quote
  await model.loadQuote();
  quoteView.render(model.state.quote);
  quoteView.addHandlerToggle(controlBookmarkQuote);
  contentMain.insertAdjacentElement("beforeend", quoteView.base);
}

function controlAddTarget(newTargetQuota) {
  model.addTargetQuota(newTargetQuota);
  targetView.renderList(model.state);
}

function controlUpdateTargets(targetQuotaId, remove = false) {
  model.updateTargetQuotas(targetQuotaId, remove);
  targetView.renderList(model.state);
}

function controlCalendar(markedDayDate, remove = false) {
  model.updateMarkedDays(markedDayDate, remove);
  calendarView.renderTable(model.state.calendar);
}

function controlCalendarPagination(setMonth, reverse) {
  model.setCalendar(setMonth, reverse);
  calendarView.renderTable(model.state.calendar);
  calendarView.renderNav(model.state.calendar);
  calendarView.updateHeader(
    model.state.calendar.formatMonth,
    model.state.calendar.year
  );
}

function controlQuotes() {
  contentMain.insertAdjacentHTML("beforeend", templates.spinner());
  quoteBookmarkView.render(model.state.bookmarks);
  contentMain.innerHTML = "";
  contentMain.classList.remove("content--flex");
  contentMain.insertAdjacentElement("beforeend", quoteBookmarkView.base);
}

function controlBookmarkQuote(newQuote) {
  model.addQuoteBookmark(newQuote);
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

The final application is now complete! We’ve successfully tackled every user story in our user-stories and applied the MVC architecture for our application to have project scalability in this post. Here's the link to the full code repo for this project

As I’ve mentioned in my first post, knowing how to build complex application from scratch can be a difficult task.

I spent a lot time cultivating this two-part post series as I wanted this series to serve as a means of reference for any beginner-to-intermediate programmer out there who is looking to perhaps build a project for school, to display on their coding portfolio, or even out-of-curiosity.

In the end of the day, I hope that this series provided at least some helpful guidance with that.

And if you have have made it this far, I greatly appreciate the time you took out of your day to read this post, and I wish you with the best in both your professional and personal life!

Top comments (1)

Collapse
 
artydev profile image
artydev

I love your app :-)