As I continue to study the MEAN.JS framework, I wanted to develop an app that would help cement what I've learned, illustrate some of the points I got hung up on and try to define problems well enough that I can try to get some more help where needed.
The basic idea for this app is a Project Task Tracker. A simple enough design pattern, it provides enough challenges across the layers of the stack to make it interesting and practical. We're going to assume the reader has worked through getting a MEAN.JS development environment configured and has used the yo means:crud-module <module name> command to create a simple module.
Our app will create a Project and related Tasks using the framework tools. We'll modify controllers, update the database models by linking tasks to the parent and parent to the child, and customize the views with standard Bootstrap 3 techniques. We will also note a few of the points that I didn't understand and ended up spending too much time researching.
There is a lot to know in working with the various layers of the stack. Many tutorials (things I bought, things I read) helped to shape this article and I encourage you to buy some ebooks and online resources that may help you move quickly through the stack learning curve. (we'll list these resources at the end)
Scaffolding Our App
It's probably easiest to use the yo means-generator tools to create the two modules we're going to use.
Run the following:
yo meanjs:crud-module project
and follow the command prompts. Accept the basics
And again for the tasks module
yo meanjs:crud-module task
If this worked, you should now have both a Project and Task menu item. Each should be configured with a Title and Content area, exactly as the Articles module looks.
Next we'll edit our Models to hold the data we want in each document collection.
Our Project data will include: name, description, created, user, assignedTo, completeBy, percentComplete, tasks. We'll now edit the /app/models/project.server.model.js file to look like this:
'use strict';
/**
* Module dependencies.
*/
var mongoose = require('mongoose'),
Schema = mongoose.Schema;
/**
* Project Schema
*/
var ProjectSchema = new Schema({
name: {
type: String,
default: '',
required: 'Please fill Project name',
trim: true
},
description: {
type: String,
trim: true
},
created: {
type: Date,
default: Date.now
},
user: {
type: Schema.ObjectId,
ref: 'User'
},
assignedTo: {
type: Schema.ObjectId,
ref: 'User'
},
completeBy: {
type: Date,
required: false
},
dateCompleted: {
type: Date,
required: false
},
isComplete: {
type: Boolean,
required: false,
default: false
},
percentComplete: {
type: Number,
required: false,
default: 0
},
tasks: [{
type: Schema.ObjectId,
ref: 'Task'
}]
});
/**
* Statics
*/
mongoose.model('Project', ProjectSchema);
You'll note that we have both user and assignedTo. My intent was to be able to assign projects and tasks to others on the team. I haven't figured out how to implement assignedTo within the MEAN.JS framework. The tasks field will be an array of ObjectIds that come from tasks as they are created. After you save the changes for the model, you'll have to update the views to take advantage of the new fields you've added.
In order for the new fields in the project model to accept data, we also need to modify the /public/modules/project/controllers/projects.client.controller.js file. Here we are adding the new fields that will come in from the create.project.client.view.html page and then clearing the form fields after the save.
// Create new Project
$scope.create = function() {
// Create new Project object
var project = new Projects ({
name: this.name,
description: this.description,
completeBy: this.completeBy,
});
// Redirect after save
project.$save(function(response) {
$location.path('projects/' + response._id);
// Clear form fields
$scope.name = '';
$scope.description = '';
$scope.completeBy = '';
}, function(errorResponse) {
$scope.error = errorResponse.data.message;
});
};
We'll actually make one more change to this controller. In the $scope.findOne function, we'll be assigning the current projecId to a variable $scope.mytasks. As we create our Tasks, we'll be marking them with this (parent) projectId being passed in the URL and available via $stateParams. Here's that code.
// Find existing Project
$scope.findOne = function() {
$scope.project = Projects.get({
projectId: $stateParams.projectId
});
$scope.mytasks = Projects.get({
projectId: $stateParams.projectId
});
};
Mongo / Mongoose
I did a lot of reading and experimenting with the notion of nested subdocuments and manually referencing collections. Coming from a MySQL experience, it became easier for me to work with this approach, although using nested subdocuments could be made to work.
One of my challenges was learning how the data storage with Mongo/Mongoose works. Lots of tutorials on the basics on Mongo, querying document collections, inserting and deleting can be found. But our app will be using Mongoose methods to interact with the DB, so you need to enough about both to understand how they work together.
An example of this is the Mongoose method populate(). You'll see how this works later, but I was looking at populate to create the "foreign key" inserts. That's not how it works.
Creating Tasks and Changing Routes
As we know from using the means:crud-module generator, Tasks can be created using the MEAN.JS framework tools, list, create, update, delete. What we're going to want to do is modify the Task module to mark tasks (a child record) with the id of the project (parent record).
In order to modify the "add task" link on the project page, we're going to need a route that will display a project and all its related tasks. In the /public/modules/tasks/config/tasks.client.routes.js file, we'll create a comment out the existing createTask block and add this:
// state('createTask', {
// url: '/tasks/create',
// templateUrl: 'modules/tasks/views/create-task.client.view.html'
// }).
state('createTask', {
url: '/tasks/:projectId/create',
templateUrl: 'modules/tasks/views/create-task.client.view.html',
controller: function($scope, $stateParams) {
$scope.projectId = $stateParams.projectId;
}
}).
Let's break this down. On the URL /tasks/:projectId/create we're going to get the create view template. We're also setting $scope.projectId from the :projectId parameter that is being sent in the URL. Back on our view-project.client.view.html page, we're going to add a button to the existing buttons like this:
<a class="btn btn-primary" href="/#!/tasks/{{project._id}}/create">
<i class="glyphicon glyphicon-plus"></i>
</a>
Back to Tasks
The Tasks data model needs to be updated to hold the fields. Add the following. As you'll see a task is almost identical to a project. While there is a lot of opportunity for simplification, for our purposes of a parent-child sample app, we'll keep it as is.
'use strict';
/**
* Module dependencies.
*/
var mongoose = require('mongoose'),
Schema = mongoose.Schema;
/**
* Task Schema
*/
var TaskSchema = new Schema({
name: {
type: String,
default: '',
required: 'Please fill Task name',
trim: true
},
description: {
type: String,
trim: true
},
created: {
type: Date,
default: Date.now
},
user: {
type: Schema.ObjectId,
ref: 'User'
},
assignedTo: {
type: Schema.ObjectId,
ref: 'User'
},
completeBy: {
type: Date,
required: false
},
dateCompleted: {
type: Date,
required: false
},
isComplete: {
type: Boolean,
required: false,
default: false
},
percentComplete: {
type: Number,
required: false,
default: 0
},
project: {
type: Schema.ObjectId,
ref: 'Project'
}
});
mongoose.model('Task', TaskSchema);
You'll want to now update your view templates to reflect the additional fields in the Tasks model.
Tasks Controllers
We're going to need make changes to both the client and server controllers. In our /public/modules/tasks/controllers/tasks.client.controller.js file, we'll be making similar changes to the Projects client controller. We need to add fields to the create Task object and later we need to clear those form fields.
// Create new Task
$scope.create = function() {
// Create new Task object
var task = new Tasks ({
name: this.name,
description: this.description,
tasks: [],
completeBy: this.completeBy,
percentComplete: this.percentComplete,
project: $scope.projectId
});
// Redirect after save
task.$save(function(response) {
$location.path('tasks/' + response._id);
// Clear form fields
$scope.name = '';
$scope.description = '';
$scope.completeBy = '';
$scope.percentComplete = '';
}, function(errorResponse) {
$scope.error = errorResponse.data.message;
});
};
You'll note that tasks:[] creates an empty array. More on this later. Also note the project: $scope.projectId. That's marking this task with our projectId. This is similar to a foreign key and will allow us to later query the tasks collection with this projectId and return the ObjectId of the related tasks.
In the /app/controllers/tasks.server.controller.js we'll need to make some further edits. This is where I ended up spending a lot of time taking wrong approaches. I'll try to explain what I was doing wrong and provide explanations.
Manual Reference Collections (sp?)
Our challenge is that during the Task creation, we also need to get the new task._id added to the Project.tasks[] array. (a mysql comparison?)
This thread was a big aha moment for me, http://stackoverflow.com/questions/29018874/how-to-implement-cascade-insert-with-mean-mongodb. There's a few misconceptions I had that are answered in this thread's responses.
- "The result of queries get passed to callbacks, not returned." For all my reading, that hadn't sunk in.
- The original author was querying the parent (university) from within the Department Post() and then pushing the department id (child).
- The author was using cascade insert
Anyway, for our purposes, while we are saving a Task, we’ll also query for the Project and push the Task._id into the tasks[] array of the Project and updating the Project and saving the Task.
/**
* Create a Task
*/
exports.create = function(req, res) {
var task = new Task(req.body);
task.user = req.user;
task.save(function(err) {
if (err) {
return res.status(400).send({
message: errorHandler.getErrorMessage(err)
});
} else {
res.jsonp(task);
}
}); // end task.save
// PSM
var id = req.body.project;
var project = {};
Project.findById(id, function (err, project) {
// result of query
console.log(project.name); // name is required
console.log(project.created); // date created
if(!project.tasks) {
project.tasks = [];
}
// push the Task id to the array
project.tasks.push({'_id': task._id});
project.save(function(err) {
if (err) {
return res.status(400).send({
message: errorHandler.getErrorMessage(err)
});
}
// saved!
}); // end project.save()
}); // end Project.findById()
}; // end exports.create
Middleware
In projects.server.controller.js, we created middleware that uses the populate() function that gives the tasks object all the data it needs?
exports.projectByID = function(req, res, next, id) {
Project.findById(id).populate('user', 'displayName')
.populate('tasks')
.exec(function(err, project) {
if (err) return next(err);
if (! project) return next(new Error('Failed to load Project ' + id));
req.project = project ;
next();
});
};
Displaying the Project and Tasks
In the screen shot below, you can see the Project page with a table displaying the tasks associated with this project.
We’re taking advantage of the Sort and Filter a Table tutorial found over at https://scotch.io/tutorials/sort-and-filter-a-table-using-angular.
When we clicked the Project One link in the list of projects, we were executing the middleware exports.projectByID(). This is found in the projects.server.controller.js file.
I think my biggest challenge was figuring out how Mongoose worked. Mongo has a CLI and you can enter commands in to learn how various CRUD functions work. Mongoose is a wrapper that abstracts and provides additional functionality to the data model that Mongo is storing. I’m still not sure I’ve got a good handle on this. At first I thought the populate was “pushing/updating”, but it is really part of a search query.
In the middleware below, we are searching for a specific project. Upon finding the project, the link between Project and User will get filled in with the displayName of the user. The tasks array will also be populated. Basically, Projects.tasks array of ObjectID’s is used to retrieve and populate the fields (name, description, completeBy, percentComplete).
/**
* Project middleware
*/
exports.projectByID = function(req, res, next, id) {
Project.findById(id).populate('user', 'displayName')
.populate('tasks')
.exec(function(err, project) {
if (err) return next(err);
if (! project) return next(new Error('Failed to load Project ' + id));
req.project = project ;
next();
});
};
Animating the App
In trying to understand where Single Page Application design patterns intersect, we need to consider how a slightly more sophisticated app fits in. While we are using the transclusion opportunities of AngularJS to display partial HTML templates within a larger template, we're also using multiple paths. Anyway, maybe it doesn't matter.
While not a designer, I do like some simple animation between page views in the app. So, what we did here was install the animate.css package via Bower and then add some simple divs and classes to wrap the sections that were created by the means:crud-module process.
From the command line, bower install animate.css --save will download and install this package. From there, you need to make sure that the files /config/env/all.js has the CSS library added.
Once the animate.css package is installed, we can head back to public/modules/projects/views/ where we'll add the following wrapping <div class="animated fadeIn"> </div> around each <section></section block. This will produce a nice fadeIn animation between pages.
Adding Some Bootstrap Bling
progress bar, calendar, on/off toggle bar