sequelize-admin-panel

1.0.3 • Public • Published

Sequelize admin panel

TL;DR

Запустите

git clone https://github.com/eliseevmikhail/sequelize-admin-panel.git
cd sequelize-admin-panel
yarn install && cd demo && yarn install && yarn initdb && yarn start

и откройте localhost:3000

Основные возможности

Простая настройка

  • Просто подключите sequelizeAdmin как middleware по требуемому пути
  • В минимальной конфигурации достаточно передать экземпляр sequelize для получения рабочей админ-панели
  • Исходные модели не требуют модификации. Все дополнительные данные находятся в наследниках класса ModelAdmin

Настройка поведения полей

Возможно переопределение функций рендеринга полей моделей в списке записей и на экране редактирования записи

Поддержка типов и отношений

Поддерживается большинство примитивных типов Sequelize и все отношения (associations)

Пользователи и права

Встроенное управление пользователями и правами доступа на таблицы

Локализация

Все переводы, включая сообщения, названия моделей, полей и действий передаются в едином объекте, разбитом по локалям. Система определяет нужную локаль по заголовкам браузера. В случае отсутствия перевода используется встроенный английский для сообщений или название модели или поля для моделей и полей соответственно.

Быстрый старт

Создайте проект

mkdir sequelize-admin-demo; cd sequelize-admin-demo
yarn init
yarn add express sequelize sequelize-cli sequelize-admin-panel
yarn add mysql2 # или другой адаптер 
node node_modules/.bin/sequelize init

отредактируйте config/config.json для подключения к вашей базе данных, рекомендуется сразу добавить "define": {"charset": "utf8", "collate": "utf8_general_ci"}, создайте БД

node node_modules/.bin/sequelize db:create

Создайте главный файл проекта index.js

const express = require('express')
const db = require('./models')
const { sequelizeAdmin } = require('sequelize-admin-panel')
const app = express()
app.use('/admin', sequelizeAdmin(express, db.sequelize))
app.listen(process.env.PORT || 3000, () => console.log('Server started'))

создайте файл cli.js

const db = require('./models')
require('sequelize-admin-panel').cli(db.sequelize)

обратите внимание, такая форма запуска работает потому что созданный по умолчанию файл ./models подключает все модели. Если вы не используете sequelize-cli, убедитесь что нужные модели импортированы (вызван Sequelize.define).

Создайте и синхронизируйте модели с помощью sequelize-cli

node node_modules/.bin/sequelize model:generate --name MyModel --attributes name:STRING,count:INTEGER
node node_modules/.bin/sequelize db:migrate
node ./cli init # инициализация таблицы пользователей 

либо, если вы не хотите пользоваться sequelize migration, вручную создайте в каталоге models файлы моделей в соответствии с шаблоном:

module.exports = (sequelize, DataTypes) => {
  const MyModel = sequelize.define(
    'MyModel',
    {
      // <--- имя модели
      name: DataTypes.STRING,
      count: DataTypes.INTEGER
    },
    {}
  )
  MyModel.associate = function(models) {
    // associations can be defined here
    // like this: MyModel.belongsTo(models.OtherModel)
  }
  return MyModel
}

и синхронизируйте модели вызовом

node ./cli init --all # очистит все данные! 

Недостатком первого способа является необходимость ручного описания изменений схем таблиц в файлах миграции, второго -- сброс содержимого базы данных при переинициализации. Подробнее о работе с sequelizejs по ссылке.

Примечание: попытка использовать Sequelize.sync({alter: true}) может приводить к дубликации constraints

Запустите сервер node . и откройте в браузере http://localhost:3000/admin. Готово!

Настройка представлений

Для настройки представления полей модели создайте наследника класса ModelAdmin, в функции init задайте требуемые значения, а затем передайте пару [MyModel,MyModelAdmin] в функцию sequelizeAdmin свойством models третьего аргумента.

MyModelAdmin.js:

const { ModelAdmin } = require('sequelize-admin-panel')
 
class MyModelAdmin extends ModelAdmin {
  repr(req, entry) {
    return entry.name
  }
 
