sequelize-admin-panel
Version:
Admin panel for sequelize ORM
535 lines (420 loc) • 26.8 kB
Markdown
# Sequelize admin panel
## TL;DR
Запустите
```sh
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)
### Пользователи и права
Встроенное управление пользователями и правами доступа на таблицы
### Локализация
Все переводы, включая сообщения, названия моделей, полей и действий передаются в едином объекте, разбитом по локалям. Система определяет нужную локаль по заголовкам браузера. В случае отсутствия перевода используется встроенный английский для сообщений или название модели или поля для моделей и полей соответственно.
## Быстрый старт
Создайте проект
```sh
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"}`, создайте БД
```sh
node node_modules/.bin/sequelize db:create
```
Создайте главный файл проекта `index.js`
```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`
```js
const db = require('./models')
require('sequelize-admin-panel').cli(db.sequelize)
```
обратите внимание, такая форма запуска работает потому что созданный по умолчанию файл ./models подключает все модели. Если вы не используете `sequelize-cli`, убедитесь что нужные модели импортированы (вызван Sequelize.define).
Создайте и синхронизируйте модели с помощью `sequelize-cli`
```sh
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 файлы моделей в соответствии с шаблоном:
```js
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
}
```
и синхронизируйте модели вызовом
```sh
node ./cli init --all # очистит все данные!
```
Недостатком первого способа является необходимость ручного описания изменений схем таблиц в файлах миграции, второго -- сброс содержимого базы данных при переинициализации. Подробнее о работе с sequelizejs [по ссылке](http://docs.sequelizejs.com/).
**Примечание:** попытка использовать Sequelize.sync({alter: true}) может приводить к дубликации constraints
Запустите сервер `node .` и откройте в браузере `http://localhost:3000/admin`. Готово!
## Настройка представлений
Для настройки представления полей модели создайте наследника класса `ModelAdmin`, в функции `init` задайте требуемые значения, а затем передайте пару `[MyModel,MyModelAdmin]` в функцию `sequelizeAdmin` свойством `models` третьего аргумента.
`MyModelAdmin.js`:
```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`:
```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:
```js
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` в форме ползунка
```js
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` определено следующим образом:
```js
point: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: '55.75027920193085,37.622483042183035',
}
```
тогда виджет с яндекс-картой можно описать так:
```js
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` вызовите
```js
this.addExtraResource(
'<script src="https://api-maps.yandex.ru/2.1/?lang=ru_RU" type="text/javascript"></script>'
)
```
либо
```js
// js в конце необходим парсеру
this.addExtraResource('https://api-maps.yandex.ru/2.1/?lang=ru_RU#js')
```
Теперь при любом нажатии на карту сериализованные координаты попадают в скрытый `input`, и передаются при сохранении на сервер с корректным именем поля. Отдельно стоит отметить, что при создании любых `id` лучше генерировать его с участием имени поля (`${fieldName}_mapid`) во избежание коллизий.
## Настройка сеттеров
При сохранении записи модели каждое её поле сохраняется сеттером в соответствии с её типом. Переопределить функцию сохранения можно с помощью свойтства `setter` методов `addFieldsDescriptions` и `setFieldDescription`. В случае примитивного поля, сеттер по умолчанию выглядит так:
```js
function defaultSetter(req, entry, fieldName, transaction) {
entry[fieldName] = req.body[fieldName]
}
```
Так же может возвращать `Promise`. В сеттере можно поместить дополнительную проверку:
```js
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`:
```js
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`
```js
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` определить простой коллбэк, принимающий записи по одной:
```js
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`):
```js
a(href=req.SA.queryExtender(["model", req.SA.modelName], {page: data.page+1}, []) >>
```
Аналогично, при использовании форм, в форму следует включить поле сгенерированное `req.SA.formExtender` с теми же аргументами кроме первого
```js
//- 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`, поэтому желательно описывать проверки при объявлении поля. Подробнее [по ссылке](http://docs.sequelizejs.com/)
Собственные проверки можно поместить в сеттер.
## Локализация
Запустите созданный ранее cli.js для получения объекта переводов
```sh
node cli dumptranslation [--empty] [--hints] > translations/[локаль].json
```
Отредактируйте локаль, переводы сообщений, имена моделей, полей и действий. Повторить по количеству локалей.
Так же вы можете задать подсказки для моделей и полей, в первом случае через свойство `hint`, во втором через одноименное с именем поля свойство с добавленным суффиксом `_hint`
```json
"sequelize_admin_user": {
"label": "",
"plural": "",
"hint": "подсказка модели",
"fields": {
"username": "",
"username_hint": "подсказка поля",
...
```
Сгруппируйте переводы и передайте свойство `translation` в третий аргумент `sequelizeAdmin`:
```js
app.use(
'/admin',
sequelizeAdmin(express, db.sequelize, {
translation: Object.assign(
{},
require('./translations/ru'),
require('./translations/de')
)
})
)
```
Автоматизация импорта содержимого папки translations на ваше усмотрение.
TODO: перевод ошибок
## Управление пользователями и правами
Для создания суперпользователя выполните
```sh
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
* сессии хранятся в памяти