Строим блог на Areto фреймворк

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

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

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

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

module/admin/model/Article.js

const Base = require('../../../model/Article');
module.exports = class Article extends Base {
  static getConstants () {
    return {
      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: [
          this.STATUS_DRAFT,
          this.STATUS_PUBLISHED,
          this.STATUS_ARCHIVED,
          this.STATUS_BLOCKED
        ]}],
        ['files', 'safe'],
        ['tags', 'validateTags', {skipOnAnyError: true}]
      ],
      UNLINK_ON_REMOVE: ['photos', 'comments'],
      ATTR_VALUE_LABELS: {
        'status': {
          [this.STATUS_DRAFT]: 'Draft',
          [this.STATUS_PUBLISHED]: 'Published',
          [this.STATUS_ARCHIVED]: 'Archived',
          [this.STATUS_BLOCKED]: 'Blocked'
        }
      }
    };
  }
  // place methods here
};
module.exports.init(module);
const async = require('areto/helper/AsyncHelper');
const ArrayHelper = require('areto/helper/ArrayHelper');
const Comment = require('./Comment');
const Tag = require('./Tag');
const File = require('./File');
const Photo = require('./Photo');
const User = require('./User');

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

module/admin/model/Article.js

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

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

module/admin/model/Article.js

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

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

module/admin/model/Article.js

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

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

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

module/admin/model/Article.js

beforeValidate (cb) {
  async.series([
    cb => super.beforeValidate(cb),
    cb => this.resolveFiles(this.get('files'), cb)
  ], cb);
}

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

module/admin/model/Article.js

afterSave (cb, insert) {
  async.series([
    cb => super.afterSave(insert, cb),
    cb => this.createPhotos(this.get('files'), cb)
  ], cb);
}

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

module/admin/model/Article.js

resolveFiles (files, cb) {
  if (!files || typeof files !== 'string') {
    return cb();
  }
  async.waterfall([
    cb => File.findById(files.split(',')).all(cb),
    (models, cb)=> {
      this.set('files', models);
      setImmediate(cb);
    }
  ], cb);
}

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

module/admin/model/Article.js

createPhotos (files, cb) {
  if (!(files instanceof Array)) {
    return cb();
  }
  let photos = [];
  async.eachSeries(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);
    setImmediate(cb);
  });
}, ()=> {
  if (!photos.length || this.get('mainPhotoId')) {
    return cb();
  }
  // set first photo as main
  this.set('files', null);
  this.set('mainPhotoId', photos[0].getId());
  this.forceSave(cb);
});
}

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

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

module/admin/model/Article.js

validateTags (cb, attr, params) {
  let items = this.get(attr);
  if (typeof items !== 'string') {
    return cb();
  }
  items = items.split(',').map(item => item.trim()).filter(item => item);
  items = ArrayHelper.unique(items);
  async.series([
    cb => this.unlinkAll('tags', cb),
    cb => async.eachSeries(items, this.resolveTag.bind(this), cb)
  ], cb);
}

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

module/admin/model/Article.js

resolveTag (name, cb) {
  async.waterfall([
    cb => Tag.findByName(name).one(cb),
    (model, cb)=> {
      if (model) {
        return this.link('tags', model, cb);
      }
      model = new Tag;
      model.set('name', name);
      model.save(cb);
    },
    (model, cb)=> model.hasError()
      ? cb()
      : this.link('tags', model, cb)
    ], cb);
}

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

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

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

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

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

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

module/admin/model/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')
    .viaTable('rel_article_tag', 'articleId', this.PK)
    .removeOnUnlink();
}