Introduction
The two example apps that we have discussed in the previous parts of this tutorial, the minimal app and the validation app, have been limited to managing the data of one object type only. A real app, however, has to manage the data of several object types, which are typically related to each other in various ways. In particular, there may be associations and subtype (inheritance) relationships between object types. Handling associations and subtype relationships are advanced issues in software application engineering. They are often not sufficiently discussed in software development text books and not well supported by application development frameworks. In this part of the tutorial, we show how to deal with unidirectional associations, while bidirectional associations and subtype relationships are covered in parts 5 and 6.
This article has been extracted from a six-part tutorial about engineering front-end web applications with plain JavaScript available as the open access book Building Front-End Web Apps with Plain JavaScript. It shows how to build a web app that takes care of the three object types Book
, Publisher
and Author
, as well as of the two unidirectional associations that assign a publisher and (one or more) authors to a book. If you want to take a look at it and see how it works, you can run the unidirectional association app discussed in this article from our server.
A front-end web app can be provided by any web server, but it is executed on the user's computer device (smartphone, tablet or notebook), and not on the remote web server. Typically, but not necessarily, a front-end web application is a single-user application, which is not shared with other users.
The app supports the four standard data management operations Create/Read/Update/Delete. It extends the example app of part 2 by adding code for handling the unidirectional functional (many-to-one) association between Book
and Publisher
, and the unidirectional non-functional (many-to-many) association between Book
and Author
, but it needs to be enhanced by adding further important parts of the app's overall functionality:
Background
If you didn't read them already, you may first want to read parts 1-3 of this tutorial: 1 Building a Minimal App in Seven Steps, 2 Adding Constraint Validation and 3 Dealing with Enumerations.
We adopt the approach of model-based development, which provides a general methodology for engineering all kinds of artifacts, including data management apps. For being able to understand this tutorial, you need to understand the underlying concepts and theory. Either you first read the theory chapter on reference properties and associations, before you continue to read this tutorial, or you start reading this tutorial and consult the theory chapter only on demand, e.g., when you stumble upon a term that you don't know.
As a short introduction to association concepts, you can read my blog article Why are associations so difficult to understand, not just for developers? Unfortunately, in many articles and tutorials, associations are not treated in sufficient depth, if at all. For instance, in this CP article, they are only superficially discussed (no mention of non-functional and bi-directional associations). In another CP article, associations are even confusingly defined in terms of objects using each other but having their own object life time, which may sound like an expert statment, but is plain wrong.
Coding the App
Implementing Unidirectional Functional Associations
A unidirectional functional association is either one-to-one or many-to-one. In both cases such an association is represented, or implemented, with the help of a single-valued reference property.
In this section, we show
-
how to derive a JavaScript data model from an information design model with single-valued reference properties representing unidirectional functional associations,
-
how to encode the JavaScript data model in the form of JavaScript model classes,
-
how to write the view and controller code based on the model code (using a model-view-controller architecture).
A single-valued reference property, such as the property publisher
of the object type Book
, allows storing internal references to objects of another type, such as Publisher
. When creating a new object, the constructor function needs to have a parameter for allowing to assign a suitable value to the reference property. In a typed programming language, such as Java, we would have to take a decision if this value is expected to be an internal object reference or an ID reference. In JavaScript, however, we can take a more flexible approach and allow using either of them, as shown in the following exmple:
function Book( slots) {
...
this.publisher = null;
...
if (arguments.length > 0) {
...
if (slots.publisher) this.setPublisher( slots.publisher);
else if (slots.publisherIdRef) this.setPublisher( slots.publisherIdRef);
...
}
}
Notice that, for flexibility, the constructor parameter slots
may contain either a publisher
slot representing an (internal) JavaScript object reference or a publisherIdRef
slot representing an (external) ID reference (or foreign key). We handle the resulting ambiguity in the property setter by checking the type of the argument as shown in the following code fragment:
Book.prototype.setPublisher = function (p) {
...
var publisherIdRef = "";
if (typeof(p) !== "object") {
publisherIdRef = p;
} else {
publisherIdRef = p.name;
}
}
Notice that the name
of a publisher is used as an ID reference (or foreign key), since this is the standard identifier (or primary key) of the Publisher
class.
Make a JavaScript Data Model
The starting point for making a JavaScript data model is an information design model where explicit associations have been turned into reference properties, like the following one:
We now show how to derive a JavaScript data model from this design model in three steps.
-
Create a check operation for each non-derived property in order to have a central place for implementing property constraints. For a standard identifier property (such as Book::isbn
), three check operations are needed:
-
A basic check operation, such as checkIsbn
, for checking all syntactic constraints, but not the mandatory value and the uniqueness constraints.
-
A standard ID check operation, such as checkIsbnAsId
, for checking the mandatory value and uniqueness constraints that are required for an identifier (or primary key) attribute.
-
An ID reference check operation, such as checkIsbnAsIdRef
, for checking the referential integrity constraint that is required for an ID reference (IdRef) (or foreign key) attribute.
For a reference property, such as Book::publisher
, the check operation, Book.checkPublisher
, has to check the corresponding referential integrity constraint, and possibly also a mandatory value constraint, if the property is mandatory.
-
Create a setter operation for each non-derived single-valued property. In the setter, the corresponding check operation is invoked and the property is only set, if the check does not detect any constraint violation.
-
Create add and remove operations for each non-derived multi-valued property.
This leads to the following JavaScript data model class Book
, where the class-level ('static') methods are shown underlined:
We have to perform a similar transformation also for the class Publisher
. This gives us the complete JavaScript data model derived from the above association-free model, as depicted in the following class diagram:
New issues
Compared to the single-class app discussed, e.g., in Part 2 (Validation Tutorial), we have to deal with a number of new technical issues:
-
In the model code we now have to take care of reference properties that require
-
validation of referential integrity constraints (ID references)
-
conversion between (internal) object references and (external) ID references in the serialization and de-serialization procedures.
-
In the user interface ("view") code we now have to take care of
-
showing information about associated objects in the list objects use case;
-
allowing to select an associated object from a list of all existing instances of the target class in the create object and update object use cases.
The last issue, allowing to select an associated object from a list of all existing instances of some class, can be solved with the help of an HTML select
form element.
Write the Model Code
The JavaScript data model can be directly encoded for getting the code of the model layer of our JavaScript frontend app:
-
Encode each model class as a JavaScript constructor function.
-
Encode the property checks in the form of class-level ('static') methods. Take care that all constraints of a property as specified in the JavaScript data model are properly encoded in the property checks.
-
Encode the property setters as (instance-level) methods. In each setter, the corresponding property check is invoked and the property is only set, if the check does not detect any constraint violation.
-
Implement a deletion policy.
-
Serialize and de-serialize.
These steps are discussed in more detail in the following sections.
1. Encode each class of the JavaScript data model as a constructor function
Each class C
of the data model is encoded by means of a corresponding JavaScript constructor function with the same name C
having a single parameterslots
, which has a key-value slot providing a value for each non-derived property of the class. The range of these properties should be indicated in a comment.
In the constructor body, we first assign default values to all properties. These values will be used when the constuctor is invoked as a default constructor, that is, without any argument. If the constructor is invoked with arguments, the default values may be overwritten by calling the setter methods for all properties.
For instance, the Publisher
class from the JavaScript data model is encoded in the following way:
function Publisher( slots) {
this.name = "";
this.address = "";
if (arguments.length > 0) {
this.setName( slots.name);
this.setAddress( slots.address);
}
};
Since the setters may throw constraint violation exceptions, the constructor function, and any setter, should be called in a try-catch block where the catch clause takes care of logging suitable error messages.
For each model class C
, we define a class-level property C.instances
representing the collection of all C
instances managed by the application in the form of a JSON table (a map of records): This property is initially set to {}
. For instance, in the case of the model class Publisher
, we define:
Publisher.instances = {};
We encode the class Book
in a similar way:
function Book( slots) {
this.isbn = "";
this.title = "";
this.year = 0;
this.publisher = null;
if (arguments.length > 0) {
this.setIsbn( slots.isbn);
this.setTitle( slots.title);
this.setYear( slots.year);
if (slots.publisher) this.setPublisher( slots.publisher);
else if (slots.publisherIdRef) this.setPublisher( slots.publisherIdRef); }
}
Notice that the Book
constructor can be invoked either with an object reference slots.publisher
or with an ID reference slots.publisherIdRef
.
2. Encode the property checks
Take care that all constraints of a property as specified in the JavaScript data model are properly encoded in its check function, as explained in Part 2 (Validation Tutorial). Error classes are defined in the file lib/errorTypes.js
.
For instance, for the checkName
operation we obtain the following code:
Publisher.checkName = function (n) {
if (!n) {
return new NoConstraintViolation();
} else if (typeof(n) !== "string" || n.trim() === "") {
return new TypeConstraintViolation(
"The name must be a non-empty string!");
} else {
return new NoConstraintViolation();
}
};
Notice that, since the name
attribute is the standard ID attribute of Publisher
, we only check syntactic constraints in checkName
, and check the mandatory value and uniqueness constraints in checkNameAsId
, which invokes checkName
:
Publisher.checkNameAsId = function (n) {
var constraintViolation = Publisher.checkName( n);
if ((constraintViolation instanceof NoConstraintViolation)) {
if (!n) {
return new MandatoryValueConstraintViolation(
"A value for the name must be provided!");
} else if (Publisher.instances[n]) {
constraintViolation = new UniquenessConstraintViolation(
"There is already a publisher record with this name!");
} else {
constraintViolation = new NoConstraintViolation();
}
}
return constraintViolation;
};
Since for any standard ID attribute, we may have to deal with ID references (foreign keys) in other classes, we need to provide a further check function, called checkNameAsIdRef
, for checking the referential integrity constraint, as illustrated in the following example:
Publisher.checkNameAsIdRef = function (n) {
var constraintViolation = Publisher.checkName( n);
if ((constraintViolation instanceof NoConstraintViolation)) {
if (!Publisher.instances[n]) {
constraintViolation = new ReferentialIntegrityConstraintViolation(
"There is no publisher record with this name!");
}
}
return constraintViolation;
};
The condition (!Publisher.instances[n])
checks if there is no publisher object with the given name n
, and then creates a constraintViolation
object. This referential integrity constraint check is used by the following Book.checkPublisher
function:
Book.checkPublisher = function (publisherIdRef) {
var constraintViolation = null;
if (!publisherIdRef) {
constraintViolation = new NoConstraintViolation();
} else {
constraintViolation = Publisher.checkNameAsIdRef( publisherIdRef);
}
return constraintViolation;
};
3. Encode the property setters
Encode the setter operations as (instance-level) methods. In the setter, the corresponding check operation is invoked and the property is only set, if the check does not detect any constraint violation. In the case of a reference property, we allow invoking the setter either with an internal object reference or with an ID reference. The resulting ambiguity is resolved by testing if the argument provided in the invocation of the setter is an object or not. For instance, the setter operation setPublisher
is encoded in the following way:
Book.prototype.setPublisher = function (p) {
var constraintViolation = null;
var publisherIdRef = "";
if (typeof(p) !== "object") {
publisherIdRef = p;
} else {
publisherIdRef = p.name;
}
constraintViolation = Book.checkPublisher( publisherIdRef);
if (constraintViolation instanceof NoConstraintViolation) {
this.publisher = Publisher.instances[ publisherIdRef];
} else {
throw constraintViolation;
}
};
4. Implement a deletion policy
For any reference property, we have to choose and implement one of the two possible deletion policies discussed in 2 for managing the corresponding object destruction dependency in the destroy
method of the property's range class. In our case, we have to choose between
-
deleting all books published by the deleted publisher;
-
dropping from all books published by the deleted publisher the reference to the deleted publisher.
We go for the second option. This is shown in the following code of the Publisher.destroy
method where for all concerned book objects book
the property book.publisher
is cleared:
Publisher.destroy = function (name) {
var publisher = Publisher.instances[name];
var book=null, keys=[];
keys = Object.keys( Book.instances);
for (var i=0; i < keys.length; i++) {
book = Book.instances[keys[i]];
if (book.publisher === publisher) delete book.publisher;
}
delete Publisher.instances[name];
console.log("Publisher " + name + " deleted.");
};
5. Serialize and de-serialize
The serialization method convertObj2Row
converts typed objects with internal object references to corresponding (untyped) record objects with ID references:
Book.prototype.convertObj2Row = function () {
var bookRow = util.cloneObject(this), keys=[];
if (this.publisher) {
bookRow.publisherIdRef = this.publisher.name;
}
return bookRow;
};
The de-serialization method convertRow2Obj
converts (untyped) record objects with ID references to corresponding typed objects with internal object references:
Book.convertRow2Obj = function (bookRow) {
var book={}, persKey="";
var publisher = Publisher.instances[bookRow.publisherIdRef];
delete bookRow.publisherIdRef;
bookRow.publisher = publisher;
try {
book = new Book( bookRow);
} catch (e) {
console.log( e.name + " while deserializing a book row: " + e.message);
}
return book;
};
The View and Controller Layers
The user interface (UI) consists of a start page for navigating to the data management UI pages, one for each object type (in our example, books.html
and publishers.html
). Each of these data management UI pages contains 5 sections, such as manage books, list books, create book, update book and delete book, such that only one of them is displayed at any time (by setting the CSS property display:none
for all others).
For initializing the data management use cases, the required data (all publisher and book records) are loaded from persistent storage. This is performed in a controller procedure such as pl.ctrl.books.manage.initialize
in ctrl/books.js
with the following code:
pl.ctrl.books.manage = {
initialize: function () {
Publisher.loadAll();
Book.loadAll();
pl.view.books.manage.setUpUserInterface();
}
};
The initialize
method for managing book data loads the publisher table and the book table since the book data management UI needs to provide selection list for both object types. Then the menu for book data managemetn options is set up by the setUpUserInterface
method.
Show information about associated objects in the List Objects use case
In our example we have only one reference property, Book::publisher
, which is functional. For showing information about the optional publisher of a book in the list books use case, the corresponding cell in the HTML table is filled with the name of the publisher, if there is any:
pl.view.books.list = {
setupUserInterface: function () {
var tableBodyEl = document.querySelector(
"section#Book-R>table>tbody");
var keys = Object.keys( Book.instances);
var row=null, listEl=null, book=null;
tableBodyEl.innerHTML = "";
for (var i=0; i < keys.length; i++) {
book = Book.instances[keys[i]];
row = tableBodyEl.insertRow(-1);
row.insertCell(-1).textContent = book.isbn;
row.insertCell(-1).textContent = book.title;
row.insertCell(-1).textContent = book.year;
row.insertCell(-1).textContent =
book.publisher ? book.publisher.name : "";
}
document.getElementById("Book-M").style.display = "none";
document.getElementById("Book-R").style.display = "block";
}
};
For a multi-valued reference property, the table cell would have to be filled with a list of all associated objects referenced by the property.
Allow selecting associated objects in the create and update use cases
For allowing to select objects to be associated with the currently edited object from a list in the create and update use cases, an HTML selection list (a select
element) is populated with the instances of the associated object type with the help of a utility method fillSelectWithOptions
. In the case of the create book use case, the UI is set up by the following procedure:
pl.view.books.create = {
setupUserInterface: function () {
var formEl = document.querySelector("section#Book-C > form"),
publisherSelectEl = formEl.selectPublisher,
submitButton = formEl.commit;
formEl.isbn.addEventListener("input", function () {
formEl.isbn.setCustomValidity(
Book.checkIsbnAsId( formEl.isbn.value).message);
});
util.fillSelectWithOptions( publisherSelectEl, Publisher.instances, "name");
submitButton.addEventListener("click", this.handleSubmitButtonClickEvent);
formEl.addEventListener( 'submit', function (e) {
e.preventDefault();
formEl.reset();
});
document.getElementById("Book-M").style.display = "none";
document.getElementById("Book-C").style.display = "block";
formEl.reset();
},
handleSubmitButtonClickEvent: function () {
...
}
};
When the user clicks the submit button, all form control values, including the value of the select
control, are copied to a slots
list, which is used as the argument for invoking the add
method after all form fields have been checked for validity, as shown in the following program listing:
handleSubmitButtonClickEvent: function () {
var formEl = document.querySelector("section#Book-C > form");
var slots = {
isbn: formEl.isbn.value,
title: formEl.title.value,
year: formEl.year.value,
publisherIdRef: formEl.selectPublisher.value
};
formEl.isbn.setCustomValidity( Book.checkIsbnAsId( slots.isbn).message);
if (formEl.checkValidity()) {
Book.add( slots);
}
}
The setupUserInterface
code for the update book use case is similar.
Implementing Unidirectional Non-Functional Associations
A unidirectional non-functional association is either one-to-many or many-to-many. In both cases such an association is represented, or implemented, with the help of a multi-valued reference property.
In this chapter, we show
-
how to derive a JavaScript data model from an association-free information design model with multi-valued reference properties representing unidirectional non-functional associations,
-
how to encode the JavaScript data model in the form of JavaScript model classes,
-
how to write the view and controller code based on the model code.
A mulit-valued reference property, such as the property authors
of the object type Book
, allows storing a set of references to objects of some type, such as Author
. When creating a new object of type Book
, the constructor function needs to have a parameter for providing a suitable value for this reference property. In JavaScript we can allow this value to be a set (or list) of internal object references or of ID references, as shown in the following example:
function Book( slots) {
...
this.authors = {};
...
if (arguments.length > 0) {
...
this.setAuthors( slots.authors || slots.authorsIdRef);
...
}
}
Notice that the constructor parameter slots
is expected to contain either an authors
or an authorsIdRef
slot. The JavaScript expression slots.authors || slots.authorsIdRef
, using the disjunction operator ||
, evaluates to authors
, if slots
contains an object reference slot authors
, or to authorsIdRef
, otherwise. We handle the resulting ambiguity in the property setter by checking the type of the argument as shown in the following code fragment:
Book.prototype.setAuthors = function (a) {
var keys=[], i=0;
this.authors = {};
if (Array.isArray(a)) {
for (i= 0; i < a.length; i++) {
this.addAuthor( a[i]);
}
} else {
keys = Object.keys( a);
for (i=0; i < keys.length; i++) {
this.addAuthor( a[keys[i]]);
}
}
};
A multi-valued reference property can be implemented as a property with either an array, or a map, of object references, as its value. We prefer using maps for implementing multi-valued reference properties since they guarantee that each element is unique, while with an array we would have to prevent duplicate elements. Also, an element of a map can be easily deleted (with the help of the delete
operator), while this requires more effort in the case of an array. But for implementing list-valued reference properties, we need to use arrays.
We use the standard identifiers of the referenced objects as keys. If the standard identifier is an integer, we must take special care in converting ID values to strings for using them as keys.
Make a JavaScript Data Model
The starting point for making a JavaScript data model is an information design model where explicit associations have been replaced with reference properties. The following model for our example app contains the multi-valued reference property Book::authors
, which represents the unidirectional many-to-many association Book-has-Author:
We now show how to derive a JavaScript data model from this design model in three steps.
-
Create a check operation for each non-derived property in order to have a central place for implementing property constraints. For any reference property, no matter if single-valued (like Book::publisher
) or multi-valued (like Book::authors
), the check operation (checkPublisher
or checkAuthor
) has to check the corresponding referential integrity constraint, which requires that all references reference an existing object, and possibly also a mandatory value constraint, if the property is mandatory.
-
Create a set operation for each non-derived single-valued property. In the setter, the corresponding check operation is invoked and the property is only set, if the check does not detect any constraint violation.
-
Create an add, a remove and a set operation for each non-derived multi-valued property. In the case of the Book::authors
property, we would create the operations addAuthor
, removeAuthor
and setAuthors
in the Book
class rectangle.
This leads to the following JavaScript data model, where we only show the classes Book
and Author
, while the missing class Publisher
is the same as before:
Notice that, for simplicity, we do not include the code for all validation checks shown in the data model in the code of the example app.
New issues
Compared to dealing with a unidirectional functional association, as discussed in the previous chapter, we have to deal with the following new technical issues:
-
In the model code we now have to take care of multi-valued reference properties that require
-
implementing an add and a remove operation, as well as a setter for assigning a set of references with the help of the add operation.
-
converting a map of internal object references to an array of ID references in the serialization function convertObj2Row
and converting such an array back to a map of internal object references in the de-serialization function convertRow2Obj
.
-
In the user interface ("view") code we now have to take care of
-
showing information about a set of associated objects in the list objects use case;
-
allowing to select a set of associated objects from a list of all existing instances of the target class in the create object and update object use cases.
The last issue, allowing to select a set of associated objects from a list of all existing instances of some class, can, in general, not be solved with the help of an HTML select multiple
form element because of usability problems. Whenever the set of selectable options is greater than a certain threshold (defined by the number of options that can be seen on the screen without scrolling), the HTML select multiple
element is no longer usable, and an alternative multi-selection widget has to be used.
Write the Model Code
1. Encode the add and remove operations
For the multi-valued reference property Book::authors
, we need to encode the operations addAuthor
and removeAuthor
. Both operations accept one parameter denoting an author either by ID reference (the author ID as integer or string) or by an internal object reference. The code of addAuthor
is as follows:
Book.prototype.addAuthor = function (a) {
var constraintViolation=null,
authorIdRef=0, authorIdRefStr="";
if (typeof( a) !== "object") {
authorIdRef = parseInt( a);
} else {
authorIdRef = a.authorId;
}
constraintViolation = Book.checkAuthor( authorIdRef);
if (authorIdRef &&
constraintViolation instanceof NoConstraintViolation) {
authorIdRefStr = String( authorIdRef);
this.authors[ authorIdRefStr] =
Author.instances[ authorIdRefStr];
}
};
The code of removeAuthor
is similar to addAuthor
:
Book.prototype.removeAuthor = function (a) {
var constraintViolation = null;
var authorIdRef = "";
if (typeof(a) !== "object") authorIdRef = parseInt( a);
else authorIdRef = a.authorId;
constraintViolation = Book.checkAuthor( authorIdRef);
if (constraintViolation instanceof NoConstraintViolation) {
delete this.authors[ authorIdRef];
}
};
For assigning an array of ID references, or a map of object references, to the property Book::authors
, the method setAuthors
adds them one by one with the help of addAuthor
:
Book.prototype.setAuthors = function (a) {
var keys=[];
this.authors = {};
if (Array.isArray(a)) {
for (i= 0; i < a.length; i++) {
this.addAuthor( a[i]);
}
} else {
keys = Object.keys( a);
for (i=0; i < keys.length; i++) {
this.addAuthor( a[keys[i]]);
}
}
};
2. Implement a deletion policy
For the reference property Book::authors
, we have to implement a deletion policy in the destroy
method of the Author
class. We have to choose between
-
deleting all books (co-)authored by the deleted author;
-
dropping from all books (co-)authored by the deleted author the reference to the deleted author.
We go for the second option. This is shown in the following code of the Author.destroy
method where for all concerned book objects book
the author reference book.authors[authorKey]
is dropped:
Author.destroy = function (id) {
var authorKey = id.toString(),
author = Author.instances[authorKey],
key="", keys=[], book=null;
keys = Object.keys( Book.instances);
for (i=0; i < keys.length; i++) {
key = keys[i];
book = Book.instances[key];
if (book.authors[authorKey]) delete book.authors[authorKey];
}
delete Author.instances[authorKey];
console.log("Author " + author.name + " deleted.");
};
3. Serialize and de-serialize
The serialization method convertObj2Row
converts typed objects with internal object references to corresponding (untyped) record objects with ID references:
Book.prototype.convertObj2Row = function () {
var bookRow = util.cloneObject(this), keys=[];
bookRow.authorsIdRef = [];
keys = Object.keys( this.authors);
for (i=0; i < keys.length; i++) {
bookRow.authorsIdRef.push( parseInt( keys[i]));
}
if (this.publisher) {
bookRow.publisherIdRef = this.publisher.name;
}
return bookRow;
};
The de-serialization method convertRow2Obj
converts (untyped) record objects with ID references to corresponding typed objects with internal object references:
Book.convertRow2Obj = function (bookRow) {
var book=null, authorKey="",
publisher = Publisher.instances[bookRow.publisherIdRef];
bookRow.authors = {};
for (i=0; i < bookRow.authorsIdRef.length; i++) {
authorKey = bookRow.authorsIdRef[i].toString();
bookRow.authors[authorKey] = Author.instances[authorKey];
}
delete bookRow.authorsIdRef;
delete bookRow.publisherIdRef;
bookRow.publisher = publisher;
try {
book = new Book( bookRow);
} catch (e) {
console.log( e.constructor.name +
" while deserializing a book row: " + e.message);
}
return book;
};
Write the User Interface Code
Show information about associated objects in the List Objects use case
For showing information about the authors of a book in the list books use case, the corresponding cell in the HTML table is filled with a list of the names of all authors with the help of the utility function util.createListFromMap
:
pl.view.books.list = {
setupUserInterface: function () {
var tableBodyEl = document.querySelector(
"section#Book-R>table>tbody");
var row=null, book=null, listEl=null,
keys = Object.keys( Book.instances);
tableBodyEl.innerHTML = "";
for (i=0; i < keys.length; i++) {
book = Book.instances[keys[i]];
row = tableBodyEl.insertRow(-1);
row.insertCell(-1).textContent = book.isbn;
row.insertCell(-1).textContent = book.title;
row.insertCell(-1).textContent = book.year;
listEl = util.createListFromMap(
book.authors, "name");
row.insertCell(-1).appendChild( listEl);
row.insertCell(-1).textContent =
book.publisher ? book.publisher.name : "";
}
document.getElementById("Book-M").style.display = "none";
document.getElementById("Book-R").style.display = "block";
}
};
The utility function util.createListFromMap
has the following code:
createListFromMap: function (aa, displayProp) {
var listEl = document.createElement("ul");
util.fillListFromMap( listEl, aa, displayProp);
return listEl;
},
fillListFromMap: function (listEl, aa, displayProp) {
var keys=[], listItemEl=null;
listEl.innerHTML = "";
keys = Object.keys( aa);
for (var j=0; j < keys.length; j++) {
listItemEl = document.createElement("li");
listItemEl.textContent = aa[keys[j]][displayProp];
listEl.appendChild( listItemEl);
}
}
Allow selecting associated objects in the create use cases
For allowing to select multiple authors to be associated with the currently edited book in the create book use case, a multiple selection list (a select
element with multiple="multiple"
), as shown in the HTML code below, is populated with the instances of the associated object type.
<section id="Book-C" class="UI-Page">
<h1>Public Library: Create a new book record</h1>
<form>
<div class="field">
<label>ISBN: <input type="text" name="isbn" /></label>
</div>
<div class="field">
<label>Title: <input type="text" name="title" /></label>
</div>
<div class="field">
<label>Year: <input type="text" name="year" /></label>
</div>
<div class="select-one">
<label>Publisher: <select name="selectPublisher"></select></label>
</div>
<div class="select-many">
<label>Authors:
<select name="selectAuthors" multiple="multiple"></select>
</label>
</div>
<div class="button-group">
<button type="submit" name="commit">Save</button>
<button type="button" onclick="pl.view.books.manage.refreshUI()">
Back to menu</button>
</div>
</form>
</section>
The create book UI is set up by populating selection lists for selecting the authors and the publisher with the help of a utility method fillSelectWithOptions
as shown in the following program listing:
pl.view.books.create = {
setupUserInterface: function () {
var formEl = document.querySelector("section#Book-C > form"),
publisherSelectEl = formEl.selectPublisher,
submitButton = formEl.commit;
...
util.fillSelectWithOptions( authorsSelectEl,
Author.instances, "authorId", {displayProp:"name"});
util.fillSelectWithOptions( publisherSelectEl,
Publisher.instances, "name");
...
},
handleSubmitButtonClickEvent: function () {
...
}
};
When the user clicks the submit button, all form control values, including the value of any single-select
control, are copied to a corresponding slots
record variable, which is used as the argument for invoking the add
method after all form fields have been checked for validity. Before invoking add
, we first have to create (in the authorsIdRef
slot) a list of author ID references from the selected options of the multiple author selection list, as shown in the following program listing:
handleSubmitButtonClickEvent: function () {
var i=0,
formEl = document.querySelector("section#Book-C > form"),
selectedAuthorsOptions = formEl.selectAuthors.selectedOptions;
var slots = {
isbn: formEl.isbn.value,
title: formEl.title.value,
year: formEl.year.value,
authorsIdRef: [],
publisherIdRef: formEl.selectPublisher.value
};
...
if (formEl.checkValidity()) {
for (i=0; i < selectedAuthorsOptions.length; i++) {
slots.authorsIdRef.push( selectedAuthorsOptions[i].value);
}
Book.add( slots);
}
}
The update book use case is discussed in the next section.
Allow selecting associated objects in the update use cases
Unfortunatley, the multiple-select
control is not really usable for displaying and allowig to maintain the set of associated authors in realistic use cases where we have several hundreds or thousands of authors, because the way it renders the choice is visually too scattered. So we better use a special association list widget that allows to add (and remove) objects to (and from) a list of associated objects, as discussed in 8. In order to show how this widget can replace the multiple-selection list discussed in the previous section, we use it now in the update book use case.
For allowing to maintain the set of authors associated with the currently edited book in the update book use case, an association list widget as shown in the HTML code below, is populated with the instances of the Author
class.
<section id="Book-U" class="UI-Page">
<h1>Public Library: Update a book record</h1>
<form>
<div class="select-one">
<label>Select book: <select name="selectBook"></select></label>
</div>
<div class="field">
<label>ISBN: <output name="isbn"></output></label>
</div>
<div class="field">
<label>Title: <input type="text" name="title" /></label>
</div>
<div class="field">
<label>Year: <input type="text" name="year" /></label>
</div>
<div class="select-one">
<label>Publisher: <select name="selectPublisher"></select></label>
</div>
<div class="widget">
<label for="updBookSelectAuthors">Authors: </label>
<div class="MultiSelectionWidget" id="updBookSelectAuthors"></div>
</div>
<div class="button-group">
<button type="submit" name="commit">Save</button>
<button type="button"
onclick="pl.view.books.manage.refreshUI()">Back to menu</button>
</div>
</form>
</section>
The update book UI is set up (in the setupUserInterface
procedure shown below) by populating
-
the selection list for selecting the book to be updated with the help of the utility method fillSelectWithOptions
, and
-
the selection list for updating the publisher with the help of the utility method fillSelectWithOptions
,
while the association list widget for updating the associated authors of the book is only populated (in handleSubmitButtonClickEvent
) when a book to be updated has been chosen.
pl.view.books.update = {
setupUserInterface: function () {
var formEl = document.querySelector("section#Book-U > form"),
bookSelectEl = formEl.selectBook,
publisherSelectEl = formEl.selectPublisher,
submitButton = formEl.commit;
util.fillSelectWithOptions( bookSelectEl, Book.instances,
"isbn", {displayProp:"title"});
bookSelectEl.addEventListener("change", this.handleBookSelectChangeEvent);
...
util.fillSelectWithOptions( publisherSelectEl, Publisher.instances, "name");
submitButton.addEventListener("click", this.handleSubmitButtonClickEvent);
formEl.addEventListener( 'submit', function (e) {
var authorsSelWidget = document.querySelector(
"section#Book-U > form .MultiSelectionWidget");
e.preventDefault();
authorsSelWidget.innerHTML = "";
formEl.reset();
});
document.getElementById("Book-M").style.display = "none";
document.getElementById("Book-U").style.display = "block";
formEl.reset();
},
When a book to be updated has been chosen, the form input fields isbn
, title
and year
, and the select
control for updating the publisher, are assigned corresponding values from the chosen book, and the associated authors selection widget is set up:
handleBookSelectChangeEvent: function () {
var formEl = document.querySelector("section#Book-U > form"),
authorsSelWidget = formEl.querySelector(
".MultiSelectionWidget"),
key = formEl.selectBook.value,
book=null;
if (key !== "") {
book = Book.instances[key];
formEl.isbn.value = book.isbn;
formEl.title.value = book.title;
formEl.year.value = book.year;
util.createMultiSelectionWidget( authorsSelWidget,
book.authors, Author.instances, "authorId", "name");
formEl.selectPublisher.selectedIndex =
(book.publisher) ? book.publisher.index : 0;
} else {
formEl.reset();
formEl.selectPublisher.selectedIndex = 0;
}
},
When the user, after updating some values, finally clicks the submit button, all form control values, including the value of the single-select
control for assigning a publisher, are copied to corresponding slots in a slots
record variable, which is used as the argument for invoking the update
method after all values have been checked for validity. Before invoking update
, a list of ID references to authors to be added, and another list of ID references to authors to be removed, is created (in the authorsIdRefToAdd
and authorsIdRefToRemove
slots) from the updates that have been recorded in the associated authors selection widget with the help of classList
values, as shown in the following program listing:
handleSubmitButtonClickEvent: function () {
var i=0, assocAuthorListItemEl=null,
authorsIdRefToAdd=[], authorsIdRefToRemove=[],
formEl = document.querySelector("section#Book-U > form"),
authorsSelWidget =
formEl.querySelector(".MultiSelectionWidget"),
authorsAssocListEl = authorsSelWidget.firstElementChild;
var slots = { isbn: formEl.isbn.value,
title: formEl.title.value,
year: formEl.year.value,
publisherIdRef: formEl.selectPublisher.value
};
if (formEl.checkValidity()) {
for (i=0; i < authorsAssocListEl.children.length; i++) {
assocAuthorListItemEl = authorsAssocListEl.children[i];
if (assocAuthorListItemEl.classList.contains("removed")) {
authorsIdRefToRemove.push(
assocAuthorListItemEl.getAttribute("data-value"));
}
if (assocAuthorListItemEl.classList.contains("added")) {
authorsIdRefToAdd.push(
assocAuthorListItemEl.getAttribute("data-value"));
}
}
if (authorsIdRefToRemove.length > 0) {
slots.authorsIdRefToRemove = authorsIdRefToRemove;
}
if (authorsIdRefToAdd.length > 0) {
slots.authorsIdRefToAdd = authorsIdRefToAdd;
}
Book.update( slots);
}
}
Run the App
You can run the example app from our server.
Possible Variations and Extensions
Part-Whole Associations
A part-whole association is an association that represents a relationship between a part type and a whole type. Its instances are part-whole relationships between two objects where one of them is a part of the other. There are different kinds of part-whole associations, like aggregation and composition, which have been defined in UML. Aggregation and composition are explained in a stackoverflow answer where it is pointed out that it is a widespread misunderstandeing that composition implies a lifecycle dependency between the whole and its parts such that the parts cannot exist without the whole.
We plane to write a follow-up tutorial that covers part-whole associations.
Points of Attention
Notice that in this tutorial, we have made the assumption that all application data can be loaded into main memory (like all book data is loaded into the map Book.instances
). This approach only works in the case of local data storage of smaller databases, say, with not more than 2 MB of data, roughgly corresponding to 10 tables with an average population of 1000 rows, each having an average size of 200 Bytes. When larger databases are to be managed, or when data is stored remotely, it's no longer possible to load the entire population of all tables into main memory, but we have to use a technique where only parts of the table contents are loaded.
We have still included the repetitive code structures (called boilerplate code) in the model layer per class and per property for constraint validation (checks and setters) and per class for the data storage management methods add
, update
, and destroy
. While it is good to write this code a few times for learning app development, you don't want to write it again and again later when you work on real projects. In Part 6 of our tutorial series, we will present an approach how to put these methods in a generic form in a meta-class called mODELcLASS
, such that they can be reused in all model classes of an app.
History
- 25 February 2015, document created.