  init() {
    super.init()
    this.list_fields = ['id', 'name', 'count', 'nonExistedField']
    this.list_links = ['id']
    this.search_fields = ['name', 'count']
    this.ordering = ['count', '-name']
    this.list_per_page = 20
    this.editor_fields = ['id', 'name', 'count']
    this.readonly_fields = ['id']
    this.icon = '<span class="oi oi-media-play"></span>'
 
    this.setFieldDescription('name', {
      view: (req, entry, fieldName) => {
        return 'I love ' + entry.name + '!'
      },
      html: false
    })
 
    this.setFieldDescription('count', {
      // можно и Promise
      view: (req, entry, fieldName) => {
        const model = req.SA.modelAdminInstance.model,
          Sequelize = req.SA.Sequelize
        // другие модели доступны через req.SA.modelAdminManager.getModelAdminByModelName('model_name').model
        return (
          model
            .find({
              // получаем максимальное
              attributes: [
                [Sequelize.fn('max', Sequelize.col('count')), 'max_count']
              ]
            })
            // простое экранирование
            .then(entry => parseInt(entry.get('max_count'), 10))
            .then(max => `<progress value="${entry.count}" max="${max}">`)
        )
      },
      html: true // осторожно -- вывод не экранируется
    })
 
    // можно создать сколько угодно псевдо-полей
    this.setFieldDescription('nonExistedField', {
      view: (req, entry, fieldName) => {
        return entry.name.toUpperCase() + ' is great!'
      }
    })
  }
}
module.exports = MyModelAdmin

index.js:

...
app.use('/admin', sequelizeAdmin(express, db.sequelize, {
  models: [ [db.MyModel, require('./MyModelAdmin')] ]
}))
...

В результате поле name будет признаваться в любви, count показывать progressbar относительно наибольшего в таблице значения, а третье поле, отсутсвующее в таблице, заниматься восхвалением.

Рассмотрим по пунктам:

  • Функция repr создаёт представление записи модели (строки таблицы). Оно используется в таблице результатов если не заданы поля в list_fields, а также для создания списков отношений. Обрабатывается как plain-text, можно возвращать Promise.
  • Свойства list_fields, list_links, search_fields, ordering задают соответственно какие поля будут видны в таблице результатов, какие из них являются ссылками, по каким осуществляется поиск и сортировка по умолчанию (в том числе в списках отношений). Надо отметить, что поиск и сортировка возможна только по примитивных типам, присутствующим в базе (т.е. не по associations и не по искусственным полям).
  • Если нужно вывести все поля кроме указанных, перечислите их в list_exclude. Если нужно вывести вообще все поля, укажите в list_exclude несуществующее поле. list_fields при этом должно быть пустым.
  • В list_fields можно указать несуществующее поле, а затем описать его представление.
  • В icon можно указать произвольный html, рекомендуется иконочный шрифт.

Далее, вызовом setFieldDescription мы переопределяем функцию рендеринга представления поля строки таблицы и указываем, следует ли её вывод интерпретировать как html. Не забывайте об экранировании.

Легко заметить, что каждый вызов view делает одну и ту же работу по вычислению максимального значения. Правильным решением будет вынести её в специальный коллбэк beforeListRender. Обратите внимание, возвращаемое значение передаётся как свойство объекта req:

class MyModelAdmin extends ModelAdmin {
...
  beforeListRender(req, count, entries) {
    const model = req.SA.modelAdminInstance.model,
      Sequelize = req.SA.Sequelize
    return model.find({
        // получаем максимальное
        attributes: [
          [Sequelize.fn('max', Sequelize.col('count')), 'max_count']
        ]
      })
      // простое экранирование и кэширование
      .then(entry => req.MAX = parseInt(entry.get('max_count'), 10))
  }
...
  init() {
    ...
    this.setFieldDescription('count', {
      view: (req, entry, fieldName) =>
        `<progress value="${entry.count}" max="${req.MAX}">`,
      html: true
    })
    ...
  }

Настройка виджетов

Функции setFieldDescription можно передать свойство widget, в котором указать функцию рендеринга виджета с сигнатурой (req, entry, fieldName, value, options) и возвращающей html-код виджета.

Например, напишем виждет для поля count в форме ползунка

