Product categories allow us to filter the products based on certain criteria, such as books, clothing, and so on. Hierarchies are another important feature to be added so that we can group the categories into sub-categories. For example, within the book category, we can have multiple sub-categories like non-fiction, novels, self-help, and so on.
We are now going to add a new sidebar to the marketplace that shows all the products. This sidebar will list all the products categories in the product-list
page:
/* client/app/products/templates/product-list.html *excerpt */
<navbar></navbar>
<div class="container">
<div class="row">
<div class="col-md-3" ng-include="'components/sidebar/sidebar.html'"></div>
<div class="col-sm-6 col-md-9" ng-show="products.length < 1"> No products to show.</div>
<div class="col-sm-6 col-md-9" ng-repeat="product in products">
<div class="thumbnail">
<h3>{{product.title}}</h3>
<img src="{{product.imageUrl}}" alt="{{product.title}}">
<figcaption class="figure-caption"> {{product.description | limitTo: 100}} ...</figcaption>
<div class="caption">
<p>{{product.price | currency }}</p>
<p>
<ngcart-addtocart id="{{product._id}}" quantity="1" quantity-max="9" name="{{product.title}}" price="{{product.price}}" template-url="components/ ngcart/addtocart.html" data="product">Add to Cart</ngcart-addtocart>
<a ui-sref="viewProduct({id: product._id})" class="btn btn-default" role="button">Details</a>
</p>
</div>
</div>
</div>
</div>
</div>
<footer></footer>
We haven't defined it as yet, but the sidebar will contain the products listed according to their categories. Let's create the new sidebar components:
mkdir client/components/sidebar touch client/components/sidebar/sidebar.{html,scss,controller.js,service.js}
Here is the content for the sidebar.html
:
/* client/components/sidebar/sidebar.html */
<div ng-controller="SidebarCtrl" class="sidebar">
<ul class="nav nav-sidebar">
<li ng-repeat="category in catalog" ng-class="{active: isActive(category.slug)}">
<a ui-sref="productCatalog ({slug: category.slug})">{{category.name}}</a>
</li>
</ul>
</div>
We will need some CSS styling for the sidebar:
/* client/components/sidebar/sidebar.scss */ /* Hide for mobile, show later */ .sidebar { display: none; } @media (min-width: 768px) { .sidebar { display: block; padding: 20px; overflow-x: hidden; overflow-y: auto; } } /* Sidebar navigation */ .nav-sidebar { margin-right: -21px; /* 20px padding + 1px border */ margin-bottom: 20px; margin-left: -20px; } .nav-sidebar > li > a { padding-right: 20px; padding-left: 20px; } .nav-sidebar > .active > a, .nav-sidebar > .active > a:hover, .nav-sidebar > .active > a:focus { color: #fff; background-color: #428bca; }
The SidebarCtrl
controller is going to pull out all the product categories from products:
/* client/components/sidebar/sidebar.controller.js */
angular.module('meanshopApp')
.controller('SidebarCtrl', function ($scope, Catalog, $location) {
$scope.catalog = Catalog.query();
$scope.isActive = function(route) {
return $location.path().indexOf(route) > -1;
};
});
And finally, we need a service that will retrieve the categories from the database. We do that as follows:
/* client/components/sidebar/sidebar.service.js */
angular.module('meanshopApp')
.factory('Catalog', function ($resource) {
return $resource('/api/catalogs/:id');
});
Now, it's time to move to the backend and create the /api/catalogs
route. In the next section, we are going to set up the backend to add categories to the products. We will also create 'slugs'—human friendly URLs—that will be linked to the categories.
Let's move to the server side. We will now provide the routes and URLs for filtering the products by categories and will allow us to search for products. For the search, we are going to add MongoDB's full-text indexes, and for the categories, we are going to create a new model:
/* server/api/product/product.model.js *excerpt */ var ProductSchema = new Schema({ title: { type: String, required: true, trim: true }, price: { type: Number, required: true, min: 0 }, stock: { type: Number, default: 1 }, description: String, imageBin: { data: Buffer, contentType: String }, imageUrl: String, categories: [{ type: Schema.Types.ObjectId, ref: 'Catalog', index: true }] }).index({ 'title': 'text', 'description': 'text' });
We haven't created the Catalog model, but we will soon. Notice that we added two text indexes on title
and description
. That will allow us to search on those fields.
In order to provide filtering by category and searching, we need to create new routes as follows:
/* server/api/product/index.js *excerpt */ router.get('/', controller.index); router.get('/:id', controller.show); router.get('/:slug/catalog', controller.catalog); router.get('/:term/search', controller.search); router.post('/', controller.create); router.put('/:id', controller.update); router.patch('/:id', controller.update); router.delete('/:id', controller.destroy);
Now that we are referencing the catalog
and search
actions in the controller, we need to create them:
/* server/api/product/product.controller.js *excerpt */ var Catalog = require('../catalog/catalog.model'); exports.catalog = function(req, res) { Catalog .findOne({ slug: req.params.slug }) .then(function (catalog) { var catalog_ids = [catalog._id].concat(catalog.children); console.log(catalog_ids, catalog); return Product .find({'categories': { $in: catalog_ids } }) .populate('categories') .exec(); }) .then(function (products) { res.json(200, products); }) .then(null, function (err) { handleError(res, err); }); }; exports.search = function(req, res) { Product .find({ $text: { $search: req.params.term }}) .populate('categories') .exec(function (err, products) { if(err) { return handleError(res, err); } return res.json(200, products); }); };
For the catalog action, we are performing the following two steps:
For the search action, we are using MongoDB's $text $search
; this is going to work on all the fields which have text indexes, such as title and description. Now, let's create the catalog model.
In our product catalog, we would like to modify the URL based on the category we are showing. So, for instance, to show all the products under the book category, we would like to show a URL like /products/books
. For that, we will use a slug.
Let's create the Product catalog and library to help us with the slug:
npm install [email protected] --save yo angular-fullstack:endpoint catalog ? What will the url of your endpoint be? /api/catalogs create server/api/catalog/catalog.controller.js create server/api/catalog/catalog.events.js create server/api/catalog/catalog.integration.js create server/api/catalog/catalog.model.js create server/api/catalog/catalog.socket.js create server/api/catalog/index.js create server/api/catalog/index.spec.js
Now let's modify the catalog model as follows:
/* server/api/catalog/catalog.model.js */ var mongoose = require('bluebird').promisifyAll(require('mongoose')); var Schema = mongoose.Schema; var slugs = require('mongoose-url-slugs'); var CatalogSchema = new Schema({ name: { type: String, required: true}, parent: { type: Schema.Types.ObjectId, ref: 'Catalog' }, ancestors: [{ type: Schema.Types.ObjectId, ref: 'Catalog' }], children: [{ type: Schema.Types.ObjectId, ref: 'Catalog' }] }); CatalogSchema.methods = { addChild: function (child) { var that = this; child.parent = this._id; child.ancestors = this.ancestors.concat([this._id]); return this.model('Catalog').create(child).addCallback (function (child) { that.children.push(child._id); that.save(); }); } } CatalogSchema.plugin(slugs('name')); module.exports = mongoose.model('Catalog', CatalogSchema);
With this catalog model, we can not only add nested categories, but also keep track of the categories' ancestors and children. Also, notice that we are adding a plugin to generate the slugs based on the name.
One way to test that everything is working as intended is through the unit tests; for more information, refer to https://raw.githubusercontent.com/amejiarosario/meanshop/ch8/server/api/catalog/catalog.model.spec.js.
From this unit, we can see that we can find products based on catalog._id
; we can also find multiple ones using $in
.
In order to have a predefined list of products and categories, it will be a good idea to seed the development database with it. Replace the previous product's seed with the following:
/* server/config/seed.js *excerpt */ var Catalog = require('../api/catalog/catalog.model'); var mainCatalog, home, books, clothing; Catalog .find({}) .remove() .then(function () { return Catalog.create({ name: 'All'}); }) .then(function (catalog) { mainCatalog = catalog; return mainCatalog.addChild({name: 'Home'}); }) .then(function (category) { home = category._id; return mainCatalog.addChild({name: 'Books'}); }) .then(function (category) { books = category._id; return mainCatalog.addChild({name: 'Clothing'}); }) .then(function (category) { clothing = category._id; return Product.find({}).remove({}); }) .then(function() { return Product.create({ title: 'MEAN eCommerce Book', imageUrl: '/assets/uploads/meanbook.jpg', price: 25, stock: 250, categories: [books], description: 'Build a powerful e-commerce application ...' }, { title: 'tshirt', imageUrl: '/assets/uploads/meantshirt.jpg', price: 15, stock: 100, categories: [clothing], description: 'tshirt with the MEAN logo' }, { title: 'coffee mug', imageUrl: '/assets/uploads/meanmug.jpg', price: 8, stock: 50, categories: [home], description: 'Convert coffee into MEAN code' }); }) .then(function () { console.log('Finished populating Products with categories'); }) .then(null, function (err) { console.error('Error populating Products & categories: ', err); });
We use promises to avoid the so-called callback hell. We create each one of the categories first and save them in variables. Later, we create each of the products and associate them with its corresponding category. If any errors occur in the process, they are catch'ed at the very end.
Now when we run grunt serve
, we see that the new categories and products are being created.
3.145.125.51