-
Notifications
You must be signed in to change notification settings - Fork 7
Add dynamic content and manage state with vanilla Javascript
So we have come so far to implement a table, and now we want to add manipulations to it. Please keep in mind that you have to order the classes correctly, subclasses need to be parsed after the parent class
We need a smart way to update our table when we add or update a book. One easy way is to create a class that we extend, so lets create the Observable class
class Observable{
constructor(){
this.subscribers = [];
}
/**
* Subscribe for changes
* Add a function you want to be executed whenever this model changes.
*
* @params Function fn
* @return null
*/
subscribe(fn) {
this.subscribers.push(fn);
}
/**
* Unsubscribe from being notified when this model changes.
*
* @params Function fn
* @return null
*/
unsubscribe(fn) {
this.subscribers = this.subscribers.filter(
function(item){
return item !== fn;
}
);
}
/**
* Notify subscribers
*
* @return null
*/
notifySubscribers() {
for(let i = 0; i < this.subscribers.length; i++){
this.subscribers[i]();
}
}
}
As you see we have three methods, a subscribe method, unsubscribe and notifySubscriber. This is called the Observer Pattern, you can see another example written in javascript of it here.
We also change the books list reference in our BooksTable class as we want to use our Observable class when we add and update our books. The BooksCollections class looks like this.
class BooksCollection extends Observable{
constructor(obj){
super();
this.books = obj.books || [];
}
getBook(id){
for(let i = 0; i < this.books.length; i++){
if(this.books[i].id === id){
return this.books[i];
}
}
}
addBook(book){
this.books.push(book);
this.notifySubscribers();
}
updateBook(obj){
const book = this.getBook(obj.id);
book.updateProperties(obj);
this.notifySubscribers();
}
}
Notice that we invoke the notifySubscribers() method when we add a book or update a book.
Now we will implement a lot of code for handling the workflow
First up is a function for showing, hiding and appending elements to the grey background for modals.
const GreyModalElement = function() {
const element = document.getElementById('grey-modal-background');
return {
show: function() {
element.classList.remove('hidden');
},
hide: function() {
element.innerHTML = "";
element.classList.add('hidden');
},
appendChild: function(childElement) {
element.appendChild(childElement);
}
}
}();
It is not a class as we only need one "instance".
Next up are the form classes, we will create 4 of them. This may look like an awful amount of code, but it is quite simple actually. I will try to explain their purpose.
-
The first class is BaseFormAbstract, it is an abstract class, you cannot instantiate it and it only serves one purpose: building the FORM element. It can be used for any types of forms you will create.
-
Next class is CreateBookForm which extends the BaseFormAbstract class. This creates the book form, with all the relevant fields. It also creates the cancel and submit button elements with eventlisteners for the form. When you submit the form it will also run form validation, which are nice when you don't want the end-user to submit empty fields. But this class still use form submit, and we are developing a javascript example with no backend.
-
The CreateBookFormForTable class is the form we create when we want to add a book to our table. We have all the behavior we want in the CreateBookForm class, except for the submit method. So the only thing different with this class is the submit method. This is an excellent way to show good code reuse by extending classes.
-
Finally we have the EditBookFormForTable class, it also extends the CreateBookForm class. When editing a book, we need to bind book data to the form. So it has a bindBookToForm() method for that. We also need a id field that will work as a reference to which book in the BooksCollection class we are going to update.
class BaseFormAbstract{
constructor(obj){
if (new.target === BaseFormAbstract) {
throw new TypeError("Cannot construct Abstract instances directly");
}
this.method = obj.method || 'POST';
this.action = obj.action || null;
this.enctype = obj.enctype || null;
this.buildDOMElements();
this.updateAttributes();
}
buildDOMElements() {
this.formElement = document.createElement('FORM');
this.submitButtonElement = document.createElement('BUTTON');
this.submitButtonElement.type = "submit";
this.submitButtonElement.textContent = 'Submit';
}
updateAttributes() {
this.formElement.method = this.method;
this.formElement.action = this.action;
if(this.enctype){
this.formElement.enctype = this.enctype;
}
}
submit(){
this.formElement.submit();
}
}
class CreateBookForm extends BaseFormAbstract{
constructor(obj){
super(obj);
this.destroyFormFn = () => {
GreyModalElement.hide();
};
this.submitEventFn = event => {
event.preventDefault();
this.submit();
};
this.updateFormElement();
this.updateSubmitButtonElement();
this.buildCancelButton();
}
updateFormElement() {
this.formElement.name = "create-book";
this.formElement.className = "modal";
}
updateSubmitButtonElement() {
this.submitButtonElement.textContent = "Add book";
this.submitButtonElement.classList.add('green');
this.submitButtonElement.addEventListener('click', this.submitEventFn);
}
buildCancelButton(){
this.cancelButtonElement = document.createElement('BUTTON');
this.cancelButtonElement.textContent = "Cancel";
this.cancelButtonElement.addEventListener('click', this.destroyFormFn);
}
validate(){
let isValid = true;
const formFields =
this.formElement.querySelectorAll('input[type="text"]');
for(let i = 0; i < formFields.length; i++){
if(formFields[i].value === ""){
isValid = false;
// Add error class to input field
if(!formFields[i].classList.contains('error')){
formFields[i].classList.add('error');
}
} else {
// If no error, remove error class if exists
if(formFields[i].classList.contains('error')){
formFields[i].classList.remove('error');
}
}
}
return isValid;
}
submit(){
if(this.validate()){
this.formElement.submit();
}
}
render(){
this.formElement.innerHTML = `
<label>Title:</label>
<input type="text" name="title" value="" tabindex="10"/>
<label>ISBN:</label>
<input type="text" name="isbn" value="" tabindex="20"/>
<label>Author:</label>
<input type="text" name="author.name" value="" tabindex="30"/>
`;
this.cancelButtonElement.tabIndex = 100;
this.submitButtonElement.tabIndex = 110;
this.formElement.appendChild(this.submitButtonElement);
this.formElement.appendChild(this.cancelButtonElement);
// assign input elements to this object,
// so it is easier to reference them in code
this.inputFieldForTitle =
this.formElement.querySelector('input[name="title"]');
this.inputFieldForAuthor =
this.formElement.querySelector('input[name="author.name"]');
this.inputFieldForISBN =
this.formElement.querySelector('input[name="isbn"]');
}
}
class CreateBookFormForTable extends CreateBookForm{
constructor(obj, booksCollection){
super(obj);
this.booksCollection = booksCollection;
}
submit(){
if(this.validate()){
let book = new Book({
id: this.booksCollection.books.length + 1,
title: this.inputFieldForTitle.value,
author: new Author({
name: this.inputFieldForAuthor.value
}),
isbn: this.inputFieldForISBN.value
});
this.booksCollection.addBook(book);
this.destroyFormFn();
}
}
}
class EditBookFormForTable extends CreateBookForm{
constructor(obj, booksCollection, book){
super(obj);
this.booksCollection = booksCollection;
this.book = book;
this.submitButtonElement.textContent = "Update book";
this.buildInputFieldForId();
}
buildInputFieldForId(){
this.inputFieldForId = document.createElement('INPUT');
this.inputFieldForId.name = "id";
this.formElement.appendChild(this.inputFieldForId);
}
bindBookToForm() {
this.inputFieldForTitle.value = this.book.title;
this.inputFieldForAuthor.value = this.book.author.name;
this.inputFieldForISBN.value = this.book.isbn;
this.inputFieldForId.value = this.book.id;
}
submit(){
if(this.validate()){
const bookProperties = {
id: Number(this.inputFieldForId.value),
title: this.inputFieldForTitle.value,
// I just create a new author here for the sake of simplicity
author: new Author({
name: this.inputFieldForAuthor.value
}),
isbn: this.inputFieldForISBN.value
};
this.booksCollection.updateBook(bookProperties);
this.destroyFormFn();
}
}
render(){
super.render();
this.bindBookToForm();
}
}
We are getting close to having a working example. Next step is updating our BooksTable class:
class BooksTable{
constructor(obj){
this.containerElement = obj.containerElement;
this.fields = Book.getFields();
this.updateProperties(obj);
this.showCreateBookModalFn = () => {
const createBookForm =
new CreateBookFormForTable({}, this.booksCollection);
createBookForm.render();
GreyModalElement.appendChild(createBookForm.formElement);
GreyModalElement.show();
createBookForm.inputFieldForTitle.focus();
};
this.showEditBookModalFn = event => {
// This make sure that we always get the row element even if we
// click the td element
const rowElement = event.target.closest('tr');
const bookId =
Number(rowElement.querySelector('td:first-child').textContent);
const editBookForm =
new EditBookFormForTable(
{},
this.booksCollection,
this.booksCollection.getBook(bookId)
);
editBookForm.render();
GreyModalElement.show();
GreyModalElement.appendChild(editBookForm.formElement);
editBookForm.inputFieldForTitle.focus();
};
this.buildDOMElements();
this.render();
}
updateProperties(obj) {
this.booksCollection = new BooksCollection(obj);
this.booksCollection.subscribe(()=>{
this.render();
});
}
buildDOMElements() {
this.tableElement = document.createElement('TABLE');
this.tableHeaderElement = this.tableElement.createTHead();
// There is no createTBody function
this.tableBodyElement = document.createElement('TBODY');
this.tableElement.appendChild(this.tableBodyElement);
this.buildAddBookBtn();
}
buildAddBookBtn(){
// Add book button element
this.createBookBtnElement = document.createElement('BUTTON');
this.createBookBtnElement.textContent = "Create Book";
this.createBookBtnElement
.addEventListener('click', this.showCreateBookModalFn);
}
renderHead(){
// map() will loop the fields property and create the <th> elements
this.tableHeaderElement.innerHTML = `
<tr>
${this.fields.map(item => `<th>${item}</th>`).join('')}
</tr>
`;
}
renderBody(){
this.tableBodyElement.innerHTML = `
${this.booksCollection.books.map(book => `
<tr>
<td>${book.id}</td>
<td>${book.title}</td>
<td>${book.isbn}</td>
<td>${book.author.name}</td>
</tr>
`).join('')}
`;
for(let i = 0; i < this.tableBodyElement.children.length; i++){
const rowElement = this.tableBodyElement.children[i];
rowElement.addEventListener('click', this.showEditBookModalFn);
}
}
render(){
this.renderHead();
this.renderBody();
this.containerElement.innerHTML = "";
this.containerElement.appendChild(this.tableElement);
// Append add book button element to the container
this.containerElement.appendChild(this.createBookBtnElement);
}
}
A lot of things has been added, BooksTable class now have events for showing both the add and edit book form modal. But the most interesting part is that we are now subscribing for events when our BooksCollection class gets a new book or an updated book.
The code is very simple, we just create a function this will invoke the BooksTable render() method when gets a new book or an updated book:
// BooksTable class
updateProperties(obj) {
this.booksCollection = new BooksCollection(obj);
this.booksCollection.subscribe(()=>{
this.render();
});
}
Thats it! Now you should see the your new table working as the following picture
The example2 folder contains the full source code for a working example.