Using IndexedDB with Dart

We will learn how to work with IndexedDB and JSON web services through the indexed_db_spirals project (https://github.com/dzenanr/indexed_db_spirals), which is a todo app like the ones we've built in previous chapters, but that stores its data in IndexedDB. Get a copy of the code with a git clone:https://github.com/dzenanr/indexed_db_spirals.git.

Spiral s00

In this spiral, todo tasks can be entered, and they are stored in IndexedDB; the following is a screenshot:

Spiral s00

The Tasks screen of Spiral s00

Our model class is called Task, and lives in model.dart; with toDb and fromDb, it can transform an object to or make an object from a map:

class Task {
  String title;
  bool completed = false;
  DateTime updated = new DateTime.now();
  var key;

  Task(this.title);

  Task.fromDb(this.key, Map value):
    title = value['title'],
    updated = DateTime.parse(value['updated']),
    completed = value['completed'] == 'true' {
  }

  Map toDb() {
    return {
      'title': title,
      'completed': completed.toString(),
      'updated': updated.toString()
    };
  }
}

Besides Task, the model also contains the class TasksStore, which contains List<Task> and interacts with IndexedDB. In order to do this, we need to import the dart:indexed_db library to model.dart, which provides the Dart API to use IndexedDB:

import 'dart:indexed_db';

The web page is view.html and references view.dart; this contains all UI code setup from the main() entry point:

import 'dart:html';
import 'model.dart';

Element taskElements;
TasksStore tasksStore;

main() {
   taskElements = querySelector('#task-list'),
   tasksStore = new TasksStore();    
   tasksStore.open().then((_) {                             (1)
     loadElements(tasksStore.tasks);
   });

  InputElement newTask = querySelector('#new-task'),
  newTask.onKeyPress.listen((KeyboardEvent e) {
    if (e.keyCode == KeyCode.ENTER) {
      var title = newTask.value.trim();
      if (title != '') {
        tasksStore.add(title).then((task) {                  (2)
          addElement(task);
        });
        newTask.value = '';
      }
    }
  });

  ButtonElement clear = querySelector('#clear-tasks'),
  clear.onClick.listen((MouseEvent e) {
    tasksStore.clear().then((_) {                            (3)
      clearElements();
    });
  });
}  
Element newElement(Task task) {
  return new Element.html('''
    <li>
      ${task.title}
    </li>
  '''),
}

addElement(Task task) {
  var taskElement = newElement(task);
  taskElements.nodes.add(taskElement);
}

loadElements(List tasks) {
  for (Task task in tasks) {
    addElement(task);
  }
}

clearElements() {
  taskElements.nodes.clear();
}
}

All interactions with IndexedDB are asynchronous and return Futures; that's why we use then in lines (1) to (3), respectively, when opening a database and adding a task or removing all tasks. The callback functions loadElements, addElement, and clearElements update the screen after the database has been changed (the code is straightforward; see view.dart). In line (1), we see that, for the parameter of the then callback function, (_) is written; this means that there is one parameter, but that we don't need it, so we don't name it. What happens now in line (1) in the preceding code with the open() call on TaskStore? You can imagine that we need to open the database or create it the first time the page is requested. This is done with a call to window.indexedDB.open in line (4) in model.dart:

class TasksStore {
  static const String TASKS_STORE = 'tasksStore';
  final List<Task> tasks = new List();
  Database _db;

