UNPKG

sequelize-admin-panel

Version:
757 lines (697 loc) 21.8 kB
const promiseAllProps = require('promise-all-props') const getAssociations = require('../utils/getAssociations') const typeHelpers = { widget: Object.assign({}, require('./widgets')), view: Object.assign({}, require('./views')), setter: Object.assign({}, require('./setters')) } /** @class Wrapper over Sequelize.Model @prop {Array<ModelAdmin~action>} actions List of actions @prop {array<String>} list_fields List of fields, visible on list view. You can define nonexistent field here and define its view method via addFieldsDescriptions @prop {array<String>} list_links List of fields, which are links @prop {array<String>} list_exclude List of fields, excluded from list view You can define nonexistent field here to show all fields @prop {number} list_per_page Number of entries on list view page @prop {array<String>} search_fields Searchable fields if not set -- all STRING and TEXT fields @prop {array<String>} ordering Default model list ordering, prepend minus means desc order example: ['date', '-active'] @prop {array<String>} editor_fields List of fields, visible on entry editor screen @prop {array<String>} readonly_fields List of readonly fields @prop {String} icon Html code of icon in model list screen. Default &lt;span class="oi oi-list"&gt;&lt;/span&gt; */ class ModelAdmin { /** * @constructs ModelAdmin * @param {Sequelize.Model} model The model produced by Sequelize.define */ constructor(model) { this._model = model this.fieldProps = {} this._userdesc = {} this.extra_resources = [] // display list section this.actions = [] this.list_fields = [] this.list_links = [] this.list_exclude = [] this.list_per_page = 10 this.search_fields = [] this.ordering = [] // entry editor section this.editor_fields = [] this.readonly_fields = [] this.icon = '<span class="oi oi-list"></span>' this.init() } /** User-defined initialization * @private */ init() {} checkProps() { ;[ 'list_fields', 'list_links', 'list_exclude', 'search_fields', 'ordering', 'editor_fields', 'readonly_fields', 'actions' ].map(varName => { if (!(this[varName] instanceof Array)) throw new Error(`ModelAdmin.${varName} must be an array`) }) this.actions.map((action, i) => { if ( action.name == undefined || (action.renderer && action.changer) || (!action.renderer && !action.changer) ) throw new Error(`ModelAdmin.actions[${i}] is malformed`) }) } /** * Walk over this.fieldProps, filter by cb * @private * @param {function} cb * @returns {Array<String>} list of matched fieldnames */ filterFields(cb) { return Object.keys(this.fieldProps) .map(name => this.fieldProps[name]) .filter(cb) .map(fprop => fprop.name) } /** * create description of all attributes, associations, pseudo-fields * @private */ populateFieldsProps() { // collect attributes Object.keys(this._model.attributes) .filter(k => !this._model.attributes[k].references) .map( k => (this.fieldProps[k] = { isAttribute: true, field: this._model.attributes[k] }) ) // collect associations Object.keys(this._model.associations).map( k => (this.fieldProps[k] = { isAssociation: true, field: this._model.associations[k] }) ) // collect pseudo-fields this.editor_fields.concat(this.list_fields).map(k => { if (!this.fieldProps[k]) this.fieldProps[k] = { isArtificial: true } }) // populate field properties Object.keys(this.fieldProps).map((fieldName, index) => { const fprop = this.fieldProps[fieldName] fprop.name = fieldName // type fprop.type = fprop.isAssociation ? fprop.field.associationType : fprop.isAttribute ? fprop.field.type.constructor.name : undefined fprop.isLink = this.list_links.length ? this.list_links.includes(fieldName) : this.list_fields.length ? this.list_fields[0] === fieldName : index === 0 fprop.readOnly = this.readonly_fields.includes(fieldName) fprop.editable = this.editor_fields.length ? this.editor_fields.includes(fieldName) && !fprop.isArtificial : !fprop.isArtificial fprop.needSave = fprop.editable && !fprop.readOnly && fieldName !== this.pkName // ;['view', 'widget', 'setter'].map(cbname => { const cb = this._userdesc[fieldName] && this._userdesc[fieldName][cbname] ? this._userdesc[fieldName][cbname] : typeHelpers[cbname][fprop.type] || typeHelpers[cbname]['STRING'] fprop[cbname] = cb instanceof Function ? cb : typeHelpers[cbname][cb] }) fprop.isHtml = this._userdesc[fieldName] && this._userdesc[fieldName].html fprop.searchable = this.search_fields.length ? this.search_fields.includes(fieldName) && fprop.isAttribute : ['STRING', 'TEXT'].includes(fprop.type) fprop.sortable = fprop.isAttribute }) // repr pseudo field this.fieldProps.___repr___ = { isRepr: true, isArtificial: true, isLink: true, view: this.repr.bind(this) } } /** * Set description for field fieldName * @param {String} fieldName * @param {ModelAdmin~fieldDescription} description */ setFieldDescription(fieldName, description) { this._userdesc[fieldName] = description } /** * Add field descriptions * @param {ModelAdmin~fieldDescriptions} descriptions */ addFieldDescriptions(descriptions) { Object.assign(this._userdesc, descriptions) } /** * Push extra js or css to page <head> section. * Resources can be tag <script|link...> or url * or array of those. Url type is determined by endsWith('js|css') * so you can add #js to end. * @param {String|Array<String>} resources */ addExtraResource(resources) { if (!(resources instanceof Array)) resources = [resources] resources.map(res => { res = res.trim() if (!res.startsWith('<')) { if (res.endsWith('js')) res = `<script src="${res}" type="text/javascript"></script>` else if (res.endsWith('css')) res = `<link href="${res}" rel="stylesheet"></link>` else { console.warn(`Unrecognized resource "${res}", neither js nor css`) res = null } } if (res && !this.extra_resources.includes(res)) this.extra_resources.push(res) }) } /** * Override widget render function for selected type * * @param {String} type type for override, e.g. enum of Sequelise.TYPE - DATE, HasOne etc * @param {ModelAdmin~widgetRenderer} widgetRenderer * @return widget html */ overrideTypeWidget(type, renderer) { typeHelpers.widget[type] = renderer } /** * Override view render function for selected field type * * @param {String} type type for override, e.g. DATE, HasOne etc * @param {ModelAdmin~viewRenderer} renderer render function */ overrideTypeView(type, renderer) { typeHelpers.view[type] = renderer } /** * Override setter function for selected field type * * @param {String} type type for override, e.g. DATE, HasOne etc * @param {ModelAdmin~setter} setter render function */ overrideTypeSetter(type, setter) { typeHelpers.setter[type] = setter } /** * Model instance * @type {Sequelize.Model} * @readonly */ get model() { return this._model } /** * Model name * @type {String} * @readonly */ get name() { return this._model.name } /** * Model plural name * @type {String} * @readonly */ get namePlural() { return this._model.options.name.plural } /** * Model primary key name * @type {String} * @readonly */ get pkName() { return this._model.primaryKeyAttribute } /** * Override this for custom entry representation, * default is entry label + primary key * @param {Express.Request} req * @param {Sequelize.ModelInstance} entry * @returns {String|Promise<String>} */ repr(req, entry) { return Promise.resolve(req.SA.trModel(this) + ' ' + entry[this.pkName]) } /** * Override this to do something before model list will rendered, * for example do subquery or compute average. You have to cache the result * within req object to use it in {@link ModelAdmin~viewRenderer} callback * @param {Express.Request} req * @param {Number} count Count of selected entries considering search param * @param {Array<Object>} entries Selected entries */ beforeListRender(req, count, entries) {} /** * Render model view instead default model list * @method modelListRenderer * @param {Express.Request} req * @param {Express.Response} res * @param {Function} next * @memberof ModelAdmin */ /** * Render entry editor view instead default one * @method entryRenderer * @param {Express.Request} req * @param {Express.Response} res * @param {Function} next * @memberof ModelAdmin */ /** * Get all entries sorted by this.ordering * @private * @returns {Promise<Sequelize.ModelInstance>} */ getAllEntries() { const query = {} query.order = this.ordering.length > 0 ? this.ordering.map(fieldName => { let desc = false if (fieldName.charAt(0) === '-') { fieldName = fieldName.substring(1, fieldName.length) desc = true } return [fieldName, desc ? 'DESC' : 'ASC'] }) : [] return this._model.findAll(query) } /** * @private * @param {Express.Request} req * @param {Sequelize.Instance} entry * @param {String} fieldName - association name we looking for * @param {boolean} useFormData - use req.body[fieldName] or entry[fieldName] * @returns {*|Promise<*>} */ getFieldValue(req, entry, fieldName, useFormData) { // simple field if (this.fieldProps[fieldName].isAttribute) return useFormData ? req.body[fieldName] : entry[fieldName] // associated field else { const { targetGetter, targetModelAdmin } = getAssociations( req, entry, fieldName ) return useFormData ? req.body[fieldName] === undefined ? [] : req.SA.utils .toArray(req.body[fieldName]) .map(id => parseInt(id, 10)) : entry.isNewRecord // TODO if default value? ? [] : entry[targetGetter]().then(selectedEntries => req.SA.utils .toArray(selectedEntries) .map(targetEntry => targetEntry[targetModelAdmin.pkName]) ) } } genSequelizeQueryOrderClause() { const query = {} if (this.ordering.length) { query.order = this.ordering.map(fieldName => { let desc = false if (fieldName.charAt(0) === '-') { fieldName = fieldName.substring(1, fieldName.length) desc = true } return [fieldName, desc ? 'DESC' : 'ASC'] }) } return query } /** * Generate sequelize query clause * @param {Express.Request} req * @private * @returns {Object} */ genSequelizeQueryClause(req) { const query = {} if (!req.SA.params.showAll) { query.limit = this.list_per_page query.offset = this.list_per_page * parseInt(req.SA.params.page || 0, 10) } if (req.SA.params.sort) query.order = [[req.SA.params.sort, req.SA.params.desc ? 'DESC' : 'ASC']] else Object.assign(query, this.genSequelizeQueryOrderClause()) if (req.SA.params.search) query.where = { [req.SA.Sequelize.Op.or]: this.filterFields(f => f.searchable).map( fieldName => { return { [fieldName]: { [req.SA.Sequelize.Op.like]: '%' + req.SA.params.search + '%' } } } ) } return query } /** * Compute data for model table * @private * @param {Express.Request} req - request object for paging, sorting, filtering * @returns {Object} */ getDisplayListData(req) { let total return promiseAllProps({ actions: this.actions.map(act => { act.label = req.SA.trAction(act.name) return act }), headers: this.list_fields.map(fieldName => { const label = this.fieldProps[fieldName].isRepr ? req.SA.trModel(this).plural : req.SA.trField(this.name, fieldName) return { isSortable: this.fieldProps[fieldName].sortable, label, fieldName } }), rows: this._model .findAndCountAll(this.genSequelizeQueryClause(req)) .then(({ count, rows }) => { // common work for all entries return Promise.resolve(this.beforeListRender(req, count, rows)) .then(() => ({ count, rows })) .catch(e => { throw e }) }) .then(({ count, rows: entries }) => { total = count return Promise.all( entries.map(entry => promiseAllProps({ pk: entry[this.pkName], columns: Promise.all( this.list_fields.map(fieldName => promiseAllProps({ isLink: this.fieldProps[fieldName].isLink, isHtml: this.fieldProps[fieldName].isHtml, value: this.fieldProps[fieldName].view( req, entry, fieldName ), type: this.fieldProps[fieldName].type }) ) ) }) ) ) }) }).then(data => { data.total = total data.perPage = !req.SA.params.showAll ? this.list_per_page : Number.MAX_SAFE_INTEGER data.page = parseInt(req.SA.params.page || 0, 10) data.pages = Math.ceil(data.total / data.perPage) return data }) } /** * @private * @param {Express.Request} req * @param {Sequelize.ModelInstance} entry * @param {Sequelize.ValidationError} validationError * @returns {Promise} */ getEditorData(req, entry, validationError) { return promiseAllProps({ header: entry.isNewRecord ? req.SA.tr('new') + ' ' + req.SA.trModel(this) : Promise.resolve(this.repr(req, entry)).then( repr => req.SA.trModel(this) + ' • ' + repr ), fields: Promise.all( (this.editor_fields ? this.editor_fields.filter( fieldName => this.fieldProps[fieldName].editable ) : this.filterFields(f => f.editable) ).map(fieldName => Promise.resolve( this.getFieldValue(req, entry, fieldName, !!validationError) ).then(value => promiseAllProps({ label: req.SA.trField(this.name, fieldName), widget: this.fieldProps[fieldName].widget( req, entry, fieldName, value, { readOnly: this.fieldProps[fieldName].readOnly } ), targetModel: !this.fieldProps[fieldName].isAttribute ? getAssociations(req, entry, fieldName).targetModelAdmin.name : null, name: fieldName, errors: validationError ? validationError.get(fieldName) : null }) ) ) ) }) } /** * @private * @param {Express.Request} req * @param {Sequelize.ModelInstance} entry * @throws {Sequelize.ValidationError} * @returns {Promise} */ save(req, entry) { let transaction return req.SA.sequelizeInstance .transaction() .then(_transaction => (transaction = _transaction)) .then(() => Promise.all( this.filterFields(f => f.needSave).map(fieldName => this.fieldProps[fieldName].setter( req, entry, fieldName, transaction ) ) ) ) .then(() => entry.save({ transaction }) ) .catch(e => { transaction.rollback() throw e }) .then(() => transaction.commit()) } /** * @private * @param {Express.Request} req * @param {Express.Response} res * @param {String} action * @param {Array<*>} ids list of selected model primaryKeys * @returns {Promise} */ doAction(req, res, action, ids) { const actionObject = this.actions.filter(a => a.name === action)[0] if (actionObject && typeof actionObject.renderer === 'function') return actionObject.renderer(req, res, this, ids, () => { res.redirect( 302, req.SA.queryExtender(['model', this.name], {}, ['action', 'entryIds']) ) }) else if (actionObject && typeof actionObject.changer === 'function') { let transaction return req.SA.sequelizeInstance .transaction() .then(_transaction => (transaction = _transaction)) .then(() => this._model.findAll({ where: { [this.pkName]: ids }, transaction }) ) .then(entries => Promise.all( entries.map(entry => { return Promise.resolve(actionObject.changer(entry)) .then(() => entry.save({ transaction })) .catch(e => { throw e }) }) ) ) .then(() => transaction.commit()) .catch(() => transaction.rollback()) .then(() => { res.redirect( 302, req.SA.queryExtender(['model', this.name], {}, [ 'action', 'entryIds' ]) ) }) } } /** * @private */ postInit() { this.checkProps() this.populateFieldsProps() if (!this.list_fields.length) this.list_fields = ['___repr___'] this.actions.push({ name: 'delete', renderer: require('./deleteAction') }) return this } } /** * Represent action structure. Define name and *ONE* of [renderer|changer] callback. * @typedef ModelAdmin~action * @type {object} * @prop {String} name action name * @prop {ModelAdmin~actionRenderer} renderer render function * @prop {ModelAdmin~actionChanger} changer simple entry change function */ /** * Full-featured render callback. You can create own navigation, confirmation, etc. * Also, you must handle transaction manually. * To return to entry list call exit callback * * @callback ModelAdmin~actionRenderer * @param {Express.Request} req * @param {Express.Response} res * @param {ModelAdmin} modelAdmin ModelAdmin instance, associated with current model * @param {Array<*>} ids list of selected model primaryKeys * @param {Function} exit return callback, redirects to entry list * @returns {Promise<String>} page html * @todo show errors */ /** * Simple per-entry update callback, just get entry and update it fields * @callback ModelAdmin~actionChanger * @param {Object} entry * @returns {Promise|any} * @todo show errors */ /** * @typedef ModelAdmin~fieldDescriptions * @type {object} * @prop {ModelAdmin~fieldDescription} $fieldName */ /** * @typedef ModelAdmin~fieldDescription * @type {object} * @prop {ModelAdmin~viewRenderer|String} view View renderer or type name * @prop {boolean} html Indicates that view produce html, so you must escape it self * @prop {ModelAdmin~widgetRenderer|String} widget Widget renderer or type name * @prop {ModelAdmin~setter|String} setter setter function or type name */ /** * Render entry field for table view * * @callback ModelAdmin~viewRenderer * @param {Express.Request} req * @param {Sequelize.ModelInstance} entry * @param {String} fieldName * @returns {String|Promise<String>} */ /** * Render entry field for editor widget * * @callback ModelAdmin~widgetRenderer * @param {Express.Request} req * @param {Sequelize.ModelInstance} entry * @param {String} fieldName * @param {*} value Raw field value from database entry or http post request * @param {Object} options widget options, readOnly mostly * @returns {String|Promise<String>} rendered html widget */ /** * Set value of entry field $fieldName given at req.body[fieldName] * * @callback ModelAdmin~setter * @param {Express.Request} req * @param {Sequelize.ModelInstance} entry * @param {String} fieldName * @param {*} transaction * @returns {Promise} */ // /** // * Object, containing header list and two-dimensional array with field representations // * @typedef ModelAdmin~displayListData // * @type {object} // * @prop {array} header list of field labels // * @prop {array} rows list of rows // * @prop {array} rows.columns list of field representations, see ModelAdmin~viewRenderer // * @see ModelAdmin~viewRenderer // */ // /** // * List of objects, containing label, widget and errors // * @typedef ModelAdmin~editorData // * @type {array} // * @prop {String} label widget label // * @prop {String} widget html widget // * @prop {array<Sequelise.ValidationErrorItem>} errors errors, occured with previous saving // * @see ModelAdmin~viewRenderer // */ module.exports = ModelAdmin