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
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();
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();
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);
}
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);
}
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);
}
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;
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:
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;
}
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();
Here’s a demo of the searchbar in action:
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);
}
}
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 createTargetQuota
factory 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 updateTargetQuota
function. This data all of course will be persisted each time a new quota is added or updated using persistTargets
function and not therefore lost. Finally, the restoreTargets
function 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;
Here’s a preview of the 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);
}
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;
Here’s a demo of rendered quote:
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);
}
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;
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;
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;
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);
}
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)
I love your app :-)