  Future open() {
    return window.indexedDB.open('tasksDb00',                 (4)
        version: 1,
        onUpgradeNeeded: _initDb)                             (5)
       .then(_loadDb);                                        (6)
  }
// code left out

The open() method takes three parameters: the first two are listed in alphabetical order by the name of the database and the third, by version number. When the app is first started on a client, a database with that name and Version 1 is created; every subsequent time, it is simply opened. At creation, the third parameter onUpgradeNeeded kicks in to fire an upgrade event, which calls _initDb in line (5). You can upgrade a database to a higher version by opening it with a new version number and then an upgrade event takes place and the previous version of the database doesn't exist anymore. Our database tasksDb00 needs one or more object stores; these can only be created during an upgrade event. Here, in _initDb, we get a reference to the database object in line (7) and, in line (8), we create an object store (that can contain data records) named TasksStore; the value of the constant is TASKS_STORE:

void _initDb(VersionChangeEvent e) {
    var db = (e.target as Request).result;                     (7)
    var objectStore = db.createObjectStore(TASKS_STORE         (8) autoIncrement: true);
}

The autoIncrement property, when true, lets the database generate unique primary keys for you. In a later spiral, we will also create an index to enhance query speed. A database can contain multiple object stores if needed, and our app can have access to multiple databases at once. After initialization, the then callback in line (6) kicks in, calling _loadDb:

Future _loadDb(Database db) {
    _db = db;
    var trans = db.transaction(TASKS_STORE, 'readonly'),       (9)
    var store = trans.objectStore(TASKS_STORE);
    var cursors = store.openCursor(autoAdvance:               (10)true).asBroadcastStream();
    cursors.listen((cursor) {                                 (11)
      var task = new Task.fromDb(cursor.key, cursor.value);
      tasks.add(task);
    });

    return cursors.length.then((_) {                          (12)
      return tasks.length;
    });
  }

In order to make sure that it is reliable, every operation on the database happens within a transaction, so that transaction is created in line (9) and attached to the object store (this is also required for reads; the second argument can be readonly, readwrite, or versionchange). Database transactions take time, so the results are always provided via Futures. Getting records from a database is mostly done using a Cursor object, which is created here in line (10) with the openCursor method on the object store. The cursors object indicates the current position in the object store and returns the records one by one through a Stream object automatically because of the autoAdvance parameter (otherwise, use the next() method). For each record that returns, a listen event fires the code defined in line (11). Here, the database values cursor.key and cursor.value are given to the named Task constructor fromDb, so a new task is created and added to the list. A BroadcastStream method also returns the length of the cursor as a final event in line (12), which is also the length of the tasks collection. When a task is added, the add method on the store is called in line (2):

Future<Task> add(String title) {
    var task = new Task(title);
    var taskMap = task.toDb();                               (13)

    var transaction = _db.transaction(TASKS_STORE, 'readwrite'),
    var objectStore = transaction.objectStore(TASKS_STORE);

    objectStore.add(taskMap).then((addedKey) {                (14)
      task.key = addedKey;
    });

    return transaction.completed.then((_) {                  (15)
      tasks.add(task);
      return task;
    });
  }

The class Task has a toDb method called in line (13) to transform the Task object into a map. A read/write transaction is created and the add method is called on the store in line (14) with the task data. This also returns a Future, resulting in the key generated by the database (addedKey), which is also stored in the task object. When the transaction completes in line (15), the task is added to the collection and returned as the Future's result, which is then used in view.dart to update the view. Removing all objects from the store is easy; calling clear on the store in line (3) executes:

Future clear() {
    var transaction = _db.transaction(TASKS_STORE, 'readwrite'),
    transaction.objectStore(TASKS_STORE).clear();
    return transaction.completed.then((_) {
      tasks.clear();
    });
}

This results in the clearing of the tasks collection and then the updating of the view. To see the data in your IndexedDB database at any moment, navigate to Chrome View | Developer | Developer Tools, and then choose Resources from the tabs along the top of the window:

Spiral s00

Viewing IndexedDb with developer tools

Spiral s01

No new functionality is added here, but the startup web page is renamed to app.html and the layout is improved through CSS. Furthermore, the methods are made private where possible and the app architecture is refactored to MVC by introducing a library in line (1); all of the UI code is moved from view.dart to the class TasksView in lib/view/view.dart, and the model code to lib/model/model.dart. Both are now contained in the library file lib/indexed_db.dart:

library indexed_db;                                           (1)

import 'dart:async';
import 'dart:html';
import 'dart:indexed_db';

part 'model/model.dart';
part 'view/view.dart';

The main Dart file app.dart imports our new library in line (2) and uses a TasksView object:

import 'package:indexed_db/indexed_db.dart';                   (2)

main() {
  var tasksStore = new TasksStore();
  var tasksView = new TasksView(tasksStore);
  tasksStore.open().then((_) {
    tasksView.loadElements(tasksStore.tasks);
  });
}

Spiral s02

Now, we can remove a task or mark it as completed using the X button:

Spiral s02

The screen of Spiral s02

To accomplish this, the newElement method is expanded a bit to draw the checkbox and remove button:

Element _newElement(Task task) {
    return new Element.html('''
        <li>
          <button class='task-button remove-task'>X</button>
          <input class='task-completed' type='checkbox'
            ${task.completed ? 'checked' : ''}>
          <label class='task-title'>${task.title}</label>
        </li>
    '''),
  }

In _addElement, a click event handler on the X button is added, which removes the task from the object store:

