For a while now I've been meaning to write up a quick demo of adding pagination to a site using IndexedDB, and today I finally had some time to create an example. I'm not sure this is the best example of course, but hopefully it can help someone, and if folks have a better solution, please let me know in the comments. In order for this to make any sense, you'll need some basic knowledge of how IDB (IndexedDB) works in general. You can pick up my book or video on client-side storage (both of which cost money) or read the extremely well done (and free) documentation at MozDevNet: Using IndexedDB
I began by building an initial demo that would handle creating seed data and simply displaying all the data. I wanted that done first so I could then "upgrade" it with pagination. I began by building a simple front end. I've got a table, pagination buttons, and that's it.
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title></title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width">
<link rel="stylesheet" href="app.css">
</head>
<body>
<h2>Cats</h2>
<table id="catTable">
<thead>
<tr>
<th>Name</th>
<th>Breed</th>
<th>Color</th>
<th>Age</th>
</tr>
</thead>
<tbody></tbody>
</table>
<!-- pagination -->
<p>
<button id="backButton" disabled>Back</button>
<button id="backButton" disabled>Forward</button>
</p>
<script src="app.js"></script>
</body>
</html>
The only thing really interesting here is that I ensure my pagination buttons are disabled by default. We'll only enable them if necessary, and not till the next version. Now let's look at app.js.
//global handler for the IDB
var db;
//current position
var position = 0;
document.addEventListener('DOMContentLoaded', init, false);
function init() {
console.log('page init');
dbSetup().then(function() {
console.log('db is setup');
displayData();
}).catch(function(e) {
console.log('I had an issue making the db: '+e);
});
}
function dbSetup() {
var p = new Promise(function(resolve, reject) {
var req = window.indexedDB.open('page_test', 1);
req.onupgradeneeded = function(e) {
var thedb = e.target.result;
var os = thedb.createObjectStore("cats", { autoIncrement:true});
os.createIndex("name", "name", {unique:false});
os.createIndex("age","age", {unique:false});
};
req.onsuccess = function(e) {
db = e.target.result;
resolve();
};
req.onerror = function(e) {
reject(e);
};
});
return p;
}
function displayData() {
getData().then(function(cats) {
var s = '';
cats.forEach(function(cat) {
s += `
<tr>
<td>${cat.name}</td>
<td>${cat.breed}</td>
<td>${cat.color}</td>
<td>${cat.age}</td>
</tr>`;
});
document.querySelector('table#catTable tbody').innerHTML = s;
console.log('got cats');
});
}
function getData() {
var p = new Promise(function(resolve, reject) {
var t = db.transaction(['cats'],'readonly');
var catos = t.objectStore('cats');
var cats = [];
catos.openCursor().onsuccess = function(e) {
var cursor = e.target.result;
if(cursor) {
cats.push(cursor.value);
cursor.continue();
} else {
resolve(cats);
}
};
});
return p;
}
/*
there is no call to this, as it is a one time/test type thing.
*/
function seedData() {
var getRandomInt = function(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
};
var randomName = function() {
var initialParts = ["Fluffy","Scruffy","King","Queen","Emperor","Lord","Hairy","Smelly","Most Exalted Knight","Crazy","Silly","Dumb","Brave","Sir","Fatty","Poopy","Scared","Old","Kid"];
var lastParts = ["Sam","Smoe","Elvira","Jacob","Lynn","Fufflepants the III","Squarehead","Redshirt","Titan","Kitten Zombie","Dumpster Fire","Butterfly Wings","Unicorn Rider"];
return initialParts[getRandomInt(0, initialParts.length-1)] + ' ' + lastParts[getRandomInt(0, lastParts.length-1)];
};
var randomColor = function() {
var colors = ["Red","Blue","Green","Yellow","Rainbow","White","Black","Invisible","Plaid","Angry"];
return colors[getRandomInt(0, colors.length-1)];
};
var randomGender = function() {
var genders = ["Male","Female"];
return genders[getRandomInt(0, genders.length-1)];
};
var randomAge = function() {
return getRandomInt(1, 15);
};
function randomBreed() {
var breeds = ["American Shorthair","Abyssinian","American Curl","American Wirehair","Bengal","Chartreux","Devon Rex","Maine Coon","Manx","Persian","Siamese"];
return breeds[getRandomInt(0, breeds.length-1)];
}
//make 25 cats
var cats = [];
for(var i=0;i<25;i++) {
var cat = {
name:randomName(),
color:randomColor(),
gender:randomGender(),
age:randomAge(),
breed:randomBreed()
};
cats.push(cat);
}
var catStore = db.transaction(['cats'], 'readwrite').objectStore('cats');
cats.forEach(function(cat) {
catStore.put(cat);
console.log('I just stored a cat.');
});
}
Ok, there's quite a bit going on here, but let's take it top to bottom. I begin by creating my IDB database, "page_test". My object store is called cats and I've made two indexes on it. I didn't end up using those indexes, but they made sense to me so I added them anyway.
displayData
handles calling off to getData
and then rendering the cats. Again, it should all pretty straightforward, but let me know if not. getData
opens up a cursor to iterate over the object store and simply create an array of cats. A "Cat Array" if you will.
As an example, here is an array of cats, size three (credit Michelle Gabriel):
seedData
is not actually called by any function. I opened up my console and simply ran it there. Yes, it is a lot of code just to generate fake data, but I had this built already for a Node.js app I wrote for work ("Building JavaScript Charts Powered by LoopBack"). Yes, that was real work. Why are you laughing at me?
So the end result, after manually running seedData
, will be a table of cat data:
So that worked. This version of the code may be found in the v1
folder in the zip attached to the article. Now let's discuss pagination.
Pagination can be achieved by using two methods. First, we need to determine the size of our data set. Luckily there's a count
method you can use on an object store.
function countData() {
return new Promise(function(resolve, reject) {
db.transaction(['cats'],'readonly').objectStore('cats').count().onsuccess = function(e) {
resolve(e.target.result);
};
});
}
The next thing we need is a way to get one "page" of data. That is possible by using the advance
method of the cursor object. This is a bit tricky. The MozDevNet example illustrates going through an entire object store and skipping over two rows on every iteration. What we want is something different - do an initial skip to the beginning of our page and then end where it makes sense. Here is the updated version of the code with this in action. (I left out seedData
.)
//global handler for the IDB
var db;
//current position for paging
var position = 0;
//total number of cats
var totalCats;
//dom items for prev/next buttons, not using jQuery but like the syntax
var $prev, $next;
//how many per page?
var page = 10;
document.addEventListener('DOMContentLoaded', init, false);
function init() {
console.log('page init');
$prev = document.querySelector("#backButton");
$next = document.querySelector("#nextButton");
$prev.addEventListener('click', move);
$next.addEventListener('click', move);
dbSetup().then(function() {
console.log('db is setup');
countData().then(function(result) {
totalCats = result;
displayData();
});
}).catch(function(e) {
console.log('I had an issue making the db: '+e);
});
}
function dbSetup() {
return new Promise(function(resolve, reject) {
var req = window.indexedDB.open('page_test', 1);
req.onupgradeneeded = function(e) {
var thedb = e.target.result;
var os = thedb.createObjectStore("cats", { autoIncrement:true});
os.createIndex("name", "name", {unique:false});
os.createIndex("age","age", {unique:false});
};
req.onsuccess = function(e) {
db = e.target.result;
resolve();
};
req.onerror = function(e) {
reject(e);
};
});
}
function countData() {
return new Promise(function(resolve, reject) {
db.transaction(['cats'],'readonly').objectStore('cats').count().onsuccess = function(e) {
resolve(e.target.result);
};
});
}
function displayData() {
getData(position,page).then(function(cats) {
var s = '';
cats.forEach(function(cat) {
s += `
<tr>
<td>${cat.name}</td>
<td>${cat.breed}</td>
<td>${cat.color}</td>
<td>${cat.age}</td>
</tr>`;
});
document.querySelector('table#catTable tbody').innerHTML = s;
console.log('got cats');
/*
so do we show/hide prev and next?
*/
if(position > 0) {
console.log('enable back');
$prev.removeAttribute('disabled');
} else {
$prev.setAttribute('disabled', 'disabled');
}
if(position + page < totalCats) {
console.log('enable next');
$next.removeAttribute('disabled');
} else {
$next.setAttribute('disabled', 'disabled');
}
});
}
function move(e) {
if(e.target.id === 'nextButton') {
position += page;
displayData();
} else {
position -= page;
displayData();
}
}
function getData(start,total) {
return new Promise(function(resolve, reject) {
var t = db.transaction(['cats'],'readonly');
var catos = t.objectStore('cats');
var cats = [];
console.log('start='+start+' total='+total);
var hasSkipped = false;
catos.openCursor().onsuccess = function(e) {
var cursor = e.target.result;
if(!hasSkipped && start > 0) {
hasSkipped = true;
cursor.advance(start);
return;
}
if(cursor) {
console.log('pushing ',cursor.value);
cats.push(cursor.value);
if(cats.length < total) {
cursor.continue();
} else {
resolve(cats);
}
} else {
console.log('resolving ',cats);
resolve(cats);
}
};
});
}
Ok, so again, let's take it top to bottom, and I'll focus on the changes. I've got a few new variables to help me with paging, including position
and totalCats
. My init
method now calls countData
first and then runs displayData
. This way I know, at the beginning, how big my dataset is.
displayData
now checks that count as well as the current position and determines if the navigation buttons should be enabled. These buttons both use a move
event that handles changing the value of position
and rerunning our display.
getData
has been updated to handle a start value and a total. In order to handle beginning at the right value, we still open a cursor as before, but on the first call we use advance
to set our starting position. The other change is to recognize when we have as many cats as we need (NEVER ENOUGH CATS!) and end iterating early if so. And here is the end result.
It seems to work well. Have any ideas for improvements?