    this.setFieldDescription('count', {
      ...
      widget: (req, entry, fieldName, value, options) =>
      `<input type="range" min="0" max="100" step="1"
        class="form-control"
        ${options.readOnly ? 'disabled' : ''}
        name=${fieldName}
        value=${value} />`
    })

Обратите внимание, нужно обязательно указать name=fieldName, желательно установить текущее значение value и флаг readonly. Класс form-control из bootstrap растягивает виджет по ширине и в общем случае не обязателен.

Если виджет сложнее поля ввода, следует создать скрытый input и помещать в него актуальное значение на клиентской стороне. Например, пусть поле point определено следующим образом:

point: {
  type: DataTypes.STRING,
  allowNull: false,
  defaultValue: '55.75027920193085,37.622483042183035',
}

тогда виджет с яндекс-картой можно описать так:

widget: (req, entry, fieldName, value) => {
  return `
      <!-- hidden form field -->
      <input type=hidden name="${fieldName}" value=${value} />
      <div style='width: 100%; height: 240px; border: solid black 1px' id="${fieldName}_mapid"></div>
      <script>
        function ${fieldName}_map() {
          var coord = '${value}'.split(',')
          var map = new ymaps.Map("${fieldName}_mapid", {
            center: coord, 
            zoom: 7
          });
          var placemark = new ymaps.Placemark(coord);
          map.events.add('click', function (e) {
            var coords = e.get('coords');
            placemark.geometry.setCoordinates(coords);
            // setup hidden form field
            document.getElementsByName("${fieldName}")[0].value=coords.join(',')
          });
          map.geoObjects.add(placemark);
        }
        ymaps.ready(${fieldName}_map);
      </script>`
}

Чтобы подключить библиотеку яндекс карт в методе init вызовите

this.addExtraResource(
  '<script src="https://api-maps.yandex.ru/2.1/?lang=ru_RU" type="text/javascript"></script>'
)

либо

// js в конце необходим парсеру
this.addExtraResource('https://api-maps.yandex.ru/2.1/?lang=ru_RU#js')

Теперь при любом нажатии на карту сериализованные координаты попадают в скрытый input, и передаются при сохранении на сервер с корректным именем поля. Отдельно стоит отметить, что при создании любых id лучше генерировать его с участием имени поля (${fieldName}_mapid) во избежание коллизий.

Настройка сеттеров

При сохранении записи модели каждое её поле сохраняется сеттером в соответствии с её типом. Переопределить функцию сохранения можно с помощью свойтства setter методов addFieldsDescriptions и setFieldDescription. В случае примитивного поля, сеттер по умолчанию выглядит так:

function defaultSetter(req, entry, fieldName, transaction) {
  entry[fieldName] = req.body[fieldName]
}

Так же может возвращать Promise. В сеттере можно поместить дополнительную проверку:

function setter(req, entry, fieldName, transaction) {
  if (req.body[fieldName] % 10 === 0) entry[fieldName] = req.body[fieldName]
  else throw req.SA.getSequelizeError('multiples of ten', fieldName)
}

Глобальные переопределения

Можно создать промежуточного наследника класса ModelAdmin, в котором переопределить функции рендеринга и сеттер для различных типов, в том числе несуществующих. А затем ссылаться на имена этих типов вместо определения функций в свойствах view, widget и setter методов addFieldsDescriptions и setFieldDescription:

class CustomTypes extends ModelAdmin {
  init() {
    super.init()
    this.overrideTypeView('X_FILEBLOB', (req, entry, fieldName) => {
      return entry[fieldName]
        ? req.SA.tr('File size:') + entry[fieldName].length
        : req.SA.tr('No file')
    })
    this.overrideTypeWidget(
      'X_FILEBLOB',
      (req, entry, fieldName, value, options) => {
        return `<input type="file" class="form-control" ${
          options.readOnly ? 'disabled' : ''
        } name="${fieldName}" />`
      }
    )
    this.overrideTypeSetter(
      'X_FILEBLOB',
      (req, entry, fieldName, transaction) => {
        return new Promise((resolve, reject) => {
          const file = req.files[fieldName]
          if (file.size > 1048576)
            reject(req.SA.getSequelizeError('file length', fieldName))
          fs.readFile(file.path, (err, buf) => {
            if (err) reject(err)
            else {
              entry[fieldName] = buf
              resolve()
            }
          })
        })
      }
    )
  }
}
 
class OverridedAdmin extends CustomTypes {
  init() {
    super.init()
    this.addFieldDescriptions({
      file: {
        view: 'X_FILEBLOB',
        widget: 'X_FILEBLOB',
        setter: 'X_FILEBLOB'
      }
    })
  }
}

Действия (actions)

Для массовой обработки результатов в таблице записей вы можете задать action.

Для этого передайте массив действий в свойство ModelAdmin.actions. Допустим, мы хотим иметь возможность обнулять поле count

this.actions = [
  {
    name: 'zerofy',
    renderer: (req, res, modelAdmin, ids, exit) => {
      let transaction
      return req.SA.sequelizeInstance
        .transaction()
        .then(_transaction => (transaction = _transaction))
        .then(() =>
          modelAdmin.model.update(
            { count: 0 },
            { where: { [modelAdmin.pkName]: ids }, transaction }
          )
        )
        .then(() => transaction.commit())
        .catch(() => transaction.rollback())
        .then(() => exit())
    }
  }
]

Теперь достаточно отметить чекбоксы соответствующих записей и выбрать пункт zerofy из выпадающего списка в заголовке колонки с чекбоксами. По умолчанию там присутствует только пункт delete.

Коллбэк renderer позволяет полностью управлять отображением и переходами между страницами, как это сделано в действии delete, но в данном случае явно избыточен. Вместо него можно в свойстве changer определить простой коллбэк, принимающий записи по одной:

this.actions = [
  {
    name: 'zerofy',
    changer: entry => (entry.count = 0)
  }
]

Не всегда оптимально, но определённо намного проще.

TODO: возврат ошибок и affected rows

Обработка параметров запроса и сквозные параметры

После редактирования записи модели желательно вернуться к просмотру списка записей в том же состоянии, включая пагинацию, поиск и т.д. Для упрощения этого, некоторые параметры должны автоматически передаваться при навигации. Реализовано это следующим образом. Есть предопределённый список параметров и функция, которая создаёт URL перехода включающего все эти параметры в сериализованном виде в свойстве params. Например, если мы находся на странице /admin/model/modelName/?search=тест, при переходе на вторую страницу результатов поиска, URL ссылки перехода будет содержать /admin/model/modelName?params={"search":"тест","page":1}.

Все URL переходов внутри админ-панели должны создаваться функцией req.SA.queryExtender(), принимающей следующие параметры:

