Build a blog with Areto Node.js framework

Article model

This class inherited from the Article of the blog's frontend.

Article model

The safe rule applied to the files attribute does not perform any checks. It only means that a value of the form data can be assigned to the attribute.

modules/admin/models/Article.js

'use strict';
const Base = require('../../../models/Article');
module.exports = class Article extends Base {
  static getConstants () {
    return {
      STORED_ATTRS: ['status', 'authorId', 'date', 'title', 'content', 'mainPhotoId'],
      RULES: [                
        [['title', 'content', 'status', 'date'], 'required'],
        ['title', 'string', {min: 3, max: 128}],
        ['title', 'unique'],
        ['content', 'string', {min: 10, max: 16128}],
        ['date', 'date'],
        ['status', 'range', {range: ['draft','published','archived','blocked']}],
        ['files', 'safe'],
        ['tags', 'validateTags', {skipOnAnyError: true}]                
      ],
      UNLINK_ON_REMOVE: ['photos']
    };
  }
  // place methods here
};
module.exports.init(module);      
const async = require('async');
const Comment = require('./Comment');
const Tag = require('./Tag');
const File = require('./File');
const Photo = require('./Photo');
const User = require('./User');

The findBySearch method searches for articles that contain the specified text in the title.

modules/admin/models/Article.js

...
static findBySearch (text) {
  let query = this.find();
  if (typeof text === 'string' && /[a-z0-9\-\s]{1,32}/i.test(text)) {
    query.andWhere(['LIKE','title', `%${text}%`]);
  }
  return query;
}
...

The findToSelect method returns a query to select all titles of the articles. The asArray modifier sets the result as an array of simple JavaScript objects.

modules/admin/models/Article.js

...
static findToSelect () {
  return this.find().select(['title']).asArray();
}
...

By default, a new article gets the "draft" status.

modules/admin/models/Article.js

...
init () {
  super.init();
  this.set('status', this.STATUS_DRAFT);
}
...

The getStatusSelect method returns a map of all possuble status values and labels.

modules/admin/models/Article.js

...
getStatusSelect () {
  return [
    { value: this.STATUS_DRAFT, label: 'Draft' },
    { value: this.STATUS_PUBLISHED, label: 'Published' },
    { value: this.STATUS_ARCHIVED, label: 'Archived' },
    { value: this.STATUS_BLOCKED, label: 'Blocked' }
  ];
}
...

Photo processing

The beforeValidate handler is called before validation of the model. You must call the asynchronous parent method first to event system to work properly.

modules/admin/models/Article.js

...
beforeValidate (cb) {
  super.beforeValidate(err => {
    if (err) return cb(err);
    this.resolveFiles(this.get('files'), cb);
  });
}
...

The afterSave handler is called after a successful saving. The insert argument contains a flag that defines create or update a model by current action.

modules/admin/models/Article.js

...
afterSave (cb, insert) {
  super.afterSave(err => {
    if (err) return cb(err);
    this.createPhotos(this.get('files'), cb);
  }, insert);
}
...

The resolveFiles asynchronous method finds models of the uploaded files. The identifiers are received in the files serialized array of a form.

modules/admin/models/Article.js

...
resolveFiles (files, cb) {
  if (files && typeof files === 'string') {
    File.findById(files.split(',')).all((err, models)=> {
      if (err) {
        return cb(err);
      }
      this.set('files', models);
      cb();
    });
  } else cb();
}
...

The createPhotos async method creates the Photo models for the uploaded files and links them to the current article. If you do not set the mainPhotoId main article photo, the first of the created models will be assigned as the main.

modules/admin/models/Article.js

...
createPhotos (files, cb) {
  if (files instanceof Array) {
    let photos = [];
    async.each(files, (file, cb)=> {
      let photo = new Photo;
      photo.set('articleId', this.getId());
      photo.set('file', file);
      photo.save(err => {
        err ? this.module.log('error', err) : photos.push(photo);
        cb();
      });
    }, ()=> {
      // set first photo as main
      if (photos.length && !this.get('mainPhotoId')) {
        this.set('files', null);
        this.set('mainPhotoId', photos[0].getId());
        this.forceSave(cb);
      } else cb();
    });
  } else cb();
}
...

Tag processing

A serialized list of tag ids that are associated with an article is sent form. The validateTags method filters the empty and non-unique values, breaks off the current relations and creates new ones from the received list.

modules/admin/models/Article.js

...
validateTags (cb, attr, params) {
  try {
    let helper = require('areto/helpers/ArrayHelper');
    let items = this.get(attr).split(',');
    items = helper.unique(items.map(item => item.trim()).filter(item => item)); 
    this.unlinkAll('tags', err => {
      async.eachSeries(items, this.resolveTag.bind(this), cb);
    });
  } catch (err) {
    cb(err);
  }
}
...

The resolveTag method finds or creates a new tag model by name and links it to the current article.

modules/admin/models/Article.js

...
resolveTag (name, cb) {
  Tag.findByName(name).one((err, model)=> {
    if (err) {
      cb(err);
    } else if (model) {
      this.link('tags', model, cb);
    } else {
      model = new Tag;
      model.set('name', name);
      model.save(err => {
        model.isNewRecord ? cb(err) : this.link('tags', model, cb);
      });
    }
  });
}
...

Article relations

The relAuthor relation defines article's author.

The relPhotos relation defines article's photos.

The relMainPhoto relation defines the main photos of the article.

The relComments relation defines article's comments. The last argument in the hasMany function responsible for deleting comment's model at break of relations with the article.

The relTags relation defines tags related to an article. The rel_article_tag junction table is used to link.

modules/admin/models/Article.js

...
relAuthor () {
  return this.hasOne(User, [User.PK, 'authorId']);
}

relPhotos () {
  return this.hasMany(Photo, ['articleId', this.PK]);
}
    
relMainPhoto () {
  return this.hasOne(Photo, [Photo.PK, 'mainPhotoId']);
}

relComments () {
  return this.hasMany(Comment, ['articleId', this.PK]).removeOnUnlink();
}

relTags () {
  return this.hasMany(Tag, [Tag.PK, 'tagId']).removeOnUnlink()
    .viaTable('rel_article_tag', ['articleId', this.PK]);
}
...