Creating an iOS App with Cordova, Part 3: Development

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:

  1. Write a failing test that describes the expected behavior
  2. Implement just enough code to make the test pass
  3. Refactor your code while keeping tests green
  4. 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:

  1. Adding a new book
  2. Viewing books by status (to-read, reading, read)
  3. Changing a book's status (from to-read to reading, from reading to read)
  4. Removing a book
  5. 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!