  • массив частей пути относительно корня админ-панели ['model', 'modelName']
  • объект с новыми параметрами {page: 1}
  • массив параметров, которые не следует включать в запрос или false для исключния всех.

В результате вышеупомянутая ссылка пагинации выглядит так (используется pug):

a(href=req.SA.queryExtender(["model", req.SA.modelName], {page: data.page+1}, []) >>

Аналогично, при использовании форм, в форму следует включить поле сгенерированное req.SA.formExtender с теми же аргументами кроме первого

//- action формы создаётся без параметров запроса
form(action=req.SA.queryExtender(["model", req.SA.modelName], {}, false), enctype="multipart/form-data")
  //- поле формы
  input(type="text", name="search", value=req.SA.params.search)
  //- параметры кроме search и page вставлены в форму
  | !{req.SA.formExtender({}, ["search", "page"])}

На что следует обратить внимание:

  • action формы не должно содержать параметров
  • значение поля формы берётся из req.SA.params.search
  • при вызове req.SA.formExtender необходимо явно исключить свойство search, иначе после очистки поле поиска будет использовано старое значение
  • page очищается из тех соображений, что номер текущей страницы после изменения результатов вывода не имеет смысла

Подробнее о req.SA.params.search: в него попадают содержимое десериализованного поля params, параметры запроса, параметры форм в порядке перекрытия.

Список сквозных параметров: ['sort', 'page', 'search', 'subwindow', 'action', 'entryIds', 'backurl']

Валидация

Проверки корректности вводных данных лежат на sequelize, поэтому желательно описывать проверки при объявлении поля. Подробнее по ссылке

Собственные проверки можно поместить в сеттер.

Локализация

Запустите созданный ранее cli.js для получения объекта переводов

node cli dumptranslation [--empty] [--hints] > translations/[локаль].json

Отредактируйте локаль, переводы сообщений, имена моделей, полей и действий. Повторить по количеству локалей. Так же вы можете задать подсказки для моделей и полей, в первом случае через свойство hint, во втором через одноименное с именем поля свойство с добавленным суффиксом _hint

  "sequelize_admin_user"{
    "label": "",
    "plural": "",
    "hint": "подсказка модели",
    "fields": {
      "username": "",
      "username_hint": "подсказка поля",
  ...

Сгруппируйте переводы и передайте свойство translation в третий аргумент sequelizeAdmin:

app.use(
  '/admin',
  sequelizeAdmin(express, db.sequelize, {
    translation: Object.assign(
      {},
      require('./translations/ru'),
      require('./translations/de')
    )
  })
)

Автоматизация импорта содержимого папки translations на ваше усмотрение.

TODO: перевод ошибок

Управление пользователями и правами

Для создания суперпользователя выполните

node cli createsuperuser логин пароль

В режиме разработки (process.env.NODE_ENV !== 'production') это необязательно, при отстутствии суперпользователей осуществляется автоматический вход пользователя EMERGENCY и выводится предупреждение.

В список моделей добавлена sequelize_admin_users, в которой перечислены все пользователи, а также есть возможность управления правами доступа к моделям согласно CRUD.

Ограничения

  • подразумевается что primary key является числовым
  • работа c paranoid option не проверялась
  • для парсинга форм используется formidable, желательно использовать enctype="multipart/form-data". Настройки парсера можно передать в свойстве formidableOpts третьего параметра sequelizeAdmin
  • требуется NodeJS не ниже 6.0.0 версии, проверялось на 6.14.2LTS и 8.11.2LTS
  • сессии хранятся в памяти

Package Sidebar

Install

npm i sequelize-admin-panel

Weekly Downloads

505

Version

1.0.3

License

gpl-3.0

Unpacked Size

1.47 MB

Total Files

125

Last publish

Collaborators

  • eliseevmikhail