Строим блог на основе Node.js MVC фреймворка Areto

Модель статьи

Класс модели наследуется от класса Article из публичной части.

Модель статьи

Правило safe, которое применяется к атрибуту files, не производит никаких проверок, но обозначает, что атрибуту может быть присвоено значение из данных формы.

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');

Метод findBySearch ищет статьи, у которых заголовок содержит указанный текст.

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;
}
...

Метод findToSelect возвращает запрос на выборку всех заголовков статей. Модификатор asArray устанавливает результатом выборки не массив моделей, а массив простых JavaScript объектов.

modules/admin/models/Article.js

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

По умолчанию новая статья получает статус «Черновик».

modules/admin/models/Article.js

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

Метод getStatusSelect возвращает список возможных значений и названий статуса статьи.

modules/admin/models/Article.js

...
getStatusSelect () {
  return [
    { value: this.STATUS_DRAFT, label: 'Черновик' },
    { value: this.STATUS_PUBLISHED, label: 'Опубликована' },
    { value: this.STATUS_ARCHIVED, label: 'Архивирована' },
    { value: this.STATUS_BLOCKED, label: 'Блокирована' }
  ];
}
...

Обработка фотографий

Обработчик beforeValidate вызывается перед валидацией модели. Для корректной работы механизма событий необходимо вызвать асинхронный родительский метод.

modules/admin/models/Article.js

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

Обработчик afterSave вызывается после успешного сохранения модели. Аргумент insert содержит флаг, определяющий создание или редактирование модели текущим сохранением.

modules/admin/models/Article.js

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

Асинхронный метод resolveFiles находит модели загруженных файлов, соответствующие идентификаторам, переданным с формы в сериализованном массиве files.

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();
}
...

Асинхронный метод createPhotos создает модели Photo из загруженных файлов и связывает их с текущей статьей. Если не задано главное фото статьи mainPhotoId, то им назначается первое из созданных фотографий.

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();
}
...

Обработка меток

С формы приходит сериализованный список идентификаторов меток, которые связаны со статьей. Метод validateTags фильтрует их от пустых и неуникальных значений, разрывает текущие связи с метками и создает новые из полученного списка.

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);
  }
}
...

Метод resolveTag находит или создает новую модель метки по указанному имени и связывает ее с текущей статьей.

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);
      });
    }
  });
}
...

Отношения статьи

Отношение relAuthor определяет автора статьи.

Отношение relPhotos определяет фотографии, относящиеся к статье.

Отношение relMainPhoto определяет главное фото статьи.

Отношение relComments определяет комментарии, относящиеся к статье. Последний аргумент в методе hasMany отвечает за удаление комментариев при разрыве связи со статьей.

Отношение relTags определяет метки, относящиеся к статье. Для связи используется промежуточная таблица rel_article_tag.

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]);
}
...