   tasksStore.remove(task).then((_) {
        _taskElements.nodes.remove(taskElement);
        _updateFooter();
   });

The click event handler then calls the remove method in the class TasksStore to delete it in the object store:

Future remove(Task task) {
    var transaction = _db.transaction(TASKS_STORE, 'readwrite'),
    transaction.objectStore(TASKS_STORE).delete(task.key);
    return transaction.completed.then((_) {
      task.key = null;
      tasks.remove(task);
    });
}

Clicking the checkbox to signal a task as complete invokes an update on the object store:

taskElement.querySelector('.task-completed').onClick.listen((MouseEvent e) {
      task.completed = !task.completed;
      task.updated = new DateTime.now();
      tasksStore.update(task);
});
The update method transforms the Task object to a map and calls the put method on the object store:
Future update(Task task) {
    var taskMap = task.toDb();
    var transaction = _db.transaction(TASKS_STORE, 'readwrite'),
    transaction.objectStore(TASKS_STORE).put(taskMap, task.key);
    return transaction.completed;
}

Spiral s03

The new elements in this spiral are: a checkbox above the textbox to mark all tasks as completed, the number of active (incomplete) tasks is shown at the bottom, and a button to clear (remove) all completed tasks (their number is indicated in parentheses) is added. The active and completed tasks are returned by getters in the class TasksStore, for instance:

List<Task> get activeTasks {
    var active = new List<Task>();
    for (var task in tasks) {
      if (!task.completed) {
        active.add(task);
      }
    }
    return active;
}   

In _initDb, we create a new database by changing the name and creating a unique index on the title by calling createIndex on the object store:

      store.createIndex(TITLE_INDEX, 'title', unique: true);

The click handler for the "complete all tasks" button calls the following method:

Future completeTasks() {
    Future future;
    for (var task in tasks) {
      if (!task.completed) {
        task.completed = true;
        task.updated = new DateTime.now();
        future = update(task);
      }
    }
    return future;
}

In fact, this will only return the future of the last update. In this example, it will be the last one that completes, but it would be more correct to return a future that completes when all updates are complete. We'll make improvements in spirals s06 and s07.

All kinds of detailed screen updates are now assembled in _updateDisplay() in view.dart.

Spiral s04

Now, it has become a fully functional todo application with nice links to all active and completed task lists, the possibility of editing the task's title, and persisting the data in IndexedDB, as shown in the following screenshot:

Spiral s04

The screen of Spiral s04

The following code changes are worth discussing. In the previous spiral, a unique index on the title was created, but no error message was shown when a duplicate task was entered; the double task was simply not added and the IndexedDB error was ignored. Now, we do better using the catchError method in line (1) and changing the Keypress event handler of the input field to:

    InputElement newTask = querySelector('#new-task'),
    newTask.onKeyPress.listen((KeyboardEvent e) {
      if (e.keyCode == KeyCode.ENTER) {
        var title = newTask.value.trim();
        if (title != '') {
          _tasksStore.add(title)
            .then((task) {
              _addElement(task);
              newTask.value = '';
              _updateFilter();
            })
         .catchError((e) {  // IndexedDB error unique index    (1)
              newTask.value = '${title} : title not unique';
              newTask.select();
            });
        }
      }
});

The edit functionality is coded as follows when the task title is double-clicked on in line (2), and a textbox editTitle is shown in line (3) and selected:

    Element title = taskElement.querySelector('.task-title'),
    InputElement editTitle = taskElement.querySelector('.edit-title'),
    editTitle.hidden = true;
    title.onDoubleClick.listen((MouseEvent e) {                (2)
      title.hidden = true;
      editTitle.hidden = false;                                (3)
      editTitle.select();
    });

The editTitle variable also has an onKeyPress event handler that calls an update on the store and shows a non-unique error when this occurs.

The model now has a find method based on querying the index in line along with get in line (4):

Future<Task> find(String title) {
    var trans = _db.transaction(TASKS_STORE, 'readonly'),
    var store = trans.objectStore(TASKS_STORE);
    var index = store.index(TITLE_INDEX);
    var future = index.get(title);                             (4)
    return future
      .then((taskMap) {
        var task = new Task.fromDbWoutKey(taskMap);
        return task;
      });
  }

This is used in the _showActive and _showCompleted methods of the view:

 _showCompleted() {
    _setSelectedFilter(_completedElements);
    for (LIElement element in _taskElements.children) {
      Element titleLabel = element.querySelector('.task-title'),
      String title = titleLabel.text;
      _tasksStore.find(title)
        .then((task) {
          element.hidden = !task.completed;                   (5)
        })
        .catchError((e) {});
    }
  }

In line (5), we see that the task in the list is hidden when it is not yet completed.

Spiral s05

In this spiral, the UI and functionality remain the same, but the model is reorganized. The model code, which contains the class Task and a collection class Tasks (with methods, such as sort, contains, find, add, remove, and display0 in lib/model/model.dart), is cleanly decoupled from the data access code: the classes TasksDb and TasksStore in lib/model/idb.dart. This makes it easier to change or enhance the model code or to use another data source by switching to a different data access layer, which is what we do in Spiral s05_1.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.147.13.192