Hello, and welcome to the third part of my series on creating an iOS application with Cordova. In Part 1, we covered the basics of planning our book cataloguing app, discussing the importance of user experience design and sketching some preliminary UI ideas. Part 2 walked through UI prototyping with Ratchet to create a solid foundation for our application. Now it's time to get into the meaty part – development!
In this post, I'll focus on using Behavior-Driven Development (BDD) to implement our app's functionality. As promised in the previous posts, we'll use Jasmine to write our tests first, then implement the code that makes those tests pass. This approach helps ensure our book cataloguing app is robust and maintainable from the start.
Why BDD?
Before diving into code, let's quickly talk about why I'm using BDD for this project. BDD focuses on the behavior of an application from the outside-in. It's a great way to ensure that you're building something that actually meets the requirements, rather than getting lost in implementation details.
The basic workflow is:
- Write a failing test that describes the expected behavior
- Implement just enough code to make the test pass
- Refactor your code while keeping tests green
- Repeat
This cycle keeps you focused on implementing only what's needed, and gives you the confidence to refactor your code without breaking functionality.
Setting Up Jasmine
First, let's set up Jasmine in our Cordova project. If you've been following along with the previous posts, you should already have a basic Cordova project set up with Bower for managing our front-end dependencies. If not, head back to Part 2 to get started.
Remember that we installed Jasmine using Bower in Part 2:
bower install --save jasmine
Next, let's create a basic structure for our tests. In your project's root directory, create a spec
folder:
mkdir -p spec/javascripts
Now we need to set up our test runner. Create a file called SpecRunner.html
in your project's root directory:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Jasmine Spec Runner</title>
<!-- Jasmine CSS -->
<link rel="stylesheet" href="bower_components/jasmine/lib/jasmine-core/jasmine.css">
<!-- Jasmine JS -->
<script src="bower_components/jasmine/lib/jasmine-core/jasmine.js"></script>
<script src="bower_components/jasmine/lib/jasmine-core/jasmine-html.js"></script>
<script src="bower_components/jasmine/lib/jasmine-core/boot.js"></script>
<!-- App Source Files -->
<script src="js/app.js"></script>
<script src="js/services/storage.js"></script>
<script src="js/models/book.js"></script>
<script src="js/controllers/bookController.js"></script>
<!-- Specs -->
<script src="spec/javascripts/storageSpec.js"></script>
<script src="spec/javascripts/bookSpec.js"></script>
<script src="spec/javascripts/bookControllerSpec.js"></script>
</head>
<body>
</body>
</html>
You'll notice I've included a few JavaScript files that we haven't created yet. Don't worry – we'll create them as we go along, following the BDD process.
Our First Test: The Storage Service
Let's start by creating a storage service for our app. This service will handle storing and retrieving our book data locally on the device. First, let's write our test.
Create a file called storageSpec.js
in the spec/javascripts
directory:
describe('Storage Service', function() {
var storage;
beforeEach(function() {
// Clear local storage before each test
window.localStorage.clear();
// Create a new instance of the storage service
storage = new app.services.Storage();
});
it('should store and retrieve a value', function() {
// Store a value
storage.set('testKey', 'testValue');
// Retrieve the value
var value = storage.get('testKey');
// Assert that the value is correct
expect(value).toBe('testValue');
});
it('should store and retrieve an object', function() {
// Create a test object
var testObject = {
id: 1,
title: 'The Hobbit',
author: 'J.R.R. Tolkien'
};
// Store the object
storage.set('testObject', testObject);
// Retrieve the object
var retrievedObject = storage.get('testObject');
// Assert that the retrieved object is correct
expect(retrievedObject).toEqual(testObject);
});
it('should return null for a non-existent key', function() {
// Retrieve a value for a key that doesn't exist
var value = storage.get('nonExistentKey');
// Assert that the value is null
expect(value).toBeNull();
});
it('should remove a value', function() {
// Store a value
storage.set('testKey', 'testValue');
// Remove the value
storage.remove('testKey');
// Retrieve the value
var value = storage.get('testKey');
// Assert that the value is null
expect(value).toBeNull();
});
});
Now, if we try to run these tests, they'll fail because we haven't implemented the storage service yet. Let's create the necessary files.
First, create a basic app structure in js/app.js
:
// Create the app namespace
var app = {
services: {},
models: {},
controllers: {}
};
Next, let's implement our storage service in js/services/storage.js
:
// Create the storage service
app.services.Storage = function() {
// Private methods
function isObject(value) {
return value && typeof value === 'object' && value.constructor === Object;
}
// Public methods
this.set = function(key, value) {
var valueToStore = value;
// If the value is an object, convert it to JSON
if (isObject(value)) {
valueToStore = JSON.stringify(value);
}
// Store the value in local storage
window.localStorage.setItem(key, valueToStore);
};
this.get = function(key) {
// Get the value from local storage
var value = window.localStorage.getItem(key);
// If the value doesn't exist, return null
if (value === null) {
return null;
}
// Try to parse the value as JSON
try {
return JSON.parse(value);
} catch (e) {
// If it's not valid JSON, return the raw value
return value;
}
};
this.remove = function(key) {
// Remove the value from local storage
window.localStorage.removeItem(key);
};
};
If we run our tests now by opening SpecRunner.html
in a browser, they should pass! Our storage service is working as expected.
Creating the Book Model
Since we're building a book cataloguing app, we need a model to represent our books. Let's first write the tests for our Book model.
Create a file called bookSpec.js
in the spec/javascripts
directory:
describe('Book Model', function() {
it('should create a book with the given title and author', function() {
// Create a new book
var book = new app.models.Book('The Hobbit', 'J.R.R. Tolkien');
// Assert that the book has the correct properties
expect(book.title).toBe('The Hobbit');
expect(book.author).toBe('J.R.R. Tolkien');
});
it('should generate a unique ID', function() {
// Create two books
var book1 = new app.models.Book('The Hobbit', 'J.R.R. Tolkien');
var book2 = new app.models.Book('The Fellowship of the Ring', 'J.R.R. Tolkien');
// Assert that the books have different IDs
expect(book1.id).toBeDefined();
expect(book2.id).toBeDefined();
expect(book1.id).not.toBe(book2.id);
});
it('should set default values for optional properties', function() {
// Create a new book
var book = new app.models.Book('The Hobbit', 'J.R.R. Tolkien');
// Assert that the book has the correct default properties
expect(book.status).toBe('to-read');
expect(book.startDate).toBeNull();
expect(book.endDate).toBeNull();
expect(book.readingTime).toBe(0);
});
it('should allow setting status', function() {
// Create a new book
var book = new app.models.Book('The Hobbit', 'J.R.R. Tolkien');
// Set the status
book.setStatus('reading');
// Assert that the status was updated
expect(book.status).toBe('reading');
// Setting the status to 'reading' should set the start date
expect(book.startDate).not.toBeNull();
// Set the status to 'read'
book.setStatus('read');
// Assert that the status was updated
expect(book.status).toBe('read');
// Setting the status to 'read' should set the end date
expect(book.endDate).not.toBeNull();
});
it('should calculate reading time', function() {
// Create a new book
var book = new app.models.Book('The Hobbit', 'J.R.R. Tolkien');
// Set some dates
book.startDate = new Date(2024, 0, 1); // January 1, 2024
book.endDate = new Date(2024, 0, 5); // January 5, 2024
// Calculate reading time
book.calculateReadingTime();
// Assert that the reading time is correct (4 days)
expect(book.readingTime).toBe(4);
});
});
Now let's implement the Book model in js/models/book.js
:
// Create the Book model
app.models.Book = function(title, author) {
// Generate a unique ID
this.id = Math.random().toString(36).substr(2, 9);
// Set book properties
this.title = title;
this.author = author;
this.status = 'to-read'; // Options: 'to-read', 'reading', 'read'
this.startDate = null;
this.endDate = null;
this.readingTime = 0; // In days
// Set the book status and update dates accordingly
this.setStatus = function(status) {
this.status = status;
if (status === 'reading' && !this.startDate) {
this.startDate = new Date();
} else if (status === 'read' && !this.endDate) {
this.endDate = new Date();
this.calculateReadingTime();
}
};
// Calculate reading time in days
this.calculateReadingTime = function() {
if (this.startDate && this.endDate) {
var timeDiff = this.endDate.getTime() - this.startDate.getTime();
var daysDiff = Math.floor(timeDiff / (1000 * 3600 * 24));
this.readingTime = daysDiff;
}
};
};
If we run our tests now, they should pass for the Book model!
Implementing the Book Controller
Now let's create a controller for managing our book collection. As always, we'll start with the tests.
Create a file called bookControllerSpec.js
in the spec/javascripts
directory:
describe('Book Controller', function() {
var controller;
var storage;
beforeEach(function() {
// Clear local storage before each test
window.localStorage.clear();
// Create a new instance of the storage service
storage = new app.services.Storage();
// Create a new instance of the book controller
controller = new app.controllers.BookController(storage);
});
describe('Adding books', function() {
it('should add a new book', function() {
// Add a new book
var book = controller.addBook('The Hobbit', 'J.R.R. Tolkien');
// Assert that the book has the correct properties
expect(book.id).toBeDefined();
expect(book.title).toBe('The Hobbit');
expect(book.author).toBe('J.R.R. Tolkien');
});
it('should store the book in storage', function() {
// Add a new book
var book = controller.addBook('The Hobbit', 'J.R.R. Tolkien');
// Get all books from storage
var books = storage.get('books');
// Assert that the book was stored
expect(books).toBeDefined();
expect(books.length).toBe(1);
expect(books[0].id).toBe(book.id);
expect(books[0].title).toBe('The Hobbit');
});
it('should not add a book with an empty title', function() {
// Try to add a book with an empty title
var book = controller.addBook('', 'J.R.R. Tolkien');
// Assert that no book was added
expect(book).toBeNull();
// Get all books from storage
var books = storage.get('books');
// Assert that no book was stored
expect(books).toBeNull();
});
});
describe('Getting books', function() {
beforeEach(function() {
// Add some test books
controller.addBook('The Hobbit', 'J.R.R. Tolkien');
controller.addBook('The Fellowship of the Ring', 'J.R.R. Tolkien');
controller.addBook('The Two Towers', 'J.R.R. Tolkien');
});
it('should return all books', function() {
// Get all books
var books = controller.getBooks();
// Assert that all books were returned
expect(books.length).toBe(3);
expect(books[0].title).toBe('The Hobbit');
expect(books[1].title).toBe('The Fellowship of the Ring');
expect(books[2].title).toBe('The Two Towers');
});
it('should return books filtered by status', function() {
// Update the status of some books
var book = controller.getBooks()[0];
controller.updateBookStatus(book.id, 'reading');
// Get books by status
var toReadBooks = controller.getBooksByStatus('to-read');
var readingBooks = controller.getBooksByStatus('reading');
// Assert that the filtered books are correct
expect(toReadBooks.length).toBe(2);
expect(readingBooks.length).toBe(1);
expect(readingBooks[0].id).toBe(book.id);
});
it('should return an empty array if there are no books', function() {
// Clear the books
storage.remove('books');
// Get all books
var books = controller.getBooks();
// Assert that an empty array was returned
expect(books).toEqual([]);
});
});
describe('Updating books', function() {
var book;
beforeEach(function() {
// Add a test book
book = controller.addBook('The Hobbit', 'J.R.R. Tolkien');
});
it('should update a book status', function() {
// Update the book status
var updatedBook = controller.updateBookStatus(book.id, 'reading');
// Assert that the book was updated
expect(updatedBook.id).toBe(book.id);
expect(updatedBook.status).toBe('reading');
expect(updatedBook.startDate).not.toBeNull();
// Get all books
var books = controller.getBooks();
// Assert that the book was updated in storage
expect(books.length).toBe(1);
expect(books[0].status).toBe('reading');
expect(books[0].startDate).not.toBeNull();
});
it('should return null if the book does not exist', function() {
// Try to update a non-existent book
var updatedBook = controller.updateBookStatus('non-existent-id', 'reading');
// Assert that null was returned
expect(updatedBook).toBeNull();
});
});
describe('Removing books', function() {
var book;
beforeEach(function() {
// Add a test book
book = controller.addBook('The Hobbit', 'J.R.R. Tolkien');
});
it('should remove a book', function() {
// Remove the book
var result = controller.removeBook(book.id);
// Assert that the book was removed
expect(result).toBe(true);
// Get all books
var books = controller.getBooks();
// Assert that there are no books
expect(books.length).toBe(0);
});
it('should return false if the book does not exist', function() {
// Try to remove a non-existent book
var result = controller.removeBook('non-existent-id');
// Assert that false was returned
expect(result).toBe(false);
});
});
describe('Exporting books', function() {
beforeEach(function() {
// Add some test books
controller.addBook('The Hobbit', 'J.R.R. Tolkien');
controller.addBook('The Fellowship of the Ring', 'J.R.R. Tolkien');
var book = controller.addBook('The Two Towers', 'J.R.R. Tolkien');
// Set one book as reading
controller.updateBookStatus(book.id, 'reading');
});
it('should export books to JSON', function() {
// Export books to JSON
var json = controller.exportToJSON();
// Parse the JSON
var books = JSON.parse(json);
// Assert that the JSON contains all books
expect(books.length).toBe(3);
expect(books[0].title).toBe('The Hobbit');
expect(books[2].status).toBe('reading');
});
it('should export books to CSV', function() {
// Export books to CSV
var csv = controller.exportToCSV();
// Assert that the CSV is a string
expect(typeof csv).toBe('string');
// Assert that the CSV contains the header and all books
var lines = csv.split('\n');
expect(lines.length).toBe(4); // Header + 3 books
expect(lines[0]).toContain('title');
expect(lines[0]).toContain('author');
expect(lines[0]).toContain('status');
expect(lines[1]).toContain('The Hobbit');
expect(lines[3]).toContain('reading');
});
});
});
Now let's implement the Book Controller in js/controllers/bookController.js
:
// Create the Book Controller
app.controllers.BookController = function(storage) {
// Store a reference to the storage service
this.storage = storage;
// Add a new book
this.addBook = function(title, author) {
// Validate the title
if (!title || title.trim() === '') {
return null;
}
// Create a new book
var book = new app.models.Book(title, author);
// Get the current books or initialize an empty array
var books = this.storage.get('books') || [];
// Add the new book
books.push(book);
// Store the updated books
this.storage.set('books', books);
// Return the new book
return book;
};
// Get all books
this.getBooks = function() {
// Get the current books or initialize an empty array
return this.storage.get('books') || [];
};
// Get books filtered by status
this.getBooksByStatus = function(status) {
// Get all books
var books = this.getBooks();
// Filter books by status
return books.filter(function(book) {
return book.status === status;
});
};
// Find a book by ID
this.findBook = function(id) {
// Get all books
var books = this.getBooks();
// Find the book
for (var i = 0; i < books.length; i++) {
if (books[i].id === id) {
return {
book: books[i],
index: i
};
}
}
// Return null if the book doesn't exist
return null;
};
// Update a book's status
this.updateBookStatus = function(id, status) {
// Find the book
var result = this.findBook(id);
// If the book doesn't exist, return null
if (!result) {
return null;
}
// Get all books
var books = this.getBooks();
// Update the book's status
result.book.setStatus(status);
// Store the updated books
this.storage.set('books', books);
// Return the updated book
return result.book;
};
// Remove a book
this.removeBook = function(id) {
// Find the book
var result = this.findBook(id);
// If the book doesn't exist, return false
if (!result) {
return false;
}
// Get all books
var books = this.getBooks();
// Remove the book
books.splice(result.index, 1);
// Store the updated books
this.storage.set('books', books);
// Return true to indicate success
return true;
};
// Export books to JSON
this.exportToJSON = function() {
// Get all books
var books = this.getBooks();
// Return the JSON string
return JSON.stringify(books);
};
// Export books to CSV
this.exportToCSV = function() {
// Get all books
var books = this.getBooks();
// Define the CSV header
var header = ['title', 'author', 'status', 'startDate', 'endDate', 'readingTime'];
// Convert each book to a CSV row
var rows = books.map(function(book) {
return [
'"' + book.title + '"',
'"' + book.author + '"',
'"' + book.status + '"',
'"' + (book.startDate ? book.startDate.toISOString() : '') + '"',
'"' + (book.endDate ? book.endDate.toISOString() : '') + '"',
'"' + book.readingTime + '"'
].join(',');
});
// Combine the header and rows
return [header.join(',')].concat(rows).join('\n');
};
};
If we run our tests now, they should all pass! We've successfully implemented our Book model and Controller.
Wiring Everything Up
Now that we have our storage service, book model, and book controller, let's update our index.html
file to use them. This file will serve as the entry point for our application.
First, let's create a basic structure in index.html
:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>ReadLog</title>
<!-- Sets initial viewport load and disables zooming -->
<meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">
<!-- Makes your prototype chrome-less once bookmarked to your phone's home screen -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<!-- Include the compiled Ratchet CSS -->
<link rel="stylesheet" href="bower_components/ratchet/dist/css/ratchet.min.css">
<link rel="stylesheet" href="bower_components/ratchet/dist/css/ratchet-theme-ios.min.css">
<!-- Include the compiled Ratchet JS -->
<script src="bower_components/ratchet/dist/js/ratchet.min.js"></script>
</head>
<body>
<!-- Make sure all your bars are the first things in your <body> -->
<header class="bar bar-nav">
<a href="#add-book-modal" class="icon icon-compose pull-right"></a>
<h1 class="title">ReadLog</h1>
</header>
<!-- Wrap all non-bar HTML in the .content div (this is actually what scrolls) -->
<div class="content">
<!-- Tab navigation -->
<div class="segmented-control">
<a class="control-item active" href="#to-read">
To Read
</a>
<a class="control-item" href="#reading">
Reading
</a>
<a class="control-item" href="#read">
Read
</a>
</div>
<!-- Tab content -->
<div class="tab-content">
<div id="to-read" class="control-content active">
<ul class="table-view" id="to-read-list">
<!-- Books will be added here dynamically -->
</ul>
</div>
<div id="reading" class="control-content">
<ul class="table-view" id="reading-list">
<!-- Books will be added here dynamically -->
</ul>
</div>
<div id="read" class="control-content">
<ul class="table-view" id="read-list">
<!-- Books will be added here dynamically -->
</ul>
</div>
</div>
</div>
<!-- Add book modal -->
<div id="add-book-modal" class="modal">
<header class="bar bar-nav">
<a class="icon icon-close pull-right" href="#add-book-modal"></a>
<h1 class="title">Add Book</h1>
</header>
<div class="content">
<form id="add-book-form">
<div class="input-group">
<div class="input-row">
<label>Title</label>
<input type="text" id="book-title" placeholder="Enter book title" required>
</div>
<div class="input-row">
<label>Author</label>
<input type="text" id="book-author" placeholder="Enter author name" required>
</div>
</div>
<div class="content-padded">
<button type="submit" class="btn btn-positive btn-block">Add Book</button>
</div>
</form>
</div>
</div>
<!-- Options modal -->
<div id="options-modal" class="modal">
<header class="bar bar-nav">
<a class="icon icon-close pull-right" href="#options-modal"></a>
<h1 class="title">Options</h1>
</header>
<div class="content">
<ul class="table-view">
<li class="table-view-cell" id="export-json-btn">
Export Books to JSON
</li>
<li class="table-view-cell" id="export-csv-btn">
Export Books to CSV
</li>
</ul>
</div>
</div>
<!-- Book actions modal -->
<div id="book-actions-modal" class="modal">
<header class="bar bar-nav">
<a class="icon icon-close pull-right" href="#book-actions-modal"></a>
<h1 class="title">Book Actions</h1>
</header>
<div class="content">
<ul class="table-view" id="book-actions-list">
<!-- Actions will be added here dynamically -->
</ul>
</div>
</div>
<!-- Tab bar -->
<nav class="bar bar-tab">
<a class="tab-item active" href="#to-read">
<span class="icon icon-book"></span>
<span class="tab-label">Books</span>
</a>
<a class="tab-item" href="#options-modal">
<span class="icon icon-gear"></span>
<span class="tab-label">Options</span>
</a>
</nav>
<!-- Include our JavaScript files -->
<script src="cordova.js"></script>
<script src="js/app.js"></script>
<script src="js/services/storage.js"></script>
<script src="js/models/book.js"></script>
<script src="js/controllers/bookController.js"></script>
<script src="js/index.js"></script>
</body>
</html>
Now, let's implement our main application logic in js/index.js
:
// Wait for the device to be ready
document.addEventListener('deviceready', onDeviceReady, false);
function onDeviceReady() {
// Initialize the application
initializeApp();
}
function initializeApp() {
// Create instances of our services, models, and controllers
var storage = new app.services.Storage();
var bookController = new app.controllers.BookController(storage);
// DOM elements
var addBookForm = document.getElementById('add-book-form');
var toReadList = document.getElementById('to-read-list');
var readingList = document.getElementById('reading-list');
var readList = document.getElementById('read-list');
var exportJSONBtn = document.getElementById('export-json-btn');
var exportCSVBtn = document.getElementById('export-csv-btn');
var bookActionsList = document.getElementById('book-actions-list');
// Currently selected book ID
var selectedBookId = null;
// Add a book when the form is submitted
addBookForm.addEventListener('submit', function(e) {
e.preventDefault();
// Get the book details
var titleInput = document.getElementById('book-title');
var authorInput = document.getElementById('book-author');
var title = titleInput.value.trim();
var author = authorInput.value.trim();
if (title && author) {
// Add the book
bookController.addBook(title, author);
// Clear the form
titleInput.value = '';
authorInput.value = '';
// Close the modal
window.location.hash = '';
// Refresh the book lists
renderBookLists();
}
});
// Show book actions when a book is clicked
function handleBookClick(e) {
// Only handle clicks on the book item, not its children
if (e.target && e.target.classList.contains('table-view-cell')) {
// Get the book ID
selectedBookId = e.target.dataset.bookId;
// Get the book
var result = bookController.findBook(selectedBookId);
if (result) {
// Clear the actions list
bookActionsList.innerHTML = '';
// Add actions based on the book's status
if (result.book.status === 'to-read') {
// Add "Start Reading" action
var startReadingItem = document.createElement('li');
startReadingItem.className = 'table-view-cell';
startReadingItem.textContent = 'Start Reading';
startReadingItem.addEventListener('click', function() {
bookController.updateBookStatus(selectedBookId, 'reading');
window.location.hash = '';
renderBookLists();
});
bookActionsList.appendChild(startReadingItem);
} else if (result.book.status === 'reading') {
// Add "Finish Reading" action
var finishReadingItem = document.createElement('li');
finishReadingItem.className = 'table-view-cell';
finishReadingItem.textContent = 'Finish Reading';
finishReadingItem.addEventListener('click', function() {
bookController.updateBookStatus(selectedBookId, 'read');
window.location.hash = '';
renderBookLists();
});
bookActionsList.appendChild(finishReadingItem);
}
// Add "Remove Book" action for all books
var removeBookItem = document.createElement('li');
removeBookItem.className = 'table-view-cell';
removeBookItem.textContent = 'Remove Book';
removeBookItem.addEventListener('click', function() {
bookController.removeBook(selectedBookId);
window.location.hash = '';
renderBookLists();
});
bookActionsList.appendChild(removeBookItem);
// Show the modal
window.location.hash = 'book-actions-modal';
}
}
}
// Add click event listeners to the book lists
toReadList.addEventListener('click', handleBookClick);
readingList.addEventListener('click', handleBookClick);
readList.addEventListener('click', handleBookClick);
// Export books to JSON
exportJSONBtn.addEventListener('click', function() {
var json = bookController.exportToJSON();
exportFile(json, 'books.json', 'application/json');
});
// Export books to CSV
exportCSVBtn.addEventListener('click', function() {
var csv = bookController.exportToCSV();
exportFile(csv, 'books.csv', 'text/csv');
});
// Function to export a file
function exportFile(content, filename, contentType) {
// Use the Cordova File plugin to export the file
// For now, we'll just log to the console
console.log('Exporting file: ' + filename);
console.log(content);
// In a real app, we would use the Cordova File plugin
// to write the file to the device's storage
// Close the modal
window.location.hash = '';
}
// Function to render the book lists
function renderBookLists() {
// Clear the lists
toReadList.innerHTML = '';
readingList.innerHTML = '';
readList.innerHTML = '';
// Get books by status
var toReadBooks = bookController.getBooksByStatus('to-read');
var readingBooks = bookController.getBooksByStatus('reading');
var readBooks = bookController.getBooksByStatus('read');
// Render to-read books
toReadBooks.forEach(function(book) {
var li = document.createElement('li');
li.className = 'table-view-cell';
li.dataset.bookId = book.id;
li.textContent = book.title + ' - ' + book.author;
toReadList.appendChild(li);
});
// Render reading books
readingBooks.forEach(function(book) {
var li = document.createElement('li');
li.className = 'table-view-cell';
li.dataset.bookId = book.id;
li.textContent = book.title + ' - ' + book.author;
readingList.appendChild(li);
});
// Render read books
readBooks.forEach(function(book) {
var li = document.createElement('li');
li.className = 'table-view-cell';
li.dataset.bookId = book.id;
li.textContent = book.title + ' - ' + book.author;
if (book.readingTime > 0) {
li.textContent += ' (' + book.readingTime + ' days)';
}
readList.appendChild(li);
});
}
// Initial render
renderBookLists();
}
Running the App
To run our app on a device or emulator, we need to add the iOS platform to our Cordova project and build it:
cordova platform add ios
cordova build ios
cordova run ios
If everything has been set up correctly, you should see your book cataloguing app running on your iOS device or simulator!
Implementing the File Export Feature
In our current implementation, we've only logged the exported content to the console. To actually save files to the device and make them accessible through iTunes, we need to use the Cordova File plugin.
First, let's install the plugin:
cordova plugin add cordova-plugin-file
Then, we can update our exportFile
function in js/index.js
to use the plugin:
// Function to export a file
function exportFile(content, filename, contentType) {
// Get the app's documents directory
window.resolveLocalFileSystemURL(cordova.file.documentsDirectory, function(dirEntry) {
// Create the file
dirEntry.getFile(filename, { create: true, exclusive: false }, function(fileEntry) {
// Write the content to the file
writeFile(fileEntry, content);
}, onError);
}, onError);
// Function to write content to a file
function writeFile(fileEntry, content) {
fileEntry.createWriter(function(writer) {
writer.onwriteend = function() {
console.log('File exported successfully: ' + fileEntry.nativeURL);
alert('File exported successfully!');
};
writer.onerror = function(error) {
console.error('Failed to export file: ' + error);
alert('Failed to export file.');
};
writer.write(content);
}, onError);
}
// Error handler
function onError(error) {
console.error('Error exporting file: ' + error);
alert('Error exporting file.');
}
// Close the modal
window.location.hash = '';
}
This implementation uses the Cordova File plugin to write the exported content to a file in the app's documents directory. The documents directory is accessible through iTunes file sharing, so users can easily copy the files to their computer.
Testing Our App
Now that we have all our code in place, let's test our app to make sure everything works as expected. Here are a few things to check:
- Adding a new book
- Viewing books by status (to-read, reading, read)
- Changing a book's status (from to-read to reading, from reading to read)
- Removing a book
- Exporting books to JSON and CSV
Remember, we can also run our Jasmine tests to verify that our code behaves as expected. Just open SpecRunner.html
in a browser and make sure all the tests pass.
Conclusion
In this post, we've seen how to use Behavior-Driven Development with Jasmine to implement our book cataloguing app. By writing our tests first, we were able to clearly define the expected behavior of our code and ensure that our implementation meets those expectations.
The BDD approach might seem like more work up front, but it pays off in the long run. As your application grows, having a comprehensive test suite gives you the confidence to make changes without breaking existing functionality.
In the next part of this series, we'll look at wrapping our app with Cordova and deploying it to the App Store. Let me know in the comments if you have any questions!