├── public ├── favicon.ico ├── express-admin.css └── express-admin.js ├── lib ├── template │ ├── index.js │ └── table.js ├── format │ ├── index.js │ ├── list.js │ └── form.js ├── qb │ ├── index.js │ ├── otm.js │ ├── mtm.js │ ├── tbl.js │ ├── partials.js │ └── lst.js ├── data │ ├── index.js │ ├── pagination.js │ ├── tbl.js │ ├── otm.js │ ├── stc.js │ ├── list.js │ └── mtm.js ├── editview │ ├── validate.js │ ├── index.js │ └── upload.js ├── app │ ├── routes.js │ └── settings.js ├── db │ ├── schema.js │ ├── update.js │ └── client.js └── listview │ └── filter.js ├── views ├── 404.html ├── breadcrumbs.html ├── js │ ├── theme.html │ └── layout.html ├── pagination.html ├── editview │ ├── view.html │ ├── inline.html │ └── column.html ├── base.html ├── editview.html ├── listview.html ├── login.html ├── header.html ├── listview │ ├── filter.html │ └── column.html └── mainview.html ├── routes ├── 404.js ├── login.js ├── index.js ├── render.js ├── mainview.js ├── auth.js ├── listview.js └── editview.js ├── .editorconfig ├── .gitignore ├── config ├── libs.json ├── themes.json └── lang │ ├── cn.json │ ├── ko.json │ ├── en.json │ ├── tr.json │ ├── bg.json │ ├── es.json │ ├── ru.json │ └── de.json ├── CHANGELOG.md ├── LICENSE ├── package.json ├── app.js └── README.md /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simov/express-admin/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /lib/template/index.js: -------------------------------------------------------------------------------- 1 | 2 | exports = module.exports = { 3 | table: require('./table') 4 | } 5 | -------------------------------------------------------------------------------- /views/404.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |

404

4 |

{{string.notfound}}

5 |
6 | -------------------------------------------------------------------------------- /lib/format/index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | list: require('./list'), 4 | form: require('./form') 5 | } 6 | -------------------------------------------------------------------------------- /routes/404.js: -------------------------------------------------------------------------------- 1 | 2 | exports.get = (req, res, next) => { 3 | res.locals.partials = { 4 | content: '404' 5 | } 6 | next() 7 | } 8 | -------------------------------------------------------------------------------- /routes/login.js: -------------------------------------------------------------------------------- 1 | 2 | exports.get = (req, res, next) => { 3 | res.locals.partials = { 4 | content: 'login' 5 | } 6 | next() 7 | } 8 | -------------------------------------------------------------------------------- /lib/qb/index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = (x) => ({ 3 | lst: require('./lst')(x), 4 | tbl: require('./tbl')(x), 5 | otm: require('./otm')(x), 6 | mtm: require('./mtm')(x), 7 | partials: require('./partials')(x) 8 | }) 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | 10 | pids 11 | logs 12 | results 13 | 14 | npm-debug.log 15 | node_modules 16 | public/upload 17 | coverage 18 | .idea 19 | package-lock.json 20 | -------------------------------------------------------------------------------- /lib/data/index.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports = { 3 | tbl: require('./tbl'), 4 | otm: require('./otm'), 5 | mtm: require('./mtm'), 6 | stc: require('./stc'), 7 | list: require('./list'), 8 | pagination: require('./pagination') 9 | } 10 | -------------------------------------------------------------------------------- /views/breadcrumbs.html: -------------------------------------------------------------------------------- 1 | 2 | {{#breadcrumbs}} 3 | 16 | {{/breadcrumbs}} 17 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | 2 | exports.auth = require('./auth' ) 3 | exports.login = require('./login') 4 | exports.notfound = require('./404') 5 | 6 | exports.mainview = require('./mainview') 7 | exports.listview = require('./listview') 8 | exports.editview = require('./editview') 9 | 10 | exports.render = require('./render') 11 | -------------------------------------------------------------------------------- /views/js/theme.html: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /views/js/layout.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /routes/render.js: -------------------------------------------------------------------------------- 1 | 2 | exports.admin = (req, res) => { 3 | res.locals.partials.header = 'header' 4 | res.locals.partials.breadcrumbs = 'breadcrumbs' 5 | res.locals.partials.theme = 'js/theme' 6 | res.locals.partials.layout = 'js/layout' 7 | 8 | res.render('base', { 9 | user: req.session.user, 10 | csrf: req.csrfToken(), 11 | url: { 12 | home: '/' 13 | } 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /config/libs.json: -------------------------------------------------------------------------------- 1 | { 2 | "bootstrap": "/csslib/bootstrap.min.css", 3 | "css": [ 4 | "/csslib/bootstrap-datetimepicker.min.css", 5 | "/csslib/chosen.min.css", 6 | "/express-admin.css" 7 | ], 8 | "js": [ 9 | "/jslib/jquery-1.9.1.min.js", 10 | "/jslib/bootstrap.min.js", 11 | "/jslib/bootstrap-datetimepicker.min.js", 12 | "/jslib/chosen.jquery.min.js", 13 | "/jslib/cookie.js", 14 | "/express-admin.js" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /lib/data/pagination.js: -------------------------------------------------------------------------------- 1 | 2 | var pagination = require('sr-pagination') 3 | var qb = require('../qb')() 4 | 5 | 6 | exports.get = (args, done) => { 7 | var str = qb.lst.pagination(args) 8 | args.db.client.query(str, (err, rows) => { 9 | if (err) return done(err) 10 | var total = parseInt(rows[0].count) 11 | var rows = args.config.listview.page 12 | var page = parseInt(args.page || 1) 13 | done(null, pagination({page: page, links: 9, rows: rows, total: total})) 14 | }) 15 | } 16 | -------------------------------------------------------------------------------- /lib/data/tbl.js: -------------------------------------------------------------------------------- 1 | 2 | var qb = require('../qb')() 3 | 4 | 5 | // get table records 6 | exports.get = (args, done) => { 7 | if (args.post) { 8 | var table = args.config.table 9 | var rows = args.post[table.name].records || [] 10 | return done(null, rows) 11 | } 12 | 13 | var str = qb.tbl.select(args) 14 | args.db.client.query(str, (err, rows) => { 15 | if (err) return done(err) 16 | var result = [] 17 | for (var i=0; i < rows.length; i++) { 18 | result.push({columns: rows[i]}) 19 | } 20 | done(null, result) 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /lib/qb/otm.js: -------------------------------------------------------------------------------- 1 | 2 | var x = null 3 | var z = require('./partials')() 4 | 5 | 6 | function select (args, ref) { 7 | var concat = z.concat(ref.columns,ref.table,undefined,' ') 8 | 9 | var pk = x.as(z.concat(ref.pk,ref.table,z.schema(ref),','),x.name('__pk')) 10 | var text = x.as(concat,x.name('__text')) 11 | 12 | var str = [ 13 | x.select([pk,text]), 14 | x.from(x.name(ref.table,z.schema(ref))), 15 | ';' 16 | ].join(' ') 17 | 18 | args.debug && console.log('otm', str) 19 | return str 20 | } 21 | 22 | module.exports = (instance) => { 23 | if (instance) x = instance; 24 | return {select} 25 | } 26 | -------------------------------------------------------------------------------- /views/pagination.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 32 |
33 | -------------------------------------------------------------------------------- /lib/data/otm.js: -------------------------------------------------------------------------------- 1 | 2 | var async = require('async') 3 | var qb = require('../qb')() 4 | 5 | 6 | // modifies `args.config.columns` with `value` 7 | 8 | function getData (args, ref, done) { 9 | var str = qb.otm.select(args, ref) 10 | args.db.client.query(str, (err, rows) => { 11 | if (err) return done(err) 12 | done(null, rows) 13 | }) 14 | } 15 | 16 | exports.get = (args, done) => { 17 | async.each(args.config.columns, (column, done) => { 18 | if ((!column.oneToMany && !column.manyToMany) || !column.control.select) return done() 19 | var ref = column.oneToMany ? column.oneToMany : column.manyToMany.ref 20 | getData(args, ref, (err, rows) => { 21 | if (err) return done(err) 22 | column.value = rows 23 | done() 24 | }) 25 | }, done) 26 | } 27 | 28 | exports._getData = getData 29 | -------------------------------------------------------------------------------- /config/themes.json: -------------------------------------------------------------------------------- 1 | [ 2 | {"key": "default", "name": "Default"}, 3 | {"key": "cerulean", "name": "Cerulean"}, 4 | {"key": "cosmo", "name": "Cosmo"}, 5 | {"key": "cyborg", "name": "Cyborg"}, 6 | {"key": "darkly", "name": "Darkly"}, 7 | {"key": "flatly", "name": "Flatly"}, 8 | {"key": "journal", "name": "Journal"}, 9 | {"key": "lumen", "name": "Lumen"}, 10 | {"key": "paper", "name": "Paper"}, 11 | {"key": "readable", "name": "Readable"}, 12 | {"key": "sandstone", "name": "Sandstone"}, 13 | {"key": "simplex", "name": "Simplex"}, 14 | {"key": "slate", "name": "Slate"}, 15 | {"key": "spacelab", "name": "Spacelab"}, 16 | {"key": "superhero", "name": "Superhero"}, 17 | {"key": "united", "name": "United"}, 18 | {"key": "yeti", "name": "Yeti"} 19 | ] 20 | -------------------------------------------------------------------------------- /lib/format/list.js: -------------------------------------------------------------------------------- 1 | 2 | var moment = require('moment') 3 | 4 | 5 | exports.value = (column, value) => { 6 | if (/^(datetime|timestamp).*/i.test(column.type)) { 7 | return value ? moment(value).format('ddd MMM DD YYYY HH:mm:ss') : '' 8 | } 9 | 10 | else if (/^date.*/i.test(column.type)) { 11 | return value ? moment(value).format('ddd MMM DD YYYY') : '' 12 | } 13 | 14 | else if (column.control.radio) { 15 | // mysql 16 | if (typeof value === 'number') { 17 | return (value == 1) // flip 18 | ? column.control.options[0] 19 | : column.control.options[1] 20 | } 21 | // pg 22 | if (typeof value === 'boolean') { 23 | return (value == true) // flip 24 | ? column.control.options[0] 25 | : column.control.options[1] 26 | } 27 | } 28 | 29 | else if (column.control.binary) { 30 | return value ? 'data:image/jpeg;base64,'+value.toString('base64') : value 31 | } 32 | 33 | else { 34 | return value 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /views/editview/view.html: -------------------------------------------------------------------------------- 1 | {{#view}} 2 |
3 | {{#tables}} 4 | 5 | 6 | {{#records}} 7 | 8 | 16 | 17 | {{#columns}} 18 | 19 | {{>column}} 20 | 21 | {{/columns}} 22 | {{/records}} 23 | 24 | {{^records}} 25 | 26 | 29 | 30 | {{#blank}} 31 | 32 | {{>column}} 33 | 34 | {{/blank}} 35 | {{/records}} 36 | 37 |
38 | {{/tables}} 39 |
40 | {{/view}} 41 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Change Log 3 | 4 | ## v2.0.0 (2023/02/14) 5 | - **Change:** the admin cli is no longer available 6 | - **Change:** the admin is now exposed as an Express.js middleware only 7 | - **Change:** config.json `server` key is no longer needed 8 | - **Change:** config.json `app` was renamed to `admin` 9 | - **Change:** config.json new required `admin.settings` config should be set the absolute path to your `settings.json` file 10 | - **New:** config.json new `admin.readonly` flag replacing the `-v` cli argument 11 | - **New:** config.json new `admin.debug` flag replacing the `-l` cli argument 12 | - **New:** config.json new `admin.favicon` key to specify a path to a custom favicon.ico file to use 13 | - **New:** config.json new `admin.footer` text and URL to use for the admin's footer 14 | - **New:** config.json new `admin.locale` path to a custom locale file to use 15 | - **New:** config.json new `admin.session` object to configure the underlying session middleware 16 | - **Change:** users.json each user now contains just a `name` and `pass` fields as clear text 17 | 18 | ## v1.0.0 (2013/05/26) 19 | - official release 20 | -------------------------------------------------------------------------------- /lib/data/stc.js: -------------------------------------------------------------------------------- 1 | 2 | // load static values from settings 3 | // modifies `args.config.columns` with `value` 4 | 5 | function getSelect (options) { 6 | var values = [] 7 | for (var i=0; i < options.length; i++) { 8 | if ('string' === typeof options[i]) { 9 | values.push({__pk: options[i], __text: options[i]}) 10 | } 11 | else if ('object' === typeof options[i]) { 12 | var value = Object.keys(options[i])[0] 13 | var text = options[i][value] 14 | values.push({__pk: value, __text: text}) 15 | } 16 | } 17 | return values 18 | } 19 | 20 | function getRadio (options) { 21 | return [ 22 | {text: options[0], value: 1}, 23 | {text: options[1], value: 0} 24 | ] 25 | } 26 | 27 | exports.get = (args) => { 28 | var columns = args.config.columns 29 | for (var i=0; i < columns.length; i++) { 30 | var control = columns[i].control 31 | if (!(control.options instanceof Array)) continue 32 | 33 | if (control.select) { 34 | columns[i].value = getSelect(control.options) 35 | } 36 | else if (control.radio) { 37 | columns[i].value = getRadio(control.options) 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /routes/mainview.js: -------------------------------------------------------------------------------- 1 | 2 | exports.get = (req, res, next) => { 3 | var settings = res.locals._admin.settings 4 | var custom = res.locals._admin.custom 5 | 6 | var tables = [] 7 | for (var key in settings) { 8 | var item = settings[key] 9 | if (!item.mainview.show || !item.table.pk || item.table.view) continue 10 | tables.push({slug: item.slug, name: item.table.verbose}) 11 | } 12 | 13 | var views = [] 14 | for (var key in settings) { 15 | var item = settings[key] 16 | if (!item.mainview.show || !item.table.view) continue 17 | views.push({slug: item.slug, name: item.table.verbose}) 18 | } 19 | 20 | var customs = [] 21 | for (var key in custom) { 22 | var item = custom[key].app 23 | if (!item || !item.mainview || !item.mainview.show) continue 24 | customs.push({slug: item.slug, name: item.verbose}) 25 | } 26 | 27 | res.locals.tables = !tables.length ? null : {items: tables} 28 | res.locals.views = !views.length ? null : {items: views} 29 | res.locals.custom = !customs.length ? null : {items: customs} 30 | 31 | res.locals.partials = { 32 | content: 'mainview' 33 | } 34 | 35 | next() 36 | } 37 | -------------------------------------------------------------------------------- /lib/editview/validate.js: -------------------------------------------------------------------------------- 1 | 2 | var moment = require('moment') 3 | var validator = require('mysql-validator') 4 | 5 | 6 | exports.value = (column, value) => { 7 | 8 | if (value === '' || value === null || value === undefined 9 | || (value instanceof Array && !value.length)) { 10 | 11 | // should check for column.defaultValue when creating a record 12 | 13 | if (column.allowNull) return null 14 | if (column.control.binary) return null // temp disabled 15 | return new Error('Column '+column.name+' cannot be empty.') 16 | } 17 | 18 | value = format(column, value) 19 | 20 | return validator.check(value, column.type) 21 | } 22 | 23 | function format (column, value) { 24 | if (column.control.date) { 25 | if (!value || !moment(value).isValid()) return value 26 | return moment(value).format('YYYY-MM-DD') 27 | } 28 | else if (column.control.datetime) { 29 | if (!value || !moment(value).isValid()) return value 30 | return moment(value).format('YYYY-MM-DD HH:mm:ss') 31 | } 32 | // else if (column.control.radio) { 33 | // if (value === true) return 't' 34 | // else if (value === false) return 'f' 35 | // } 36 | else { 37 | return value 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /lib/data/list.js: -------------------------------------------------------------------------------- 1 | 2 | var qb = require('../qb')() 3 | var format = require('../format') 4 | 5 | 6 | exports.get = (args, done) => { 7 | var columns = args.config.columns 8 | 9 | var visible = [] 10 | var names = [] 11 | for (var i=0; i < columns.length; i++) { 12 | if (!columns[i].listview.show) continue 13 | visible.push(columns[i]) 14 | names.push(columns[i].verbose) 15 | } 16 | columns = visible 17 | 18 | qb.lst.create(args) 19 | 20 | args.db.client.query(args.query, (err, rows) => { 21 | if (err) return done(err) 22 | var idx = args.config.table.view ? 0 : 1 23 | 24 | var records = [] 25 | for (var i=0; i < rows.length; i++) { 26 | var pk = idx ? { 27 | id: rows[i]['__pk'], 28 | text: rows[i][columns[0].name] 29 | } : null 30 | var values = [] 31 | for (var j=idx; j < columns.length; j++) { 32 | var value = rows[i][columns[j].name] 33 | value = format.list.value(columns[j], value) 34 | if (columns[j].manyToMany && value) value = {mtm: value.split(',')} 35 | values.push(value) 36 | } 37 | records.push({pk, values}) 38 | } 39 | done(null, {columns: names, records}) 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2012-present Simeon Velichkov (https://github.com/simov/express-admin) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/app/routes.js: -------------------------------------------------------------------------------- 1 | 2 | var regex = { 3 | login: /^\/login/i, 4 | logout: /^\/logout/i, 5 | tables: null, 6 | custom: null, 7 | anything: /(?:\/.*)?/, 8 | list: /(?:\/\?p=\d+)?/i, 9 | edit: /\/(.+|add)/i, 10 | home: /^\/$/, 11 | $: /\/?$/i 12 | } 13 | 14 | // regex helpers 15 | 16 | function joinRegex () { 17 | var str = '' 18 | for (var i=0; i < arguments.length; i++) { 19 | str += regex[arguments[i]].source 20 | } 21 | return new RegExp(str) 22 | } 23 | 24 | function getTableSlugs (config) { 25 | var slugs = [] 26 | for (var key in config) { 27 | var item = (config[key].app) 28 | ? config[key].app 29 | : config[key] 30 | 31 | if (!item.mainview || !item.mainview.show) continue 32 | if (item.table && !item.table.view && !item.table.pk) continue 33 | slugs.push(item.slug) 34 | } 35 | if (!slugs.length) return null 36 | return new RegExp(['^\\/(',slugs.join('|'),')'].join(''), 'i') 37 | } 38 | 39 | exports.init = (tables, custom) => { 40 | regex.tables = getTableSlugs(tables)||/.*/i 41 | regex.custom = getTableSlugs(custom) 42 | return { 43 | editview: joinRegex('tables', 'edit', '$'), 44 | listview: joinRegex('tables', 'list', '$'), 45 | custom: regex.custom ? joinRegex('custom', 'anything', '$') : null, 46 | mainview: regex.home, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "express-admin", 3 | "version": "2.0.0", 4 | "description": "MySql, MariaDB, SQLite and PostgreSQL database admin built with Express and Bootstrap", 5 | "keywords": [ 6 | "mysql", 7 | "mariadb", 8 | "sqlite", 9 | "postgresql", 10 | "database", 11 | "admin", 12 | "express", 13 | "bootstrap" 14 | ], 15 | "license": "MIT", 16 | "homepage": "https://github.com/simov/express-admin", 17 | "author": "Simeon Velichkov (https://simov.github.io)", 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/simov/express-admin.git" 21 | }, 22 | "dependencies": { 23 | "express": "^4.18.2", 24 | "body-parser": "^1.20.1", 25 | "connect-multiparty": "^2.2.0", 26 | "cookie-parser": "^1.4.6", 27 | "express-session": "^1.17.3", 28 | "csurf": "^1.11.0", 29 | "method-override": "^2.3.10", 30 | "serve-static": "^1.15.0", 31 | "consolidate": "^0.16.0", 32 | "hogan.js": "^3.0.2", 33 | "mysql-validator": "^0.1.6", 34 | "async": "^0.9.2", 35 | "slugify": "^1.6.5", 36 | "sr-pagination": "^1.0.1", 37 | "deep-copy": "^1.4.2", 38 | "xsql": "^1.0.1", 39 | "moment": "^2.29.4", 40 | "express-admin-static": "^2.2.2" 41 | }, 42 | "devDependencies": {}, 43 | "main": "./app", 44 | "engines": { 45 | "node": ">=12.0.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/db/schema.js: -------------------------------------------------------------------------------- 1 | 2 | var async = require('async') 3 | var qb = require('../qb')() 4 | 5 | 6 | // get all table or view names for this schema 7 | exports.getTables = (client, type, done) => { 8 | var sql = qb.partials[type](client.config.schema) 9 | client.query(sql, (err, rows) => { 10 | if (err) return done(err) 11 | done(null, rows.map((row) => { 12 | return row[Object.keys(row)[0]] 13 | })) 14 | }) 15 | } 16 | 17 | // get creation information for each column 18 | exports.getColumns = (client, table, done) => { 19 | var sql = qb.partials.columns(table, client.config.schema) 20 | client.query(sql, (err, columns) => { 21 | if (err) return done(err) 22 | done(null, client.getColumnsInfo(columns)) 23 | }) 24 | } 25 | 26 | // get creation information for each column in each table and view 27 | exports.getData = (client, done) => { 28 | var result = {} 29 | async.each(['tables', 'views'], (type, done) => { 30 | exports.getTables(client, type, (err, tables) => { 31 | if (err) return done(err) 32 | async.each(tables, (table, done) => { 33 | exports.getColumns(client, table, (err, info) => { 34 | if (err) return done(err) 35 | result[table] = info 36 | if (type == 'views') result[table].__view = true 37 | done() 38 | }) 39 | }, done) 40 | }) 41 | }, (err) => { 42 | done(err, result) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /views/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{string.logo}} 7 | 8 | 9 | 10 | {{>theme}} 11 | 12 | {{#libs.external.css}} 13 | 14 | {{/libs.external.css}} 15 | 16 | {{#libs.css}} 17 | 18 | {{/libs.css}} 19 | 20 | {{#libs.external.js}} 21 | 22 | {{/libs.external.js}} 23 | 24 | {{#libs.js}} 25 | 26 | {{/libs.js}} 27 | 28 | 29 | 30 |
31 | {{>header}} 32 | {{>layout}} 33 | 34 |
35 | {{>breadcrumbs}} 36 | 37 | {{>content}} 38 |
39 | 40 | 41 |
42 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /views/editview.html: -------------------------------------------------------------------------------- 1 | 2 |

{{view.name}}

3 | 4 | {{#show.success}} 5 |
6 | 7 | {{string.success}}: {{string.saved}}. 8 |
9 | {{/show.success}} 10 | 11 | {{#show.error}} 12 |
13 | 14 | {{string.error}}: {{string.checkout}}. 15 |
16 | {{/show.error}} 17 | 18 |
19 | {{>view}} 20 | {{>inline}} 21 | 22 | {{^view.readonly}} 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | {{#show.delete}} 31 | 32 | {{/show.delete}} 33 | 34 | 35 |
36 | {{/view.readonly}} 37 |
38 | -------------------------------------------------------------------------------- /lib/data/mtm.js: -------------------------------------------------------------------------------- 1 | 2 | var async = require('async') 3 | var qb = require('../qb')() 4 | 5 | 6 | // modifies `rows.columns` with manyToMany columns on select 7 | // modifies `rows` with ids on post 8 | 9 | // return an array of all child ids linked to this record's pk 10 | function getIds (args, column, pk, done) { 11 | var str = qb.mtm.select(args, column, pk) 12 | args.db.client.query(str, (err, rows) => { 13 | if (err) return done(err) 14 | var result = [] 15 | for (var i=0; i < rows.length; i++) { 16 | result.push(rows[i][Object.keys(rows[i])[0]]) 17 | } 18 | done(err, result) 19 | }) 20 | } 21 | 22 | function loopColumns (args, row, done) { 23 | row.ids = {} 24 | async.each(args.config.columns, (column, done) => { 25 | if (!column.manyToMany) return done() 26 | 27 | // after select this column practically doesn't exists 28 | if (row.columns[column.name] === undefined) 29 | row.columns[column.name] = [] 30 | 31 | var pk = row.pk || row.columns['__pk'] 32 | if (!pk) return done() 33 | 34 | getIds(args, column, pk, (err, ids) => { 35 | if (err) return done(err) 36 | args.post 37 | // save the ids from the database 38 | ? row.ids[column.name] = ids 39 | // selecting 40 | : row.columns[column.name] = ids 41 | 42 | done() 43 | }) 44 | }, done) 45 | } 46 | 47 | // loopRecords 48 | exports.get = (args, rows, done) => { 49 | async.each(rows, (row, done) => { 50 | loopColumns(args, row, done) 51 | }, done) 52 | } 53 | -------------------------------------------------------------------------------- /config/lang/cn.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "Chinese/中文", 3 | 4 | "logo" : "Express Admin", 5 | 6 | "layout" : "布局", 7 | "centered" : "居中", 8 | "streched" : "拉伸", 9 | "theme" : "换肤", 10 | "language" : "Language/语言", 11 | 12 | "login" : "登录", 13 | "logout" : "退出", 14 | "username" : "用户名", 15 | "password" : "密 码", 16 | "access-denied" : "登录失败!", 17 | "find-user" : "不存在的用户!", 18 | "invalid-password" : "密码错误!", 19 | 20 | "home" : "Home", 21 | "site-admin" : "站点管理", 22 | "tables" : "数据表", 23 | "views" : "Views", 24 | "others" : "其它", 25 | 26 | "select" : "对", 27 | "to-change" : "进行修改", 28 | "filter" : "按条件过滤", 29 | "clear" : "清除", 30 | "order" : "排序", 31 | "asc" : "升序", 32 | "desc" : "降序", 33 | "or" : "或", 34 | "today" : "今天", 35 | 36 | "add-another" : "载追加", 37 | "remove" : "移除", 38 | "add" : "增加", 39 | "change" : "修改", 40 | "delete" : "删除", 41 | "save" : "保存", 42 | "save-continue" : "保存然后继续修改", 43 | "save-add" : "保存然后增加", 44 | 45 | "success" : "成功", 46 | "error" : "错误", 47 | "saved" : "数据已保存", 48 | "checkout" : "请检查高亮数据项", 49 | 50 | "notfound" : "您请求的页面不存在." 51 | } 52 | -------------------------------------------------------------------------------- /views/listview.html: -------------------------------------------------------------------------------- 1 | 2 | {{#view}} 3 |

4 | {{#table}}{{string.select}} {{name}} {{string.to-change}}.{{/table}} 5 | {{^table}}{{name}}{{/table}} 6 | 7 | {{#table}} 8 | {{string.add}} {{name}} 9 | {{/table}} 10 |

11 | {{/view}} 12 | 13 | {{#show.success}} 14 |
15 | 16 | {{string.success}}: {{string.saved}}. 17 |
18 | {{/show.success}} 19 | 20 | {{#show.error}} 21 |
22 | 23 | {{string.error}}: {{show.error}}. 24 |
25 | {{/show.error}} 26 | 27 | {{>filter}} 28 | 29 | 30 | 31 | 32 | {{#columns}} 33 | 34 | {{/columns}} 35 | 36 | 37 | 38 | {{#records}} 39 | 40 | {{#pk}} 41 | 42 | {{/pk}} 43 | {{#values}} 44 | 48 | {{/values}} 49 | 50 | {{/records}} 51 | 52 |
{{.}}
{{text}} 45 | {{^mtm}}{{.}}{{/mtm}} 46 | {{#mtm}}{{.}}{{/mtm}} 47 |
53 | 54 | {{>pagination}} 55 | -------------------------------------------------------------------------------- /config/lang/ko.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "한국어", 3 | 4 | "logo" : "익스프레스 관리자", 5 | 6 | "layout" : "레이아웃", 7 | "centered" : "고정폭 중앙정렬", 8 | "streched" : "늘이기", 9 | "theme" : "테마", 10 | "language" : "언어", 11 | 12 | "login" : "로그인", 13 | "logout" : "로그아웃", 14 | "username" : "사용자명", 15 | "password" : "패스워드", 16 | "access-denied" : "접근이 거부되었습니다!", 17 | "find-user" : "사용자를 찾을 수 없습니다.", 18 | "invalid-password" : "패스워드가 정확하지 않습니다.", 19 | 20 | "home" : "", 21 | "site-admin" : "사이트 관리", 22 | "tables" : "테이블들", 23 | "views" : "Views", 24 | "others" : "기타", 25 | 26 | "select" : "선택하세요", 27 | "to-change" : "를 수정합니다", 28 | "filter" : "필터", 29 | "clear" : "삭제", 30 | "order" : "순서", 31 | "asc" : "오름차순", 32 | "desc" : "내림차순", 33 | "or" : "또는", 34 | "today" : "오늘", 35 | 36 | "add-another" : "새로 추가", 37 | "remove" : "삭제", 38 | "add" : "추가하기", 39 | "change" : "변경하기", 40 | "delete" : "지우기", 41 | "save" : "저장", 42 | "save-continue" : "저장하고 계속 편집하기", 43 | "save-add" : "저장하고 새로 추가하기", 44 | 45 | "success" : "성공", 46 | "error" : "에러", 47 | "saved" : "데이터가 저장되었습니다.", 48 | "checkout" : "밝게 표시된 부분을 다시 확인해주세요", 49 | 50 | "notfound" : "페이지를 찾을 수 없습니다" 51 | } 52 | -------------------------------------------------------------------------------- /lib/qb/mtm.js: -------------------------------------------------------------------------------- 1 | 2 | var x = null 3 | var z = require('./partials')() 4 | 5 | 6 | function select (args, column, pk) { 7 | var link = column.manyToMany.link 8 | 9 | var concat = z.concat(link.childPk, link.table, z.schema(link),',') 10 | 11 | var str = [ 12 | x.select(concat), 13 | x.from(x.name(link.table,z.schema(link))), 14 | x.where(z.eq(link,link.parentPk,pk)), 15 | ';' 16 | ].join(' ') 17 | 18 | args.debug && console.log('mtm', str) 19 | return str 20 | } 21 | 22 | function insert (args, ids, link, record) { 23 | var parentPk = link.parentPk instanceof Array ? link.parentPk : [link.parentPk] 24 | var childPk = link.childPk instanceof Array ? link.childPk : [link.childPk] 25 | 26 | var columns = x.names(parentPk.concat(childPk)); 27 | 28 | var values = (function values (parentValues, childValues) { 29 | parentValues = parentValues.toString().split(',') 30 | 31 | var result = [] 32 | for (var i=0; i < childValues.length; i++) { 33 | result.push(parentValues.concat(childValues[i].toString().split(','))) 34 | } 35 | return result 36 | }(record.pk, ids)) 37 | 38 | var table = x.name(link.table,z.schema(link)) 39 | 40 | var str = [x.insert(table,columns,values), ';'].join(' ') 41 | 42 | args.debug && console.log('mtm', str) 43 | return str 44 | } 45 | 46 | function remove (args, ids, link, record) { 47 | 48 | var str = [ 49 | x.delete(x.name(link.table,z.schema(link))), 50 | x.where(z.eq(link, link.parentPk, record.pk)), 51 | x.and(z.in(link.childPk, ids)), 52 | ';' 53 | ].join(' ') 54 | 55 | args.debug && console.log('mtm', str) 56 | return str 57 | } 58 | 59 | module.exports = (instance) => { 60 | if (instance) x = instance 61 | return {select, insert, remove} 62 | } 63 | -------------------------------------------------------------------------------- /config/lang/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "English", 3 | 4 | "logo" : "Express Admin", 5 | 6 | "layout" : "Layout", 7 | "centered" : "Centered", 8 | "streched" : "Streched", 9 | "theme" : "Theme", 10 | "language" : "Language", 11 | 12 | "login" : "Login", 13 | "logout" : "Logout", 14 | "username" : "Username", 15 | "password" : "Password", 16 | "access-denied" : "Access denied!", 17 | "find-user" : "Cannot find user!", 18 | "invalid-password" : "Invalid password!", 19 | 20 | "home" : "Home", 21 | "site-admin" : "Site administration", 22 | "tables" : "Tables", 23 | "views" : "Views", 24 | "others" : "Others", 25 | 26 | "select" : "Select", 27 | "to-change" : "to change", 28 | "filter" : "Filter", 29 | "clear" : "Clear", 30 | "order" : "Order By", 31 | "asc" : "Asc", 32 | "desc" : "Desc", 33 | "or" : "or", 34 | "today" : "Today", 35 | 36 | "add-another" : "Add another", 37 | "remove" : "Remove", 38 | "add" : "Add", 39 | "change" : "Change", 40 | "delete" : "Delete", 41 | "save" : "Save", 42 | "save-continue" : "Save and continue editing", 43 | "save-add" : "Save and add another", 44 | 45 | "success" : "Success", 46 | "error" : "Error", 47 | "saved" : "The record have been saved", 48 | "checkout" : "Check out the highlighted fields below", 49 | 50 | "notfound" : "The requested page was not found." 51 | } 52 | -------------------------------------------------------------------------------- /config/lang/tr.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "Türk", 3 | 4 | "logo" : "Express Admin", 5 | 6 | "layout" : "Düzeni", 7 | "centered" : "Ortalanmış", 8 | "streched" : "Gerilmiş", 9 | "theme" : "Tema", 10 | "language" : "Dil", 11 | 12 | "login" : "Giriş", 13 | "logout" : "Çıkış", 14 | "username" : "Kullanıcı adı", 15 | "password" : "Şifre", 16 | "access-denied" : "Erişim engellendi!", 17 | "find-user" : "Kullanıcı bulunamıyor!", 18 | "invalid-password" : "Geçersiz şifre!", 19 | 20 | "home" : "Ev", 21 | "site-admin" : "Site yönetimi", 22 | "tables" : "Tablolar", 23 | "views" : "Manzarası", 24 | "others" : "Diğerleri", 25 | 26 | "select" : "Seçin", 27 | "to-change" : "değiştirmek için ", 28 | "filter" : "Filtre", 29 | "clear" : "Açık", 30 | "order" : "Sipariş", 31 | "asc" : "Asc", 32 | "desc" : "Tanım", 33 | "or" : "ya", 34 | "today" : "Bugün", 35 | 36 | "add-another" : "Başka bir ekleme ", 37 | "remove" : "Kaldır", 38 | "add" : "Ekle", 39 | "change" : "Değiştirin", 40 | "delete" : "Sil", 41 | "save" : "Kaydet", 42 | "save-continue" : "Kaydet ve düzenlemeye devam et ", 43 | "save-add" : "Başka bir kaydetmek ve eklemek ", 44 | 45 | "success" : "Başarı", 46 | "error" : "Hata", 47 | "saved" : "Kayıt kaydedildi ", 48 | "checkout" : "Aşağıda vurgulanmış alanları kontrol edin ", 49 | 50 | "notfound" : "İstenen sayfa bulunamadı." 51 | } 52 | -------------------------------------------------------------------------------- /config/lang/bg.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "Български", 3 | 4 | "logo" : "Експрес Админ", 5 | 6 | "layout" : "Изглед", 7 | "centered" : "Центриран", 8 | "streched" : "Разпънат", 9 | "theme" : "Тема", 10 | "language" : "Език", 11 | 12 | "login" : "Влез", 13 | "logout" : "Излез", 14 | "username" : "Потребител", 15 | "password" : "Парола", 16 | "access-denied" : "Достъпът е отказан!", 17 | "find-user" : "Потребителят не е намерен!", 18 | "invalid-password" : "Невалидна парола!", 19 | 20 | "home" : "Начало", 21 | "site-admin" : "Сайт администрация", 22 | "tables" : "Таблици", 23 | "views" : "Изгледи", 24 | "others" : "Други", 25 | 26 | "select" : "Посочи", 27 | "to-change" : "за промяна", 28 | "filter" : "Филтрирай", 29 | "clear" : "Изчисти", 30 | "order" : "Подреди по", 31 | "asc" : "Възходящ", 32 | "desc" : "Низходящ", 33 | "or" : "или", 34 | "today" : "Днес", 35 | 36 | "add-another" : "Добави друг", 37 | "remove" : "Премахни", 38 | "add" : "Добави", 39 | "change" : "Промени", 40 | "delete" : "Изтрий", 41 | "save" : "Запази", 42 | "save-continue" : "Запази и продължи редакцията", 43 | "save-add" : "Запази и добави друг", 44 | 45 | "success" : "Успех", 46 | "error" : "Грешка", 47 | "saved" : "Записът е запаметен", 48 | "checkout" : "Провери отбелязаните полета долу", 49 | 50 | "notfound" : "Заявената страница не беше намерена." 51 | } 52 | -------------------------------------------------------------------------------- /config/lang/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "Español", 3 | 4 | "logo" : "Express Admin", 5 | 6 | "layout" : "Disposición", 7 | "centered" : "Centrado", 8 | "streched" : "Estirado", 9 | "theme" : "Tema", 10 | "language" : "Languaje", 11 | 12 | "login" : "Login", 13 | "logout" : "Logout", 14 | "username" : "Nombre de Usuario", 15 | "password" : "Clave", 16 | "access-denied" : "¡Acceso Denegado!", 17 | "find-user" : "¡Usuario inexistente!", 18 | "invalid-password" : "¡Clave inválida!", 19 | 20 | "home" : "Inicio", 21 | "site-admin" : "Sitio de Administración", 22 | "tables" : "Tablas", 23 | "views" : "Vistas", 24 | "others" : "Otros", 25 | 26 | "select" : "Seleccionar", 27 | "to-change" : "a modificar", 28 | "filter" : "Filtrar", 29 | "clear" : "Limpiar", 30 | "order" : "Ordenado por", 31 | "asc" : "Asc.", 32 | "desc" : "Desc.", 33 | "or" : "o", 34 | "today" : "Hoy", 35 | 36 | "add-another" : "Agregar otro/a", 37 | "remove" : "Eliminar", 38 | "add" : "Agregar", 39 | "change" : "Cambiar", 40 | "delete" : "Borrar", 41 | "save" : "Guardar", 42 | "save-continue" : "Guardar y continuar editando", 43 | "save-add" : "Guardar y agregar otro/a", 44 | 45 | "success" : "Éxito", 46 | "error" : "Error", 47 | "saved" : "El registro ha sido guardado", 48 | "checkout" : "Revisar los campos resaltados debajo", 49 | 50 | "notfound" : "Página no encontrada." 51 | } 52 | -------------------------------------------------------------------------------- /lib/listview/filter.js: -------------------------------------------------------------------------------- 1 | 2 | var dcopy = require('deep-copy') 3 | 4 | 5 | exports.prepareSession = (req, args) => { 6 | if (!req.session.filter) req.session.filter = {} 7 | var filter = req.session.filter 8 | 9 | if ((req.method == 'GET' && !filter.hasOwnProperty(args.name)) 10 | || (req.method == 'POST' && req.body.action.hasOwnProperty('clear'))) { 11 | filter[args.name] = { 12 | columns: {}, 13 | order: '', 14 | direction: '', 15 | show: false, 16 | or: false 17 | } 18 | } 19 | else if (req.method == 'POST' && req.body.action.hasOwnProperty('filter')) { 20 | filter[args.name] = { 21 | columns: req.body.filter || {}, 22 | order: req.body.order, 23 | direction: req.body.direction, 24 | show: true, 25 | or: req.body.or 26 | } 27 | } 28 | 29 | filter[args.name].page = args.page 30 | 31 | return filter[args.name] 32 | } 33 | 34 | exports.getOrderColumns = (req, args) => { 35 | var order = [] 36 | for (var i=0; i < args.config.columns.length; i++) { 37 | var column = args.config.columns[i] 38 | if (!column.listview.show) continue 39 | if (column.name == args.filter.order) { 40 | column = dcopy(column) 41 | column.selected = true 42 | } 43 | order.push(column) 44 | } 45 | return order 46 | } 47 | 48 | exports.getColumns = (args) => { 49 | var filter = [] 50 | for (var i=0; i < args.config.columns.length; i++) { 51 | if (!args.config.listview.filter) continue 52 | for (var j=0; j < args.config.listview.filter.length; j++) { 53 | if (args.config.columns[i].name == args.config.listview.filter[j]) { 54 | var column = dcopy(args.config.columns[i]) 55 | column.key = 'filter['+column.name+']' 56 | filter.push(column) 57 | } 58 | } 59 | } 60 | return filter 61 | } 62 | -------------------------------------------------------------------------------- /config/lang/ru.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "Русский", 3 | 4 | "logo" : "Express Admin", 5 | 6 | "layout" : "Расположение", 7 | "centered" : "По центру", 8 | "streched" : "Растянуть", 9 | "theme" : "Тема", 10 | "language" : "Язык", 11 | 12 | "login" : "Вход", 13 | "logout" : "Выход", 14 | "username" : "Имя пользователя", 15 | "password" : "Пароль", 16 | "access-denied" : "Доступ запрещен!", 17 | "find-user" : "Нет такого пользователя!", 18 | "invalid-password" : "Неверный пароль!", 19 | 20 | "home" : "Начало", 21 | "site-admin" : "Администрирование сайта", 22 | "tables" : "Таблицы", 23 | "views" : "Views", 24 | "others" : "Другое", 25 | 26 | "select" : "Выбор", 27 | "to-change" : "для изменения", 28 | "filter" : "Фильтр", 29 | "clear" : "Очистить", 30 | "order" : "Сортировка", 31 | "asc" : "По возрастанию", 32 | "desc" : "По убыванию", 33 | "or" : "или", 34 | "today" : "Сегодня", 35 | 36 | "add-another" : "Добавить еще", 37 | "remove" : "Удалить", 38 | "add" : "Добавить", 39 | "change" : "Изменить", 40 | "delete" : "Удалить", 41 | "save" : "Сохранить", 42 | "save-continue" : "Сохранить и продолжить редактирование", 43 | "save-add" : "Сохранить и добавить еще", 44 | 45 | "success" : "Успех", 46 | "error" : "Ошибка", 47 | "saved" : "Запись сохранена", 48 | "checkout" : "Проверьте подсвеченные поля ниже", 49 | 50 | "notfound" : "Запрошенная страница не найдена." 51 | } 52 | -------------------------------------------------------------------------------- /config/lang/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "name" : "Deutsch", 3 | 4 | "logo" : "Express Administrator", 5 | 6 | "layout" : "Layout", 7 | "centered" : "Zentriert", 8 | "streched" : "Gestreckt", 9 | "theme" : "Thema", 10 | "language" : "Sprache", 11 | 12 | "login" : "Login", 13 | "logout" : "Logout", 14 | "username" : "Benutzername", 15 | "password" : "Passwort", 16 | "access-denied" : "Zugriff verweigert!", 17 | "find-user" : "Benutzer nicht gefunden!", 18 | "invalid-password" : "Ungültiges Passwort!", 19 | 20 | "home" : "Start", 21 | "site-admin" : "Seitenadministration", 22 | "tables" : "Tabellen", 23 | "views" : "Views", 24 | "others" : "Anderes", 25 | 26 | "select" : "Wähle", 27 | "to-change" : "zum Bearbeiten", 28 | "filter" : "Filtern", 29 | "clear" : "Zurücksetzen", 30 | "order" : "Sortieren nach", 31 | "asc" : "Aufsteigend", 32 | "desc" : "Absteigend", 33 | "or" : "of", 34 | "today" : "Heute", 35 | 36 | "add-another" : "Weiteren hinzufügen", 37 | "remove" : "Entfernen", 38 | "add" : "Hinzufügen", 39 | "change" : "Bearbeiten", 40 | "delete" : "Löschen", 41 | "save" : "Speichern", 42 | "save-continue" : "Speichern und weiter bearbeiten", 43 | "save-add" : "Speichern und neuen hinzufügen", 44 | 45 | "success" : "Erfolg", 46 | "error" : "Fehler", 47 | "saved" : "Der Eintrag wurde gespeichert", 48 | "checkout" : "Überprüfen Sie die markierten Felder unten", 49 | 50 | "notfound" : "Die angeforderte Seite wurde nicht gefunden." 51 | } 52 | -------------------------------------------------------------------------------- /routes/auth.js: -------------------------------------------------------------------------------- 1 | 2 | exports.status = (req, res, next) => { 3 | res.locals.show = { 4 | error: req.session.error, 5 | success: req.session.success 6 | } 7 | res.locals.username = req.session.username 8 | delete req.session.error 9 | delete req.session.success 10 | delete req.session.username 11 | next() 12 | } 13 | 14 | exports.restrict = (req, res, next) => { 15 | if (res.locals._admin.readonly) return next() 16 | 17 | if (req.session.user) return next() 18 | req.session.error = res.locals.string['access-denied'] 19 | res.redirect(res.locals.root + '/login') 20 | } 21 | 22 | exports.login = (req, res) => { 23 | // query the db for the given username 24 | var user = res.locals._admin.users[req.body.username] 25 | if (!user) { 26 | req.session.error = res.locals.string['find-user'] 27 | req.session.username = req.body.username 28 | res.redirect(res.locals.root + '/login') 29 | return 30 | } 31 | 32 | // apply the same algorithm to the POSTed password, applying 33 | // the hash against the pass / salt, if there is a match we 34 | // found the user 35 | if (req.body.password === user.pass) { 36 | // Regenerate session when signing in 37 | // to prevent fixation 38 | req.session.regenerate((err) => { 39 | // Store the user's primary key 40 | // in the session store to be retrieved, 41 | // or in this case the entire user object 42 | req.session.user = user 43 | res.redirect(res.locals.root + '/') 44 | }) 45 | } 46 | else { 47 | req.session.error = res.locals.string['invalid-password'] 48 | req.session.username = req.body.username 49 | res.redirect(res.locals.root + '/login') 50 | } 51 | } 52 | 53 | exports.logout = (req, res) => { 54 | // destroy the user's session to log them out 55 | // will be re-created next request 56 | req.session.destroy(() => { 57 | // successfully logged out 58 | res.redirect(res.locals.root + '/login') 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /views/editview/inline.html: -------------------------------------------------------------------------------- 1 | {{#inline}} 2 |
3 | {{#tables}} 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {{#records}} 12 | 13 | 29 | 30 | {{#columns}} 31 | 32 | {{>column}} 33 | 34 | {{/columns}} 35 | {{/records}} 36 | 37 | 38 | 44 | 45 | {{#blank}} 46 | 47 | {{>column}} 48 | 49 | {{/blank}} 50 | 51 | 52 | 53 | 54 | 55 | 56 |
{{verbose}}
14 | {{#pk}} 15 | 16 | {{/pk}} 17 | {{#insert}} 18 | 19 | 20 | {{/insert}} 21 | {{^insert}} 22 | 27 | {{/insert}} 28 |
{{string.add-another}} {{verbose}}
57 | {{/tables}} 58 |
59 | {{/inline}} 60 | -------------------------------------------------------------------------------- /lib/format/form.js: -------------------------------------------------------------------------------- 1 | 2 | var moment = require('moment') 3 | 4 | 5 | function setActiveSingle (column, value) { 6 | if (!column.value) return value 7 | var rows = column.value 8 | for (var i=0; i < rows.length; i++) { 9 | if (rows[i]['__pk'] == value) { 10 | rows[i].selected = true 11 | return rows 12 | } 13 | } 14 | return rows 15 | } 16 | 17 | function setActiveMultiple (column, value) { 18 | if (!column.value) return value 19 | var rows = column.value 20 | for (var i=0; i < rows.length; i++) { 21 | var pk = rows[i]['__pk'] 22 | value = (value instanceof Array) ? value : [value] 23 | for (var j=0; j < value.length; j++) { 24 | if (pk == value[j]) { 25 | rows[i].selected = true 26 | break 27 | } 28 | } 29 | } 30 | return rows 31 | } 32 | 33 | function setActiveRadio (column, value) { 34 | if (value == 1) 35 | column.value[0].active = true 36 | 37 | else if (value == 0) 38 | column.value[1].active = true 39 | 40 | return column.value 41 | } 42 | 43 | exports.value = (column, value) => { 44 | if (value == '' && column.defaultValue) { 45 | value = column.defaultValue 46 | } 47 | 48 | if (column.control.date) { 49 | if (!value) return null 50 | if (moment(value).isValid()) return moment(value).format('YYYY-MM-DD') 51 | return value 52 | } 53 | 54 | else if (column.control.datetime) { 55 | if (!value) return null 56 | if (moment(value).isValid()) return moment(value).format('YYYY-MM-DD HH:mm:ss') 57 | return value 58 | } 59 | 60 | else if (column.control.binary) { 61 | return value ? 'data:image/jpeg;base64,'+value.toString('base64') : value 62 | } 63 | 64 | else if (column.control.multiple) { 65 | return setActiveMultiple(column, value) 66 | } 67 | 68 | else if (column.control.select) { 69 | return setActiveSingle(column, value) 70 | } 71 | 72 | else if (column.control.radio) { 73 | return setActiveRadio(column, value) 74 | } 75 | 76 | else { 77 | return value 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /views/login.html: -------------------------------------------------------------------------------- 1 | 2 | {{#show.error}} 3 |
4 |
5 | 6 | {{show.error}} 7 |
8 |
9 | {{/show.error}} 10 | 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 44 | 45 | 46 |
{{string.login}}
22 | {{!username}} 23 |
24 | 25 |
26 | 27 |
28 |
29 | {{!password}} 30 |
31 | 32 |
33 | 34 |
35 |
36 | {{!submit}} 37 |
38 |
39 | 40 | 41 |
42 |
43 |
47 |
48 |
49 | -------------------------------------------------------------------------------- /views/header.html: -------------------------------------------------------------------------------- 1 | 2 | 53 | -------------------------------------------------------------------------------- /lib/app/settings.js: -------------------------------------------------------------------------------- 1 | 2 | var slugify = require('slugify') 3 | 4 | 5 | // check for column existence 6 | function exists (columns, name) { 7 | for (var i=0; i < columns.length; i++) { 8 | if (columns[i].name == name) return true 9 | } 10 | return false 11 | } 12 | 13 | // create settings object for a table 14 | function createTable (name, pk, view) { 15 | return { 16 | slug: slugify(name), 17 | table: { 18 | name: name, 19 | pk: pk, 20 | verbose: name, 21 | view: view 22 | }, 23 | columns: [], 24 | mainview: { 25 | show: true 26 | }, 27 | listview: { 28 | order: {}, 29 | page: 25 30 | }, 31 | editview: { 32 | readonly: false 33 | } 34 | } 35 | } 36 | 37 | // create a settings object for a column 38 | function createColumn (name, info) { 39 | return { 40 | name: name, 41 | verbose: name, 42 | control: {text: true}, 43 | type: info.type, 44 | allowNull: info.allowNull, 45 | defaultValue: info.defaultValue, 46 | listview: {show: true}, 47 | editview: {show: true} 48 | } 49 | } 50 | 51 | // get the first found primary key from a given table's columns list 52 | function primaryKey (columns) { 53 | var pk = [] 54 | for (var name in columns) { 55 | for (var property in columns[name]) { 56 | if (columns[name][property] === 'pri') { 57 | pk.push(name) 58 | } 59 | } 60 | } 61 | return !pk.length ? '' : (pk.length > 1 ? pk : pk[0]) 62 | } 63 | 64 | // insert new tables and columns for a settings object 65 | exports.refresh = (settings, info) => { 66 | for (var table in info) { 67 | var view = info[table].__view 68 | delete info[table].__view 69 | 70 | var columns = info[table] 71 | var pk = primaryKey(columns) 72 | 73 | if (settings[table] === undefined) { 74 | settings[table] = createTable(table, pk, view) 75 | } 76 | 77 | for (var name in columns) { 78 | if (exists(settings[table].columns, name)) continue 79 | 80 | settings[table].columns.push(createColumn(name, columns[name])) 81 | } 82 | } 83 | return settings 84 | } 85 | -------------------------------------------------------------------------------- /views/listview/filter.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | {{#have}} 4 |
5 |

{{string.filter}}

6 |
7 | 8 |
9 |
10 |
11 | 12 | 13 | {{#filter}} 14 | 15 | 22 | 23 | {{/filter}} 24 | 25 |
16 |
17 | {{#row}} 18 | {{>column}} 19 | {{/row}} 20 |
21 |
26 |
27 | {{/have}} 28 | 52 | 53 |
54 | -------------------------------------------------------------------------------- /views/mainview.html: -------------------------------------------------------------------------------- 1 | 2 |

3 | {{string.site-admin}} 4 | 5 |

6 | 7 |
8 |
9 |

{{string.filter}}

10 |
11 | 25 |
26 | 27 | {{#tables}} 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {{#items}} 36 | 37 | 41 | 42 | {{/items}} 43 | 44 |
{{string.tables}}
38 | {{name}} 39 | 40 |
45 | {{/tables}} 46 | 47 | {{#views}} 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | {{#items}} 56 | 57 | 60 | 61 | {{/items}} 62 | 63 |
{{string.views}}
58 | {{name}} 59 |
64 | {{/views}} 65 | 66 | {{#custom}} 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | {{#items}} 75 | 76 | 79 | 80 | {{/items}} 81 | 82 |
{{string.others}}
77 | {{name}} 78 |
83 | {{/custom}} 84 | -------------------------------------------------------------------------------- /lib/editview/index.js: -------------------------------------------------------------------------------- 1 | 2 | var async = require('async') 3 | var dcopy = require('deep-copy') 4 | 5 | var upload = require('./upload') 6 | var data = require('../data') 7 | var template = require('../template') 8 | 9 | 10 | function removeHidden (columns) { 11 | var result = [] 12 | for (var i=0; i < columns.length; i++) { 13 | if (!columns[i].editview.show) continue 14 | result.push(columns[i]) 15 | } 16 | return result 17 | } 18 | 19 | function getData (args, done) { 20 | args.config.columns = removeHidden(args.config.columns) 21 | 22 | data.otm.get(args, (err) => { 23 | if (err) return done(err) 24 | data.stc.get(args) 25 | data.tbl.get(args, (err, rows) => { 26 | if (err) return done(err) 27 | data.mtm.get(args, rows, (err) => { 28 | if (err) return done(err) 29 | upload.loopRecords(args, (err) => { 30 | if (err) return done(err) 31 | done(null, template.table.get(args, rows)) 32 | }) 33 | }) 34 | }) 35 | }) 36 | } 37 | 38 | function getTables (args, done) { 39 | var tables = [] 40 | async.eachSeries(Object.keys(args.tables), (name, done) => { 41 | 42 | args.config = dcopy(args.settings[name]) 43 | args.fk = args.tables[name] 44 | 45 | getData(args, (err, table) => { 46 | if (err) return done(err) 47 | tables.push(table) 48 | done() 49 | }) 50 | }, (err) => { 51 | done(err, tables) 52 | }) 53 | } 54 | 55 | exports.getTypes = (args, done) => { 56 | var result = {} 57 | args.config = dcopy(args.settings[args.name]) 58 | async.eachSeries(['view', 'oneToOne', 'manyToOne'], (type, done) => { 59 | args.type = type 60 | 61 | args.tables = {} 62 | ;(type == 'view') 63 | ? args.tables[args.name] = null 64 | : args.tables = args.config.editview[type] || {} 65 | 66 | args.post = args.data ? args.data[type] : null 67 | args.files = args.upload ? args.upload[type] : null 68 | 69 | getTables(args, (err, tables) => { 70 | if (err) return done(err) 71 | result[type] = {tables: tables} 72 | done() 73 | }) 74 | }, (err) => { 75 | delete args.type 76 | delete args.tables 77 | delete args.post 78 | delete args.files 79 | delete args.config 80 | delete args.fk 81 | done(err, result) 82 | }) 83 | } 84 | 85 | exports._removeHidden = removeHidden 86 | -------------------------------------------------------------------------------- /lib/template/table.js: -------------------------------------------------------------------------------- 1 | 2 | var dcopy = require('deep-copy') 3 | var validate = require('../editview/validate') 4 | var format = require('../format') 5 | 6 | 7 | var _blank = (args) => { 8 | var table = args.config.table 9 | var columns = args.config.columns 10 | 11 | var names = [], blank = [] 12 | for (var i=0; i < columns.length; i++) { 13 | names.push(columns[i].name) 14 | blank.push(dcopy(columns[i])) 15 | } 16 | 17 | for (var i=0; i < names.length; i++) { 18 | if (args.type === 'view') { 19 | blank[i].key = args.type+'['+table.name+'][records][0][columns]['+names[i]+']' 20 | } 21 | else { 22 | blank[i].key = args.type+'['+table.name+'][blank][index][columns]['+names[i]+']' 23 | } 24 | } 25 | 26 | return blank 27 | } 28 | 29 | var _insert = (args) => { 30 | var table = args.config.table 31 | return { 32 | key: args.type+'['+table.name+'][blank][index][insert]', 33 | value: true 34 | } 35 | } 36 | 37 | var _records = (args, rows) => { 38 | var table = args.config.table 39 | var records = [] 40 | for (var i=0; i < rows.length; i++) { 41 | 42 | var values = rows[i].columns 43 | var record = args.type+'['+table.name+'][records]['+i+']' 44 | 45 | var pk = { 46 | key: record+'[pk]', 47 | value: rows[i].pk || values['__pk'] 48 | } 49 | 50 | var insert = rows[i].insert 51 | ? { 52 | key: record+'[insert]', 53 | value: true 54 | } : null 55 | 56 | var remove = { 57 | key: record+'[remove]', 58 | value: rows[i].remove ? true : null 59 | } 60 | 61 | var columns = dcopy(args.config.columns) 62 | for (var j=0; j < columns.length; j++) { 63 | 64 | var column = columns[j] 65 | column.key = record+'[columns]['+column.name+']' 66 | 67 | var value = values[column.name] 68 | column.error = validate.value(column, value) 69 | column.value = format.form.value(column, value) 70 | args.error = column.error ? true : args.error 71 | } 72 | records.push({pk: pk, columns: columns, insert: insert, remove: remove}) 73 | } 74 | return records 75 | } 76 | 77 | // `rows` comes either from sql select of from post request 78 | exports.get = (args, rows) => ({ 79 | name: args.config.table.name, 80 | verbose: args.config.table.verbose, 81 | records: _records(args, rows), 82 | blank: _blank(args), 83 | insert: _insert(args), 84 | }) 85 | -------------------------------------------------------------------------------- /lib/editview/upload.js: -------------------------------------------------------------------------------- 1 | 2 | var fs = require('fs') 3 | var path = require('path') 4 | 5 | 6 | // prevent overriding of existing files 7 | function getName (target, cb) { 8 | var ext = path.extname(target) 9 | var fname = path.basename(target, ext) 10 | var dpath = path.dirname(target) 11 | fs.exists(target, (exists) => { 12 | return exists ? loop(1) : cb(target, fname+ext) 13 | }) 14 | function loop (i) { 15 | var name = fname+'-'+i 16 | var fpath = path.join(dpath, name+ext) 17 | fs.exists(fpath, (exists) => { 18 | return exists ? loop(++i) : cb(fpath, name+ext) 19 | }) 20 | } 21 | } 22 | 23 | // get control type 24 | function getType (args, name) { 25 | var columns = args.config.columns 26 | for (var i=0; i < columns.length; i++) { 27 | if (columns[i].name == name) return columns[i].control 28 | } 29 | return {} 30 | } 31 | 32 | function processFile (args, file, type, cb) { 33 | if (!file.name) return cb(null, file.name) 34 | // file.name // file-name.jpg 35 | // file.path // /tmp/9c9b10b72fe71be752bd3895f1185bc8 36 | 37 | var source = file.path 38 | var target = path.join(args.upath, file.name) 39 | 40 | fs.readFile(source, (err, data) => { 41 | if (err) return cb(err) 42 | if (type.binary) return cb(null, data) 43 | 44 | getName(target, (target, fname) => { 45 | fs.writeFile(target, data, (err) => { 46 | if (err) return cb(err) 47 | cb(null, fname) 48 | }) 49 | }) 50 | }) 51 | } 52 | 53 | function loopColumns (args, files, post, cb) { 54 | var keys = Object.keys(files.columns) 55 | 56 | ;(function loop (index) { 57 | if (index == keys.length) return cb() 58 | var name = keys[index] 59 | var type = getType(args, name) 60 | 61 | processFile(args, files.columns[name], type, (err, result) => { 62 | if (err) return cb(err) 63 | // either file name or file data 64 | if (result) post.columns[name] = type.binary ? result.toString('hex') : result 65 | loop(++index) 66 | }) 67 | }(0)) 68 | } 69 | 70 | exports.loopRecords = (args, cb) => { 71 | if (!args.files) return cb() 72 | 73 | var files = args.files[args.config.table.name] 74 | var post = args.post[args.config.table.name] 75 | if (!files.records) return cb() 76 | ;(function loop (index) { 77 | if (index == files.records.length) return cb() 78 | loopColumns(args, files.records[index], post.records[index], (err) => { 79 | if (err) return cb(err) 80 | loop(++index) 81 | }) 82 | }(0)) 83 | } 84 | -------------------------------------------------------------------------------- /views/editview/column.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |
5 | 6 | 7 | 8 | {{#control}} 9 | {{! data-type-name="type.name" data-allow-null="allowNull" data-default-value="defaultValue"}} 10 | 11 |
12 | 13 | {{#number}} 14 | 15 | {{/number}} 16 | 17 | {{#text}} 18 | 19 | {{/text}} 20 | 21 | {{#textarea}} 22 | 23 | {{/textarea}} 24 | 25 | {{#select}} 26 | 33 | {{/select}} 34 | 35 | {{#date}} 36 | 37 | {{/date}} 38 | {{#time}} 39 | 40 | {{/time}} 41 | {{#datetime}} 42 | 43 | {{/datetime}} 44 | {{#year}} 45 | 46 | {{/year}} 47 | 48 | {{#file}} 49 | {{^binary}} 50 | 51 | {{/binary}} 52 | {{/file}} 53 | {{#binary}} 54 | 55 | {{/binary}} 56 | 57 | {{#radio}} 58 | {{#value}} 59 | 62 | {{/value}} 63 | {{/radio}} 64 | 65 | {{#error}} 66 | {{message}} 67 | {{/error}} 68 | 69 | {{#hidden}} 70 | 71 | {{/hidden}} 72 | 73 |
74 | {{#date}} 75 |
76 | {{string.today}} 77 |
78 | {{/date}} 79 | {{#file}} 80 |
81 | 82 |
83 | {{/file}} 84 | 85 | {{/control}} 86 |
87 |
88 | 89 | -------------------------------------------------------------------------------- /views/listview/column.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{#control}} 5 | {{! data-type-name="type.name" data-allow-null="allowNull" data-default-value="defaultValue"}} 6 | 7 |
8 | 9 | {{#number}} 10 | 11 | {{/number}} 12 | 13 | {{#text}} 14 | 15 | {{/text}} 16 | 17 | {{#textarea}} 18 | 19 | {{/textarea}} 20 | 21 | {{#select}} 22 | 29 | {{/select}} 30 | 31 | {{#date}} 32 | 33 | 34 | 35 | {{/date}} 36 | {{#time}} 37 | 38 | 39 | 40 | {{/time}} 41 | {{#datetime}} 42 | 43 | 44 | 45 | {{/datetime}} 46 | {{#year}} 47 | 48 | 49 | 50 | {{/year}} 51 | 52 | {{#file}} 53 | 54 | {{/file}} 55 | 56 | {{#radio}} 57 | {{#value}} 58 | 61 | {{/value}} 62 | {{/radio}} 63 | 64 | {{#error}} 65 | {{message}} 66 | {{/error}} 67 | 68 | {{#hidden}} 69 | 70 | {{/hidden}} 71 | 72 |
73 | 74 | {{/control}} 75 | -------------------------------------------------------------------------------- /lib/qb/tbl.js: -------------------------------------------------------------------------------- 1 | 2 | var x = null 3 | var z = require('./partials')() 4 | 5 | 6 | function select (args) { 7 | var table = args.config.table 8 | var columns = args.config.columns 9 | 10 | var names = [] 11 | for (var i=0; i < columns.length; i++) { 12 | if (columns[i].oneToMany && columns[i].fk) { 13 | names.push(x.as( 14 | z.concat(columns[i].fk,table.name,z.schema(table),','), 15 | x.name(columns[i].name) 16 | )) 17 | } 18 | else if (!columns[i].manyToMany) { 19 | names.push(x.name(columns[i].name,table.name,z.schema(table))) 20 | } 21 | } 22 | names.unshift(x.as( 23 | z.concat(table.pk,table.name,z.schema(table),','), 24 | x.name('__pk') 25 | )) 26 | 27 | var str = [ 28 | x.select(names.join()), 29 | x.from(x.name(table.name,z.schema(table))), 30 | x.where(x.and(z.eq(table, (args.fk? args.fk:table.pk), args.id))), 31 | ';' 32 | ].join(' ') 33 | 34 | args.debug && console.log('tbl', str) 35 | return str 36 | } 37 | 38 | function insert (args, settings, record) { 39 | var data = _kvp(args.name, record.columns, settings.columns) 40 | 41 | var result = [ 42 | x.insert( 43 | x.name(settings.table.name,z.schema(settings.table)), 44 | data.keys, 45 | data.values 46 | ) 47 | ] 48 | 49 | if (x.dialect == 'pg') { 50 | (settings.table.pk instanceof Array) 51 | ? result.push('returning ' + x.names(settings.table.pk)) 52 | : result.push('returning ' + x.name(settings.table.pk)) 53 | } 54 | result.push(';') 55 | 56 | var str = result.join(' ') 57 | args.debug && console.log('tbl', str) 58 | return str 59 | } 60 | 61 | function update (args, settings, record) { 62 | var data = _kvp(args.name, record.columns, settings.columns) 63 | 64 | var str = [ 65 | x.update( 66 | x.name(settings.table.name,z.schema(settings.table)), 67 | data.keys, 68 | data.values 69 | ), 70 | x.where(x.and(z.eq(settings.table, settings.table.pk, record.pk))), 71 | ';' 72 | ].join(' ') 73 | 74 | args.debug && console.log('tbl', str) 75 | return str 76 | } 77 | 78 | function remove (args, settings, record) { 79 | var pk = settings.table.pk 80 | 81 | var str = [ 82 | x.delete(x.name(settings.table.name,z.schema(settings.table))), 83 | x.where(x.and(z.eq(settings.table,pk,record.pk))), 84 | ';' 85 | ].join(' ') 86 | 87 | args.debug && console.log('tbl', str) 88 | return str 89 | } 90 | 91 | function _kvp (table, data, columns) { 92 | var keys = [], values = [] 93 | 94 | for (var i=0; i < columns.length; i++) { 95 | if (columns[i].manyToMany) continue 96 | 97 | var name = columns[i].name 98 | if (!{}.hasOwnProperty.call(data, name)) continue 99 | 100 | if (columns[i].oneToMany && columns[i].fk) { 101 | var _values = data[name].split(',') 102 | for (var j=0; j < columns[i].fk.length; j++) { 103 | var _name = columns[i].fk[j] 104 | 105 | keys.push(x.name(_name)) 106 | for (var k=0; k < columns.length; k++) { 107 | if (columns[k].name == _name) { 108 | var value = _values[j] 109 | break 110 | } 111 | } 112 | values.push(value || null) 113 | } 114 | continue 115 | } 116 | 117 | keys.push(x.name(name)) 118 | var value = _escape(data[name], columns[i].type) 119 | 120 | values.push(value) 121 | } 122 | return {keys, values} 123 | } 124 | 125 | function _escape (value, type) { 126 | if (!value) return null 127 | if (/(?:tiny|medium|long)?blob|(?:var)?binary\(\d+\)/i.test(type)) { 128 | return "X\'"+value+"\'" 129 | } 130 | else if (/bytea/.test(type)) { 131 | return "\'\\x"+value+"\'" 132 | } 133 | else { 134 | return value 135 | } 136 | } 137 | 138 | module.exports = (instance) => { 139 | if (instance) x = instance 140 | return {select, insert, update, remove} 141 | } 142 | -------------------------------------------------------------------------------- /routes/listview.js: -------------------------------------------------------------------------------- 1 | 2 | var async = require('async') 3 | var dcopy = require('deep-copy') 4 | 5 | var qb = require('../lib/qb')() 6 | var data = require('../lib/data') 7 | 8 | var format = require('../lib/format') 9 | var filter = require('../lib/listview/filter') 10 | 11 | 12 | function getArgs (req, res) { 13 | var args = { 14 | settings : res.locals._admin.settings, 15 | db : res.locals._admin.db, 16 | readonly : res.locals._admin.readonly, 17 | debug : res.locals._admin.debug, 18 | slug : req.params[0], 19 | page : req.query.p || 0, 20 | data : req.body 21 | } 22 | args.name = res.locals._admin.slugs[args.slug] 23 | args.config = dcopy(args.settings[args.name]) 24 | return args 25 | } 26 | 27 | exports.get = (req, res, next) => { 28 | _data(req, res, next) 29 | } 30 | 31 | exports.post = (req, res, next) => { 32 | _data(req, res, next) 33 | } 34 | 35 | function _data (req, res, next) { 36 | var args = getArgs(req, res) 37 | var events = res.locals._admin.events 38 | 39 | args.filter = filter.prepareSession(req, args) 40 | qb.lst.select(args) 41 | 42 | var results = {} 43 | async.series([ 44 | events.preList.bind(events, req, res, args), 45 | (done) => { 46 | data.list.get(args, (err, result) => { 47 | if (err) return done(err) 48 | results.data = result 49 | done() 50 | }) 51 | }, 52 | (done) => { 53 | data.pagination.get(args, (err, pager) => { 54 | if (err) return done(err) 55 | // always should be in front of filter.getColumns 56 | // as it may reduce args.config.columns 57 | results.order = filter.getOrderColumns(req, args) 58 | args.config.columns = filter.getColumns(args) 59 | results.pager = pager 60 | done() 61 | }) 62 | }, 63 | (done) => { 64 | data.otm.get(args, (err) => { 65 | if (err) return done(err) 66 | data.stc.get(args) 67 | done() 68 | }) 69 | } 70 | ], (err) => { 71 | if (err) return next(err) 72 | render( 73 | req, res, args, 74 | results.data, results.pager, results.order, 75 | next 76 | ) 77 | }) 78 | } 79 | 80 | function render (req, res, args, ddata, pager, order, next) { 81 | // set filter active items 82 | for (var i=0; i < args.config.columns.length; i++) { 83 | var column = args.config.columns[i] 84 | var value = args.filter.columns[column.name] 85 | column.defaultValue = null 86 | column.value = format.form.value(column, value) 87 | } 88 | 89 | res.locals.view = { 90 | name: args.config.table.verbose, 91 | slug: args.slug, 92 | error: res.locals.error, 93 | table: !args.config.table.view 94 | } 95 | res.locals.breadcrumbs = { 96 | links: [ 97 | {url: '/', text: res.locals.string.home}, 98 | {active: true, text: args.config.table.verbose} 99 | ] 100 | } 101 | 102 | // show filter rows in two columns 103 | res.locals.filter = (() => { 104 | var filter = args.config.columns 105 | var rows = [] 106 | var size = 2 107 | var total = filter.length / size 108 | for (var i=0; i < total; i++) { 109 | rows.push({row: filter.slice(i*size, (i+1)*size)}) 110 | } 111 | return rows 112 | })() 113 | res.locals.have = res.locals.filter.length ? true : false 114 | 115 | res.locals.order = order 116 | // order direction 117 | res.locals.direction = [ 118 | { 119 | text: res.locals.string.asc, 120 | value: 'asc', 121 | selected: args.filter.direction == 'asc' ? true : null 122 | }, 123 | { 124 | text: res.locals.string.desc, 125 | value: 'desc', 126 | selected: args.filter.direction == 'desc' ? true : null 127 | } 128 | ] 129 | res.locals.collapsed = args.filter.show 130 | res.locals.or = args.filter.or 131 | 132 | res.locals.columns = ddata.columns 133 | res.locals.records = ddata.records 134 | res.locals.pagination = pager 135 | 136 | res.locals.partials = { 137 | content: 'listview', 138 | filter: 'listview/filter', 139 | column: 'listview/column', 140 | pagination: 'pagination' 141 | } 142 | 143 | next() 144 | } 145 | -------------------------------------------------------------------------------- /routes/editview.js: -------------------------------------------------------------------------------- 1 | 2 | var async = require('async') 3 | var dcopy = require('deep-copy') 4 | var editview = require('../lib/editview/index') 5 | var database = require('../lib/db/update') 6 | 7 | 8 | function getArgs (req, res) { 9 | var args = { 10 | settings : res.locals._admin.settings, 11 | db : res.locals._admin.db, 12 | readonly : res.locals._admin.readonly, 13 | debug : res.locals._admin.debug, 14 | slug : req.params[0], 15 | id : req.params[1] == 'add' ? null : req.params[1].split(','), 16 | data : req.body, 17 | upload : req.files, 18 | upath : res.locals._admin.config.admin.upload 19 | }; 20 | args.name = res.locals._admin.slugs[args.slug] 21 | return args 22 | } 23 | 24 | function page (req, args) { 25 | if (!req.session.filter || !req.session.filter[args.name]) return '' 26 | var page = req.session.filter[args.name].page 27 | return page ? '?p='+page : '' 28 | } 29 | 30 | function action (req, name) { 31 | return {}.hasOwnProperty.call(req.body.action, name) 32 | } 33 | 34 | exports.get = (req, res, next) => { 35 | var args = getArgs(req, res); 36 | 37 | editview.getTypes(args, (err, data) => { 38 | if (err) return next(err) 39 | render(req, res, next, data, args) 40 | }) 41 | } 42 | 43 | exports.post = (req, res, next) => { 44 | var args = getArgs(req, res) 45 | var events = res.locals._admin.events 46 | 47 | editview.getTypes(args, (err, data) => { 48 | if (err) return next(err) 49 | 50 | var view = req.body.view, 51 | table = Object.keys(view)[0] 52 | 53 | if (action(req, 'remove')) { 54 | // should be based on constraints 55 | args.action = 'remove' 56 | 57 | } else if ({}.hasOwnProperty.call(view[table].records[0], 'insert')) { 58 | if (args.error && !args.readonly) 59 | return render(req, res, next, data, args) 60 | args.action = 'insert' 61 | 62 | } else { 63 | if (args.error && !args.readonly) 64 | return render(req, res, next, data, args) 65 | args.action = 'update' 66 | } 67 | 68 | async.series([ 69 | events.preSave.bind(events, req, res, args), 70 | 71 | database.update.bind(database, args), 72 | 73 | events.postSave.bind(events, req, res, args) 74 | 75 | ], (err) => { 76 | if (err) { 77 | req.session.error = err.message 78 | res.redirect(res.locals.root+'/'+args.slug+page(req, args)) 79 | return 80 | } 81 | 82 | req.session.success = true 83 | 84 | // based on clicked button 85 | if (action(req, 'remove')) { 86 | // the message should be different for delete 87 | res.redirect(res.locals.root+'/'+args.slug+page(req, args)) 88 | } 89 | else if (action(req, 'save')) { 90 | res.redirect(res.locals.root+'/'+args.slug+page(req, args)) 91 | } 92 | else if (action(req, 'another')) { 93 | res.redirect(res.locals.root+'/'+args.slug+'/add') 94 | } 95 | else if (action(req, 'continue')) { 96 | if (args.readonly) return render(req, res, next, data, args) 97 | res.redirect(res.locals.root+'/'+args.slug+'/'+args.id.join()) 98 | } 99 | }) 100 | }) 101 | } 102 | 103 | function render (req, res, next, data, args) { 104 | var string = res.locals.string 105 | var view = args.settings[args.name] 106 | 107 | res.locals.view = { 108 | tables: data.view.tables, 109 | name: view.table.verbose, 110 | slug: args.slug, 111 | action: req.url, 112 | readonly: view.editview.readonly, 113 | success: args.success 114 | } 115 | res.locals.breadcrumbs = { 116 | links: [ 117 | {url: '/', text: string.home}, 118 | {url: '/'+args.slug, text: view.table.verbose}, 119 | {active: true, text: req.params[1]} 120 | ] 121 | } 122 | res.locals.show.error = args.error 123 | res.locals.show.delete = !(req.params[1] == 'add') 124 | 125 | data.oneToOne.one = true 126 | data.oneToOne.type = 'one' 127 | data.manyToOne.type = 'many' 128 | res.locals.inline = [data.oneToOne, data.manyToOne] 129 | 130 | res.locals.partials = { 131 | content: 'editview', 132 | view: 'editview/view', 133 | inline: 'editview/inline', 134 | column: 'editview/column' 135 | } 136 | 137 | next() 138 | } 139 | -------------------------------------------------------------------------------- /lib/db/update.js: -------------------------------------------------------------------------------- 1 | 2 | var async = require('async') 3 | var qb = require('../qb')() 4 | 5 | 6 | exports.update = (args, done) => { 7 | var types = /insert|update/.test(args.action) 8 | ? ['view', 'oneToOne', 'manyToOne'] 9 | // remove 10 | : ['oneToOne', 'manyToOne', 'view'] 11 | 12 | async.eachSeries(types, (type, done) => { 13 | args.type = type 14 | args.tables = args.data[type] 15 | if (!args.tables) return done() 16 | 17 | async.eachSeries(Object.keys(args.tables), (table, done) => { 18 | var records = args.tables[table].records 19 | if (!records) return done() 20 | 21 | async.eachSeries(records, (record, done) => { 22 | var settings = args.settings[table] 23 | tbl.inline(args, settings, record) 24 | 25 | var data = tbl.query(args, settings, record) 26 | tbl.execute(data.action, args, data.query, record, settings, done) 27 | }, done) 28 | }, done) 29 | }, done) 30 | } 31 | 32 | 33 | var tbl = { 34 | // modifies inline record.columns[fk] with parent [pk] 35 | inline: (args, settings, record) => { 36 | if (args.type == 'view') return 37 | 38 | var fk = args.settings[args.name].editview[args.type][settings.table.name] 39 | fk = fk instanceof Array ? fk : [fk] 40 | for (var i=0; i < fk.length; i++) { 41 | record.columns[fk[i]] = args.id[i] 42 | } 43 | }, 44 | // settings - current table settings, record - current record data 45 | query: (args, settings, record) => { 46 | if (record.insert) { 47 | var str = qb.tbl.insert(args, settings, record) 48 | var action = 'insert' 49 | 50 | } else if (record.remove || args.action == 'remove') { 51 | var str = qb.tbl.remove(args, settings, record) 52 | var action = 'remove' 53 | 54 | } else { // update 55 | var str = qb.tbl.update(args, settings, record) 56 | var action = 'update' 57 | } 58 | 59 | return {action:action, query:str} 60 | }, 61 | _pks: (args, settings, record, result) => { 62 | var pks = settings.table.pk instanceof Array ? settings.table.pk : [settings.table.pk] 63 | var columns = record.columns 64 | var values = [] 65 | 66 | for (var i=0; i < pks.length; i++) { 67 | if (columns[pks[i]] === undefined) { 68 | values.push(result.insertId) 69 | } else { 70 | values.push(columns[pks[i]]) 71 | } 72 | } 73 | 74 | record.pk = values 75 | if (args.type == 'view' && args.action == 'insert') args.id = values 76 | }, 77 | execute: (action, args, str, record, settings, cb) => { 78 | if (args.readonly) return cb() 79 | 80 | // insert or update 81 | if (action != 'remove') { 82 | args.db.client.query(str, (err, result) => { 83 | if (err) return cb(err) 84 | 85 | if (action == 'insert') { 86 | tbl._pks(args, settings, record, result) 87 | } 88 | 89 | var queries = many.queries(action, args, record, settings.columns) 90 | many.execute(args, queries, (err) => { 91 | if (err) return cb(err) 92 | cb() 93 | }) 94 | }) 95 | } 96 | // remove 97 | else { 98 | var queries = many.queries(action, args, record, settings.columns) 99 | many.execute(args, queries, (err) => { 100 | if (err) return cb(err) 101 | args.db.client.query(str, (err, result) => { 102 | if (err) return cb(err) 103 | cb() 104 | }) 105 | }) 106 | } 107 | } 108 | } 109 | 110 | 111 | var many = { 112 | queries: (action, args, record, columns) => { 113 | var queries = [] 114 | 115 | for (var i=0; i < columns.length; i++) { 116 | if (!columns[i].manyToMany) continue 117 | 118 | var link = columns[i].manyToMany.link 119 | var dbIds = record.ids[columns[i].name] || [] 120 | var postIds = record.columns[columns[i].name] || [] 121 | 122 | postIds = (postIds instanceof Array) ? postIds : [postIds] 123 | 124 | var ins, del 125 | if (action === 'insert') { 126 | ins = many._insert(args, postIds, link, record) 127 | } 128 | else if (action === 'remove') { 129 | del = many._delete(args, dbIds, link, record) 130 | } 131 | else if (action === 'update') { 132 | var delIds = many._difference(dbIds, postIds) 133 | var insIds = many._difference(postIds, dbIds) 134 | del = many._delete(args, delIds, link, record) 135 | ins = many._insert(args, insIds, link, record) 136 | } 137 | ins ? queries.push(ins) : null 138 | del ? queries.push(del) : null 139 | } 140 | return queries 141 | }, 142 | _insert: (args, ids, link, record) => { 143 | if (!ids.length) return null 144 | return qb.mtm.insert(args, ids, link, record) 145 | }, 146 | _delete: (args, ids, link, record) => { 147 | if (!ids.length) return null 148 | return qb.mtm.remove(args, ids, link, record) 149 | }, 150 | _difference: (source, target) => { 151 | for (var i=0; i < source.length; i++) { 152 | source[i] = source[i].toString() 153 | } 154 | for (var i=0; i < target.length; i++) { 155 | target[i] = target[i].toString() 156 | } 157 | return source.filter((i) => { 158 | return !(target.indexOf(i) > -1) 159 | }) 160 | }, 161 | execute: (args, queries, done) => { 162 | async.eachSeries(queries, (query, done) => { 163 | args.db.client.query(query, done) 164 | }, done) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /lib/qb/partials.js: -------------------------------------------------------------------------------- 1 | 2 | var x = null 3 | 4 | 5 | function schema (table) { 6 | if (x.dialect != 'pg') return 7 | return table.schema || x._schema 8 | } 9 | 10 | function concat (columns, table, schema, sep) { 11 | if (!(columns instanceof Array)) return x.name(columns,table,schema) 12 | 13 | var result = [] 14 | if (x.dialect == 'sqlite') { 15 | for (var i=0; i < columns.length; i++) { 16 | result.push(x.func('ifnull',[x.name(columns[i],table),x.wrap('')])) 17 | } 18 | } 19 | else { 20 | // mysql|pg 21 | result = x.names(columns,table,schema) 22 | } 23 | columns = result 24 | 25 | return (/mysql|pg/.test(x.dialect)) 26 | ? x.func('concat_ws',[x.wrap(sep),columns]) 27 | // sqlite 28 | : columns.join("||'"+sep+"'||") 29 | } 30 | 31 | function group (columns) { 32 | return (/mysql|sqlite/.test(x.dialect)) 33 | ? x.func('group_concat',['distinct',columns],' ') 34 | // pg 35 | : x.func('string_agg',['distinct',[columns,x.wrap(',')].join()],' ') 36 | } 37 | 38 | function join (table, fk, ref, pk, alias) { 39 | var tbl = table.name||table.table 40 | var tsch = schema(table) 41 | var rsch = schema(ref) 42 | var ref = ref.table 43 | 44 | var fks = (fk instanceof Array) ? fk : [fk] 45 | var pks = (pk instanceof Array) ? pk : [pk] 46 | 47 | var condition = [] 48 | for (var i=0; i < fks.length; i++) { 49 | var pk = pks[i], fk = fks[i] 50 | 51 | condition.push( 52 | x.eq(x.name(fk,tbl,tsch), 53 | alias ? x.name(pk,alias) : x.name(pk,ref,rsch) 54 | ) 55 | ) 56 | } 57 | 58 | return x.join( 59 | alias ? x.alias(x.name(ref,rsch),x.name(alias)) : x.name(ref,rsch), 60 | condition, 61 | 'left' 62 | ) 63 | } 64 | 65 | function eq (table, column, value) { 66 | var columns = column instanceof Array ? column : [column] 67 | 68 | var values = 69 | (value instanceof Array) ? value : 70 | ('string'===typeof value ? value.split(',') : 71 | [value]) 72 | 73 | var tbl = table.name||table.table 74 | var sch = schema(table) 75 | 76 | var result = [] 77 | for (var i=0; i < columns.length; i++) { 78 | result.push(x.eqv(x.name(columns[i],tbl,sch),values[i])) 79 | } 80 | 81 | return result 82 | } 83 | 84 | // [1,2],[3,4] = [1,3],[2,4] 85 | function zip (columns, values) { 86 | for (var i=0; i < values.length; i++) { 87 | values[i] = values[i].toString().split(',') 88 | } 89 | var result = [] 90 | for (var i=0; i < columns.length; i++) { 91 | var val = [] 92 | for (var j=0; j < values.length; j++) { 93 | val.push(values[j][i]) 94 | } 95 | result.push(val) 96 | } 97 | return result 98 | } 99 | function _in (column, value, alias) { 100 | var columns = column instanceof Array ? column : [column] 101 | var values = value instanceof Array ? value : [value] 102 | 103 | values = zip(columns, values) 104 | 105 | var result = [] 106 | for (var i=0; i < columns.length; i++) { 107 | result.push([ 108 | x.name(columns[i],alias&&alias.alias), 109 | x.in(values[i]) 110 | ].join(' ')) 111 | } 112 | 113 | return x.and(result) 114 | } 115 | 116 | function tables (db) { 117 | if (x.dialect == 'mysql') { 118 | return [ 119 | 'show tables in', 120 | x.name(db), 121 | ';' 122 | ].join(' ') 123 | } 124 | else if (x.dialect == 'pg') { 125 | return [ 126 | x.select('table_name'), 127 | x.from('information_schema.tables'), 128 | x.where(x.eq('table_schema',x.wrap(db))), 129 | ';' 130 | ].join(' ') 131 | } 132 | else if (x.dialect == 'sqlite') { 133 | return [ 134 | x.select(x.name('name')), 135 | x.from('sqlite_master'), 136 | x.where(x.eq('type',x.wrap('table'))), 137 | ';' 138 | ].join(' ') 139 | } 140 | } 141 | 142 | function views (db) { 143 | if (x.dialect == 'mysql') { 144 | return [ 145 | 'show full tables in', 146 | x.name(db), 147 | x.where(x.name('TABLE_TYPE')), 148 | x.like(x.wrap('VIEW')), 149 | ';' 150 | ].join(' ') 151 | } 152 | else if (x.dialect == 'pg') { 153 | return "select viewname from pg_catalog.pg_views "+ 154 | "where schemaname NOT IN ('pg_catalog', 'information_schema') "+ 155 | "order by viewname;" 156 | } 157 | else if (x.dialect == 'sqlite') { 158 | return [ 159 | x.select(x.name('name')), 160 | x.from('sqlite_master'), 161 | x.where(x.eq('type',x.wrap('view'))), 162 | ';' 163 | ].join(' ') 164 | } 165 | } 166 | 167 | function columns (table, schema) { 168 | if (x.dialect == 'mysql') { 169 | return [ 170 | 'show columns in', 171 | x.name(table), 172 | 'in', 173 | x.name(schema), 174 | ';' 175 | ].join(' ') 176 | } 177 | else if (x.dialect == 'sqlite') { 178 | return [ 179 | 'pragma', 180 | x.func('table_info', x.wrap(table)), 181 | ';' 182 | ].join(' ') 183 | } 184 | else if (x.dialect == 'pg') { 185 | return 'SELECT cs.column_name AS "Field", cs.data_type AS "Type", '+ 186 | 'cs.is_nullable AS "Null", tc.constraint_type AS "Key", '+ 187 | 'cs.column_default AS "Default", '+ 188 | 'cs.numeric_precision, cs.numeric_precision_radix, cs.numeric_scale, '+ 189 | 'cs.character_maximum_length '+ 190 | 'FROM "information_schema"."columns" cs '+ 191 | 'LEFT JOIN "information_schema"."key_column_usage" kc '+ 192 | ' ON cs.column_name = kc.column_name '+ 193 | ' AND cs.table_schema = kc.table_schema '+ 194 | ' AND cs.table_name = kc.table_name '+ 195 | 'LEFT JOIN "information_schema"."table_constraints" tc '+ 196 | ' ON kc.constraint_name = tc.constraint_name '+ 197 | ' AND kc.table_schema = tc.table_schema '+ 198 | ' AND kc.table_name = tc.table_name '+ 199 | 'WHERE '+ 200 | ' cs.table_name = \''+table+'\' '+ 201 | ' AND cs.table_schema = \''+schema+'\' ;' 202 | } 203 | } 204 | 205 | module.exports = (instance) => { 206 | if (instance) x = instance 207 | return { 208 | schema, concat, group, join, eq, in:_in, 209 | tables, views, columns 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /lib/db/client.js: -------------------------------------------------------------------------------- 1 | 2 | // https://github.com/mysqljs/mysql 3 | function MySql () { 4 | try { 5 | var client = require('mysql') 6 | } 7 | catch (err) { 8 | throw Error('The `mysql` module was not found') 9 | } 10 | var connection = null 11 | var config = {} 12 | var mysql = true 13 | var name = 'mysql' 14 | 15 | var connect = (options, done) => { 16 | connection = client.createConnection(options) 17 | connection.connect((err) => { 18 | if (err) return done(err) 19 | Object.keys(connection.config).forEach((key) => { 20 | config[key] = connection.config[key] 21 | }) 22 | config.schema = config.database 23 | done() 24 | }) 25 | connection.on('error', (err) => { 26 | if (err.code == 'PROTOCOL_CONNECTION_LOST') { 27 | handleDisconnect(options) 28 | } 29 | else throw err 30 | }) 31 | } 32 | 33 | var handleDisconnect = (options) => { 34 | setTimeout(() => { 35 | connect(options, (err) => { 36 | err && handleDisconnect(options) 37 | }) 38 | }, 2000) 39 | } 40 | 41 | var query = (sql, done) => { 42 | connection.query(sql, (err, rows) => { 43 | if (err) return done(err) 44 | done(null, rows) 45 | }) 46 | } 47 | 48 | var getColumnsInfo = (data) => { 49 | var columns = {} 50 | for (var key in data) { 51 | var column = data[key] 52 | columns[column.Field] = { 53 | type: column.Type, 54 | allowNull: column.Null === 'YES' ? true : false, 55 | key: column.Key.toLowerCase(), 56 | defaultValue: column.Default 57 | // extra: column.Extra 58 | } 59 | } 60 | return columns 61 | } 62 | 63 | return { 64 | config, 65 | name, 66 | connect, 67 | handleDisconnect, 68 | query, 69 | getColumnsInfo, 70 | } 71 | } 72 | 73 | // https://github.com/brianc/node-postgres 74 | function PostgreSQL () { 75 | try { 76 | var client = require('pg') 77 | } 78 | catch (err) { 79 | throw Error('The `pg` module was not found') 80 | } 81 | var connection = null 82 | var config = {} 83 | var pg = true 84 | var name = 'pg' 85 | 86 | var connect = (options, done) => { 87 | connection = new client.Client(options) 88 | connection.connect((err) => { 89 | if (err) return done(err) 90 | Object.keys(connection.connectionParameters).forEach((key) => { 91 | config[key] = connection.connectionParameters[key] 92 | }) 93 | config.schema = options.schema || 'public' 94 | done() 95 | }) 96 | } 97 | 98 | var query = (sql, done) => { 99 | connection.query(sql, (err, result) => { 100 | if (err) return done(err) 101 | if (result.command == 'INSERT' && result.rows.length) { 102 | var obj = result.rows[0] 103 | var key = Object.keys(obj)[0] 104 | result.insertId = obj[key] 105 | return done(null, result) 106 | } 107 | // select 108 | done(null, result.rows) 109 | }) 110 | } 111 | 112 | function getType (column) { 113 | if (/^double precision$/.test(column.Type)) 114 | return 'double' 115 | 116 | else if (/^numeric$/.test(column.Type)) 117 | return column.numeric_precision 118 | ? 'decimal('+column.numeric_precision+','+column.numeric_scale+')' 119 | : 'decimal' 120 | 121 | else if (/^time\s.*/.test(column.Type)) 122 | return 'time' 123 | 124 | else if (/^timestamp\s.*/.test(column.Type)) 125 | return 'timestamp' 126 | 127 | else if (/^bit$/.test(column.Type)) 128 | return 'bit('+column.character_maximum_length+')' 129 | 130 | else if (/^character$/.test(column.Type)) 131 | return 'char('+column.character_maximum_length+')' 132 | 133 | else if (/^character varying$/.test(column.Type)) 134 | return 'varchar('+column.character_maximum_length+')' 135 | 136 | else if (/^boolean$/.test(column.Type)) 137 | return 'char' 138 | 139 | else return column.Type 140 | } 141 | 142 | var getColumnsInfo = (data) => { 143 | var columns = {} 144 | for (var key in data) { 145 | var column = data[key] 146 | columns[column.Field] = { 147 | type: getType(column), 148 | allowNull: column.Null === 'YES' ? true : false, 149 | key: (column.Key && column.Key.slice(0,3).toLowerCase()) || '', 150 | defaultValue: column.Default && column.Default.indexOf('nextval') == 0 ? null : column.Default 151 | // extra: column.Extra 152 | } 153 | } 154 | return columns 155 | } 156 | 157 | return { 158 | config, 159 | name, 160 | connect, 161 | query, 162 | getColumnsInfo, 163 | } 164 | } 165 | 166 | // https://github.com/TryGhost/node-sqlite3 167 | function SQLite () { 168 | try { 169 | var client = require('sqlite3') 170 | } 171 | catch (err) { 172 | throw new Error('The `sqlite3` module was not found') 173 | } 174 | var connection = null 175 | var config = {} 176 | var sqlite = true 177 | var name = 'sqlite' 178 | 179 | var connect = (options, done) => { 180 | connection = new client.Database(options.database) 181 | config = {schema:''} 182 | done() 183 | } 184 | 185 | var query = (sql, done) => { 186 | if (/^(insert|update|delete)/i.test(sql)) { 187 | connection.run(sql, function (err) { 188 | if (err) return done(err) 189 | done(null, {insertId: this.lastID}) 190 | }) 191 | } 192 | else { 193 | connection.all(sql, function (err, rows) { 194 | if (err) return done(err) 195 | done(null, rows) 196 | }) 197 | } 198 | } 199 | 200 | var getColumnsInfo = (data) => { 201 | var columns = {} 202 | for (var i=0; i < data.length; i++) { 203 | var column = data[i] 204 | columns[column.name] = { 205 | type: column.type, 206 | allowNull: column.notnull === 1 ? false : true, 207 | key: column.pk === 1 ? 'pri' : '', 208 | defaultValue: column.dflt_value 209 | } 210 | } 211 | return columns 212 | } 213 | 214 | return { 215 | config, 216 | name, 217 | connect, 218 | query, 219 | getColumnsInfo, 220 | } 221 | } 222 | 223 | module.exports = (config) => 224 | config.mysql ? MySql() : 225 | config.pg ? PostgreSQL() : 226 | config.sqlite ? SQLite() : 227 | null 228 | -------------------------------------------------------------------------------- /lib/qb/lst.js: -------------------------------------------------------------------------------- 1 | 2 | var x = null 3 | var z = require('./partials')() 4 | 5 | 6 | function join (table, column, index) { 7 | if (column.oneToMany) { 8 | var ref = column.oneToMany 9 | var alias = ref.table+index 10 | 11 | var joins = column.fk 12 | ? [z.join(table, column.fk, ref, ref.pk, alias)] 13 | : [z.join(table, column.name, ref, ref.pk, alias)] 14 | } 15 | else if (column.manyToMany) { 16 | var ref = column.manyToMany.ref 17 | var link = column.manyToMany.link 18 | var alias = ref.table+index 19 | 20 | var joins = [ 21 | z.join(table, table.pk, link, link.parentPk), 22 | z.join(link, link.childPk, ref, ref.pk, alias) 23 | ] 24 | } 25 | 26 | var concat = z.concat(ref.columns,alias,undefined,' ') 27 | var group = z.group(concat) 28 | 29 | return { 30 | select: x.as(group,x.name(column.name)), 31 | joins: joins 32 | } 33 | } 34 | 35 | 36 | function select (args) { 37 | var view = args.config 38 | var table = view.table 39 | var columns = view.columns 40 | 41 | var names = [] 42 | var joins = [] 43 | for (var i=0; i < columns.length; i++) { 44 | if (!columns[i].listview.show) continue 45 | 46 | if (columns[i].oneToMany || columns[i].manyToMany) { 47 | var result = join(table, columns[i], i) 48 | joins = joins.concat(result.joins) 49 | names.push(result.select) 50 | } 51 | else { 52 | names.push(x.name(columns[i].name,table.name,z.schema(table))) 53 | } 54 | } 55 | if (!table.view) { 56 | names.unshift(x.as(z.concat(table.pk,table.name,z.schema(table),','),x.name('__pk'))) 57 | } 58 | 59 | var where = statement(table, columns, args.filter, joins) 60 | 61 | // always group by pk inside the listview! 62 | var group = (function groupby () { 63 | if (table.view) return 64 | var pk = (table.pk instanceof Array) ? table.pk : [table.pk] 65 | return x.groupby(x.names(pk,table.name)) 66 | }()) 67 | 68 | var order = '' 69 | if (args.filter.order) { 70 | for (var i=0; i < columns.length; i++) { 71 | if (columns[i].name == args.filter.order) { 72 | var direction = args.filter.direction || 'asc' 73 | var column = (columns[i].oneToMany || columns[i].manyToMany) 74 | ? x.name(args.filter.order)//column as alias 75 | : x.name(args.filter.order,table.name,z.schema(table)) 76 | order = [column, direction].join(' ') 77 | break 78 | } 79 | } 80 | } 81 | else if (!Object.keys(view.listview.order).length) { 82 | if (table.view) { 83 | order = names[0] + ' asc' 84 | } 85 | else { 86 | var pk = table.pk instanceof Array ? table.pk[0] : table.pk 87 | order = x.name(pk,table.name,z.schema(table)) + ' asc' 88 | } 89 | } 90 | else { 91 | order = ((table, list) => { 92 | var result = [] 93 | for (var column in list) { 94 | var order = list[column] 95 | result.push([x.name(column,table.name,z.schema(table)), order].join(' ')) 96 | } 97 | return result.join() 98 | })(table, view.listview.order) 99 | } 100 | 101 | var from = args.page ? (args.page-1)*view.listview.page : 0 102 | 103 | args.statements = { 104 | columns: names.join(), table: x.name(table.name,z.schema(table)), 105 | join: joins.join(' '), 106 | where: where, group: group, order: order, 107 | from: from, to: view.listview.page 108 | } 109 | } 110 | 111 | function create (args) { 112 | var s = args.statements 113 | 114 | var str = [ 115 | x.select(s.columns), x.from(s.table), s.join, s.where, s.group, 116 | x.orderby(s.order), x.limit(s.from,s.to), ';' 117 | ].join(' ') 118 | 119 | args.debug && console.log('lst', str) 120 | args.query = str 121 | } 122 | 123 | function statement (table, columns, filter, joins) { 124 | var statements = [] 125 | for (var i=0; i < columns.length; i++) { 126 | var column = columns[i] 127 | var value = filter.columns[column.name] 128 | if (!value) continue 129 | 130 | if (column.oneToMany && column.fk) { 131 | if (value == 'NULL') continue 132 | statements.push(x.and(z.eq(table, column.fk, value))) 133 | } 134 | else if (column.manyToMany) { 135 | if (!column.listview.show) { 136 | var result = join(table, columns[i], i) 137 | joins.push(result.joins[0]) 138 | joins.push(result.joins[1]) 139 | } 140 | var ref = column.manyToMany.ref 141 | var alias = {alias: ref.table+i} 142 | statements.push(z.in(ref.pk, value, alias)) 143 | } 144 | else { 145 | var expr = expression(table, column.name, column.control, value) 146 | if (!expr) continue 147 | statements.push(expr) 148 | } 149 | } 150 | 151 | return statements.length 152 | ? x.where(statements,(filter.or?'or':'and')) 153 | : '' 154 | } 155 | 156 | function expression (table, column, control, value) { 157 | var name = x.name(column,table.name,z.schema(table)) 158 | 159 | if (control.select && !!control.options) { 160 | if ('string'===typeof value) value = x.string(value) 161 | return x.eq(name,value) 162 | } 163 | else if (control.select) { 164 | return x.eq(name,value) 165 | } 166 | else if (control.date || control.time || control.datetime || control.year) { 167 | if (value[0] == '' && value[1] == '') return null 168 | var from = value[0] || value[1] 169 | var to = value[1] || value[0] 170 | return [name, x.between(x.string(from),x.string(to))].join(' ') 171 | } 172 | else if (control.radio) { 173 | // string 0 or 1 - false|true 174 | if (x.dialect == 'pg') value = (value==0 ? x.wrap('f') : x.wrap('t')) 175 | return x.eq(name,value) 176 | } 177 | else if (control.text || control.file) { 178 | return [name,x.like(x.string(value+'%'))].join(' ') 179 | } 180 | else if (control.textarea) { 181 | return [name,x.like(x.string('%'+value+'%'))].join(' ') 182 | } 183 | else { 184 | return x.eq(name,value) 185 | } 186 | } 187 | 188 | function pagination (args) { 189 | var table = args.config.table 190 | var s = args.statements 191 | 192 | var concat = z.concat(table.pk,table.name,z.schema(table),',') 193 | 194 | var str = [ 195 | x.select( 196 | table.view ? '*' : x.as(x.func('count',['distinct',concat],' '),x.name('count'))), 197 | x.from(s.table), 198 | s.join, 199 | s.where, 200 | ';' 201 | ].join(' ') 202 | 203 | args.debug && console.log('pgr', str) 204 | return str 205 | } 206 | 207 | module.exports = (instance) => { 208 | if (instance) x = instance 209 | return { 210 | join, select, create, statement, expression, pagination 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /public/express-admin.css: -------------------------------------------------------------------------------- 1 | 2 | /*xs*/ 3 | @media (max-width: 768px) { 4 | /*all field labels*/ 5 | .x-table .control-label { padding-bottom: 5px; } 6 | /*listview buttons*/ 7 | .x-filter .panel-footer button { float: none; width: 100% !important; margin-top: 15px; } 8 | /*editview buttons*/ 9 | #controls button { float: none; width: 100% !important; margin-bottom: 15px; } 10 | /*login button*/ 11 | #login .btn-default { width: 100%; } 12 | } 13 | .container-fluid { width: auto; max-width: none; } 14 | 15 | /*hide chosen select by default*/ 16 | .chosen-select { display: none; } 17 | 18 | /*fix table size and content*/ 19 | .x-table { table-layout: fixed; } 20 | .x-table thead th, 21 | .x-table tbody td { word-wrap: break-word; } 22 | .x-table tbody td span.label-default { display: inline-block; max-width: 100%; white-space: normal; margin-bottom: 2px; } 23 | 24 | /*editview buttons*/ 25 | #controls { text-align: right; } 26 | #controls button { width: auto; } 27 | #controls .btn-danger { float: left; } 28 | 29 | /*fixed header fix*/ 30 | #content { padding-top: 70px; } 31 | /*login header*/ 32 | #login .label-default { color: #f8f8f2; } 33 | 34 | /*mainview*/ 35 | .x-table tbody td .glyphicon-plus { font-size: 8px; position: relative; top: -1px; } 36 | 37 | /*listview add record*/ 38 | #express-admin { position: relative; } 39 | #express-admin h2 .btn { font-weight: normal; float: right; } 40 | #express-admin h2 .glyphicon-filter { display: inline-block; font-size: 12px; } 41 | #express-admin h2 .glyphicon-filter:hover { text-decoration: none; } 42 | /*listview mtm labels*/ 43 | .x-table tbody td .label { margin-right: 5px; } 44 | .x-table tbody td .label:last-child { margin: 0; } 45 | /*listview search filter*/ 46 | .x-filter .panel-heading .checkbox { float: right; margin-top: -18px; } 47 | .x-filter .panel-body { padding: 0; } 48 | .x-filter .panel-body table { margin-bottom: 0; } 49 | .x-filter .panel-body table tr:first-child td { border: 0; } 50 | .x-filter .panel-body .form-group:last-child { margin-bottom: 0; } 51 | .x-filter .panel-footer .form-group { margin-bottom: 0; } 52 | .x-filter .panel-footer .form-group div.col-lg-3:last-child { text-align: right; } 53 | .x-filter .panel-footer button { width: auto; float: right; } 54 | .x-filter .panel-footer button:first-child { margin-left: 10px; } 55 | .x-filter.x-hidden .panel-footer { border-top: 0; } 56 | .x-filter.x-collapsed { display: none; } 57 | 58 | 59 | /*editview*/ 60 | .btn-today { line-height: 240%; } 61 | .x-table [type=file] { margin-top: 4px; } 62 | 63 | /*fix for all fields*/ 64 | .x-table .form-group { margin-bottom: 0; } 65 | /*inline table headers*/ 66 | .x-table .label-default { color: #f8f8f2; } 67 | /*checkbox remove inline*/ 68 | .x-table .head td { padding-top: 0; padding-bottom: 0; } 69 | .x-table .head label { float: right; margin-bottom: 0; } 70 | .x-table .head label input { margin-top: 0; } 71 | .x-table .jumbotron { padding-left: 0; padding-right: 0; border-radius: 0; } 72 | .x-table .jumbotron label { font-size: 14px; font-weight: normal; cursor: pointer; margin-right: 8px; } 73 | .x-table .jumbotron label [type=checkbox] { position: relative; top: 3px; cursor: pointer; } 74 | .x-table .remove { margin-right: 2px; } 75 | /*field error message*/ 76 | .x-table .form-group .help-block { margin-bottom: 0; } 77 | /*add another link*/ 78 | .x-table tfoot > tr > td { text-align: right; padding-top: 0; padding-bottom: 0; } 79 | 80 | 81 | /*dark themes fix*/ 82 | .datetimepicker * { background: #fff; color: #555; } 83 | 84 | /*chosen*/ 85 | .chosen-container { font-size: inherit; line-height: inherit; } 86 | .chosen-container .chosen-drop { color: #555; } 87 | .chosen-container .chosen-single { line-height: inherit; height: auto; background-color: #fff; padding: 0; } 88 | .chosen-container .chosen-single span { padding: 6px 12px; } 89 | 90 | /*single*/ 91 | .chosen-container-single .chosen-single { 92 | background-image: none; 93 | border: 1px solid #ccc; 94 | border-radius: 4px; 95 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); 96 | -webkit-transition: border linear 0.2s, box-shadow linear 0.2s;} 97 | .chosen-container-single .chosen-single abbr { top: 12px; } 98 | .chosen-container-single .chosen-single div b { width: 12px; height: 12px; background-position: 0 0; position: relative; top: 7px; } 99 | /*active*/ 100 | .chosen-container-active .chosen-single { 101 | border-color: rgba(82, 168, 236, 0.8); 102 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset, 0 0 8px rgba(82, 168, 236, 0.6); 103 | outline: 0 none;} 104 | .chosen-container-active.chosen-with-drop .chosen-single div b { background-position: -18px 0; } 105 | .chosen-container-active .chosen-single-with-drop { border-radius: 4px 4px 0px 0px; } 106 | /*error*/ 107 | .has-error .chosen-container .chosen-single span { color: #B94A48; } 108 | .has-error .chosen-container .chosen-single { 109 | border-color: #B94A48; 110 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset;} 111 | .has-error .chosen-container-active .chosen-single-with-drop { 112 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #D59392;} 113 | 114 | /*multiple*/ 115 | .chosen-container-multi .chosen-choices li.search-field input[type=text] { line-height: inherit; height: auto; } 116 | .chosen-container-multi .chosen-choices { 117 | background-image: none; 118 | border: 1px solid #ccc; 119 | border-radius: 4px; 120 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075); 121 | -webkit-transition: border linear 0.2s, box-shadow linear 0.2s; 122 | padding-top: 2px; padding-bottom: 2px;} 123 | /*default*/ 124 | .chosen-container-multi .chosen-choices li.search-field input[type=text] { color: #999; padding-left: 12px; margin: 4px 0 3px; } 125 | /*active*/ 126 | .chosen-container-multi.chosen-container-active .chosen-choices { 127 | border-color: rgba(82, 168, 236, 0.8); 128 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset, 0 0 8px rgba(82, 168, 236, 0.6); 129 | outline: 0 none;} 130 | /*error*/ 131 | .has-error .chosen-container-multi .chosen-choices { 132 | border-color: #B94A48; 133 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.075) inset;} 134 | .has-error .chosen-container-multi.chosen-container-active .chosen-choices { 135 | border-color: #B94A48; 136 | box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 6px #D59392;} 137 | 138 | 139 | /*sticky footer*/ 140 | html, body { height: 100%; } 141 | #wrapper { min-height: 100%; height: auto !important; height: 100%; margin: 0 auto -30px; } 142 | footer, #footer-push { height: 30px; text-align: center; } 143 | footer p { margin-bottom: 0; } 144 | 145 | 146 | /*custom scrollbars in WebKit*/ 147 | ::-webkit-scrollbar { 148 | width: 13px; 149 | } 150 | ::-webkit-scrollbar-track-piece { 151 | background: #333; 152 | -webkit-border-radius: 2px; 153 | } 154 | ::-webkit-scrollbar-thumb { 155 | height: 50px; 156 | background: #bbb; 157 | -webkit-border-radius: 8px; 158 | outline: 2px solid #333; 159 | outline-offset: -2px; 160 | border: 2px solid #333; 161 | } 162 | ::-webkit-scrollbar-thumb:hover { 163 | height: 50px; 164 | background-color: #ddd; 165 | -webkit-border-radius: 8px; 166 | } 167 | -------------------------------------------------------------------------------- /public/express-admin.js: -------------------------------------------------------------------------------- 1 | 2 | ;(function($) { 3 | 'use strict'; 4 | 5 | var chosen = { 6 | allow_single_deselect: true, 7 | no_results_text: 'No results matched!
Click to add ', 8 | width: '100%' 9 | }; 10 | function toJSONLocal (date) { 11 | var local = new Date(date); 12 | local.setMinutes(date.getMinutes() - date.getTimezoneOffset()); 13 | return local.toJSON().slice(0, 10); // toJSON is not supported in input', 22 | '.form-group .form-control', 23 | '.form-group .radio-inline input', 24 | '.form-group [type=file]' 25 | ].join(), self); 26 | } 27 | function initDatetimePickers (type, ctx) { 28 | var lang = cookie.getItem('lang'); 29 | var options = { 30 | weekStart: 1, autoclose: 1, todayHighlight: 1, 31 | keyboardNavigation: 0, forceParse: 0, viewSelect: 'decade', 32 | language: lang == 'cn' ? 'zh-CN' : lang 33 | }; 34 | var controls = [ 35 | {format: 'yyyy-mm-dd', formatViewType: 'date', startView: 2, minView: 2, maxView: 4}, 36 | {format: 'hh:ii:ss', formatViewType: 'time', startView: 1, minView: 0, maxView: 1}, 37 | {format: 'yyyy-mm-dd hh:ii:ss',formatViewType: 'date', startView: 2, minView: 0, maxView: 4}, 38 | {format: 'yyyy', formatViewType: 'date', startView: 4, minView: 4, maxView: 4} 39 | ]; 40 | var mobile = ['date', 'time', 'datetime', 'date']; 41 | 42 | var selectors = ['.date', '.time', '.datetime-', '.year']; 43 | for (var i=0; i < selectors.length; i++) { 44 | selectors[i] = (type == 'static') 45 | ? 'tr:not(.blank) ' + selectors[i] + 'picker,' 46 | + '.x-filter ' + selectors[i] + 'picker' 47 | : selectors[i] + 'picker'; 48 | } 49 | 50 | var have = false; 51 | for (var i=0; i < selectors.length; i++) { 52 | if ($(selectors[i], ctx).length) {have = true; break;} 53 | } 54 | if (!have) return; 55 | 56 | if (isMobile()) { 57 | for (var i=0; i < selectors.length; i++) { 58 | $(selectors[i], ctx).each(function (index) { 59 | $(this).attr('type', mobile[i]); 60 | }); 61 | } 62 | } else { 63 | for (var i=0; i < selectors.length; i++) { 64 | $(selectors[i], ctx).datetimepicker($.extend(options, controls[i])); 65 | } 66 | } 67 | } 68 | 69 | $(function () { 70 | // mainview table filter 71 | (function () { 72 | var $input = $('.x-mv.x-filter [name=table]'); 73 | $input.on('change input', function (e) { 74 | var value = $(this).val(); 75 | $('.x-table:eq(0) tbody tr').each(function (index) { 76 | var name = $('a:eq(0)', this).text(); 77 | (name.indexOf(value) == -1) ? $(this).hide() : $(this).show(); 78 | localStorage.setItem('mv-filter', value); 79 | }); 80 | }); 81 | $('.x-mv.x-filter [name=clear]').on('click', function (e) { 82 | localStorage.setItem('mv-filter', ''); 83 | $input.val(''); 84 | $input.trigger('change'); 85 | $('.x-filter').hide(); 86 | }); 87 | var mvfilter = localStorage.getItem('mv-filter'); 88 | if (mvfilter) { 89 | $input.val(mvfilter); 90 | $input.trigger('change'); 91 | $('.x-mv.x-filter').show(); 92 | } 93 | }()); 94 | 95 | // inlines 96 | $('.add-another').on('click', function (e) { 97 | // table and current index 98 | var name = $(this).data('table'), 99 | $table = $('table[data-table="'+name+'"]'); 100 | // get the last index 101 | var index = $('.head', $table).length-1; 102 | // clone and set classes 103 | var $rows = $('.blank', $table).clone(); 104 | $rows.removeClass('blank hidden'); 105 | 106 | // set the column keys 107 | $rows.each(function (idx) { 108 | var $controls = getControls(this); 109 | $controls.each(function (i) { 110 | var name = $(this).attr('name'); 111 | $(this).attr('name', 112 | name.replace('blank', 'records').replace('index', index)); 113 | }); 114 | }); 115 | // set keys for insert 116 | (function () { 117 | var name = $rows.eq(0).find('input').attr('name'); 118 | $rows.eq(0).find('input').attr('name', 119 | name.replace('blank', 'records').replace('index', index)); 120 | }()); 121 | 122 | // append 123 | var tbody = $('tbody', $table); 124 | $rows.appendTo(tbody); 125 | 126 | // init controls 127 | if ($('.chosen-select').length) { 128 | if (isMobile()) $('.chosen-select').show(); 129 | $('.chosen-select', $rows).chosen(chosen); 130 | } 131 | initDatetimePickers('dynamic', $rows); 132 | if (typeof onAddInline === 'function') 133 | onAddInline($rows); 134 | 135 | // one 136 | if ($table.parents('#one').length) { 137 | $('tfoot', $table).addClass('hidden'); 138 | } 139 | return false; 140 | }); 141 | $('table').on('click', '.remove', function (e) { 142 | var name = $(this).data('table'), 143 | $table = $('table[data-table="'+name+'"]'); 144 | 145 | // remove 146 | var head = $(this).parents('.head'), 147 | rows = head.nextUntil('tr.head'); 148 | head.remove(); 149 | rows.remove(); 150 | 151 | // re-set the indexes 152 | $('.head:not(.blank)', $table).each(function (index) { 153 | var idx = -1; 154 | 155 | $('.jumbotron input', this).each(function () { 156 | var name = $(this).attr('name'); 157 | idx = name.match(/.*(\[\d+\]).*/)[1]; 158 | $(this).attr('name', name.replace(idx, '['+index+']')); 159 | }); 160 | 161 | $(this).nextUntil('tr.head').each(function () { 162 | 163 | var $controls = getControls(this); 164 | $controls.each(function (i) { 165 | var name = $(this).attr('name'); 166 | $(this).attr('name', name.replace(idx, '['+index+']')); 167 | }); 168 | }); 169 | }); 170 | 171 | // one 172 | if ($table.parents('#one').length) { 173 | $('tfoot', $table).removeClass('hidden'); 174 | } 175 | return false; 176 | }); 177 | 178 | // layout 179 | $('#x-layout a').on('click', function (e) { 180 | $('body, #navbar').removeClass(); 181 | var layout = this.hash.slice(1); 182 | layout == 'fixed' 183 | ? $('body, #navbar').addClass('container') 184 | : $('body, #navbar').addClass('container container-fluid'); 185 | $('#x-layout li').removeClass('active'); 186 | $(this).parent().addClass('active'); 187 | localStorage.setItem('layout', layout); 188 | return false; 189 | }); 190 | 191 | // theme 192 | var bootstrap = $('#bootstrap'); 193 | $('#x-theme a').on('click', function (e) { 194 | var theme = this.hash.slice(1), 195 | url = xAdmin.root+'/bootswatch/'+theme+'/bootstrap.min.css'; 196 | bootstrap.attr('href', url); 197 | $('#x-theme li').removeClass('active'); 198 | $(this).parent().addClass('active'); 199 | localStorage.setItem('theme', theme); 200 | return false; 201 | }); 202 | 203 | // lang 204 | $('#x-lang a').on('click', function (e) { 205 | cookie.setItem('lang', this.hash.slice(1)); 206 | window.location.reload(true); 207 | return false; 208 | }); 209 | 210 | $('body').on('click', 'td .form-group .btn-today', function (e) { 211 | $(this).parents('td').find('input').val(toJSONLocal(new Date())); 212 | return false; 213 | }); 214 | 215 | // filter 216 | $('.glyphicon-filter').on('click', function (e) { 217 | $('.x-filter').toggle(); 218 | return false; 219 | }); 220 | 221 | // init 222 | var layout = localStorage.getItem('layout') || 'fixed'; 223 | $('#x-layout li').removeClass('active'); 224 | $('#x-layout [href$="'+layout+'"]').parent().addClass('active'); 225 | 226 | var theme = localStorage.getItem('theme') || 'default'; 227 | $('#x-theme li').removeClass('active'); 228 | $('#x-theme [href$="'+theme+'"]').parent().addClass('active'); 229 | 230 | var lang = cookie.getItem('lang'); 231 | $('#x-lang li').removeClass('active'); 232 | $('#x-lang [href$="'+lang+'"]').parent().addClass('active'); 233 | if (lang != 'en') 234 | $('head').append( 235 | '' 237 | ); 238 | 239 | // chosen 240 | if ($('.chosen-select').length) { 241 | if (isMobile()) $('.chosen-select').show(); 242 | $('tr:not(.blank) .chosen-select, .x-filter .chosen-select').chosen(chosen); 243 | } 244 | 245 | // datepicker 246 | initDatetimePickers('static', document); 247 | }); 248 | })(jQuery); 249 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | 2 | var fs = require('fs') 3 | var path = require('path') 4 | 5 | var express = require('express') 6 | var bodyParser = require('body-parser') 7 | var multipart = require('connect-multiparty') 8 | var cookieParser = require('cookie-parser') 9 | var session = require('express-session') 10 | var csrf = require('csurf') 11 | var methodOverride = require('method-override') 12 | var serveStatic = require('serve-static') 13 | var consolidate = require('consolidate') 14 | var hogan = require('hogan.js') 15 | 16 | var moment = require('moment') 17 | var async = require('async') 18 | 19 | var Client = require('./lib/db/client') 20 | var schema = require('./lib/db/schema') 21 | var settings = require('./lib/app/settings') 22 | var routes = require('./lib/app/routes') 23 | 24 | var Xsql = require('xsql') 25 | var Qb = require('./lib/qb') 26 | var dcopy = require('deep-copy') 27 | 28 | 29 | // sets args.db.client 30 | // updates args.settings 31 | function initDatabase (args, done) { 32 | try { 33 | var client = Client(args.config) 34 | } 35 | catch (err) { 36 | return done(err) 37 | } 38 | var qb 39 | async.series([ 40 | (done) => { 41 | var options = args.config.mysql || args.config.pg || args.config.sqlite 42 | client.connect(options, (err) => { 43 | if (err) return done(err) 44 | var x = new Xsql({ 45 | dialect: client.name, 46 | schema: client.config.schema 47 | }) 48 | qb = Qb(x) 49 | done() 50 | }) 51 | }, 52 | (done) => { 53 | var sql = qb.partials.tables(client.config.schema) 54 | client.query(sql, (err, rows) => { 55 | if (err) return done(err) 56 | if (!rows.length) return done(new Error('Empty schema!')) 57 | done() 58 | }) 59 | }, 60 | (done) => { 61 | schema.getData(client, (err, data) => { 62 | if (err) return done(err) 63 | // write back the settings 64 | var updated = settings.refresh(args.settings, data) 65 | fs.writeFileSync(args.config.admin.settings, JSON.stringify(updated, null, 2), 'utf8') 66 | args.settings = updated 67 | done() 68 | }) 69 | } 70 | ], (err) => { 71 | if (err) return done(err) 72 | args.db = {client} 73 | done() 74 | }) 75 | } 76 | 77 | // modifies args 78 | function initSettings (args) { 79 | // route variables 80 | 81 | // upload 82 | var upload = args.config.admin.upload || path.join(__dirname, 'public/upload') 83 | args.config.admin.upload = upload 84 | if (!fs.existsSync(upload)) fs.mkdirSync(upload) 85 | 86 | // languages 87 | args.langs = (() => { 88 | var dpath = path.join(__dirname, 'config/lang') 89 | var files = fs.readdirSync(dpath) 90 | var langs = {} 91 | for (var i=0; i < files.length; i++) { 92 | var name = files[i].replace(path.extname(files[i]), '') 93 | langs[name] = require(path.join(dpath, files[i])) 94 | } 95 | if (args.config.admin.locale) { 96 | var fpath = args.config.admin.locale 97 | var fname = path.basename(fpath) 98 | var locale = fname.replace(path.extname(fname), '') 99 | langs[locale] = require(fpath) 100 | args.locale = locale 101 | } 102 | return langs 103 | })() 104 | 105 | // slug to table map 106 | args.slugs = (() => { 107 | var slugs = {} 108 | for (var key in args.settings) { 109 | slugs[args.settings[key].slug] = key 110 | } 111 | return slugs 112 | })() 113 | 114 | // readonly mode 115 | args.readonly = args.config.admin.readonly || false 116 | // debug logs 117 | args.debug = args.config.admin.debug || false 118 | 119 | // events 120 | for (var key in args.custom) { 121 | var fpath = args.custom[key].events 122 | if (fpath) break 123 | } 124 | var events = fpath ? require(fpath) : {} 125 | if (!events.hasOwnProperty('preSave')) 126 | events.preSave = (req, res, args, next) => {next()} 127 | if (!events.hasOwnProperty('postSave')) 128 | events.postSave = (req, res, args, next) => {next()} 129 | if (!events.hasOwnProperty('preList')) 130 | events.preList = (req, res, args, next) => {next()} 131 | args.events = events 132 | 133 | 134 | // template variables 135 | 136 | // root 137 | if (args.config.admin.root) { 138 | var root = args.config.admin.root 139 | if (/.*\/$/.test(root)) args.config.admin.root = root.slice(0, -1) 140 | } 141 | else { 142 | args.config.admin.root = '' 143 | } 144 | 145 | // layouts 146 | args.layouts = args.config.admin.layouts !== undefined 147 | ? args.config.admin.layouts 148 | : true 149 | 150 | // themes 151 | args.themes = args.config.admin.themes === undefined || args.config.admin.themes 152 | ? {theme: require(path.join(__dirname, 'config/themes'))} 153 | : null 154 | 155 | // languages 156 | args.languages = (() => { 157 | if (args.config.admin.languages !== undefined && !args.config.admin.languages) return null 158 | var langs = [] 159 | for (var key in args.langs) { 160 | langs.push({key: key, name: args.langs[key].name}) 161 | } 162 | return {language: langs} 163 | })() 164 | 165 | // footer 166 | args.footer = args.config.admin.footer || { 167 | text: 'Express Admin', 168 | url: 'https://github.com/simov/express-admin' 169 | } 170 | 171 | // static 172 | args.libs = dcopy(require(path.join(__dirname, 'config/libs'))) 173 | args.libs.external = {css: [], js: []} 174 | for (var key in args.custom) { 175 | var assets = args.custom[key].public 176 | if (!assets) continue 177 | if (assets.local) { 178 | args.libs.js = args.libs.js.concat(assets.local.js || []) 179 | args.libs.css = args.libs.css.concat(assets.local.css || []) 180 | } 181 | if (assets.external) { 182 | args.libs.external.js = args.libs.external.js.concat(assets.external.js || []) 183 | args.libs.external.css = args.libs.external.css.concat(assets.external.css || []) 184 | } 185 | } 186 | } 187 | 188 | function initServer (args) { 189 | var r = require('./routes'); 190 | 191 | // general settings 192 | var app = express() 193 | .set('views', path.resolve(__dirname, './views')) 194 | .set('view engine', 'html') 195 | .engine('html', consolidate.hogan) 196 | 197 | .use(bodyParser.json()) 198 | .use(bodyParser.urlencoded({extended: true})) 199 | .use(multipart()) 200 | 201 | .use(cookieParser()) 202 | .use(session(args.config.admin.session || { 203 | name: 'express-admin', 204 | secret: 'very secret', 205 | saveUninitialized: true, 206 | resave: true 207 | })) 208 | .use(r.auth.status)// session middleware 209 | .use(csrf()) 210 | .use(methodOverride()) 211 | 212 | // custom favicon 213 | if (args.config.admin.favicon) { 214 | app.use(serveStatic(args.config.admin.favicon)) 215 | } 216 | 217 | app.use(serveStatic(path.join(__dirname, 'public'))) 218 | app.use(serveStatic((() => { 219 | var dpath = path.resolve(__dirname, 'node_modules/express-admin-static') 220 | if (!fs.existsSync(dpath)) { 221 | dpath = path.resolve(__dirname, '../express-admin-static') 222 | } 223 | return dpath 224 | })())) 225 | 226 | if (!args.readonly) app.set('view cache', true) 227 | 228 | // register custom static local paths 229 | for (var key in args.custom) { 230 | var assets = args.custom[key].public 231 | if (!assets || !assets.local || !assets.local.path || 232 | !fs.existsSync(assets.local.path)) continue 233 | app.use(serveStatic(assets.local.path)) 234 | } 235 | 236 | // pass server wide variables 237 | app.use((req, res, next) => { 238 | // app data 239 | res.locals._admin = args 240 | 241 | // i18n 242 | var lang = req.cookies.lang || args.locale || 'en' 243 | res.cookie('lang', lang, {path: '/', maxAge: 900000000}) 244 | moment.locale(lang === 'cn' ? 'zh-cn' : lang) 245 | 246 | // template vars 247 | res.locals.string = args.langs[lang] 248 | res.locals.root = args.config.admin.root 249 | res.locals.libs = args.libs 250 | res.locals.themes = args.themes 251 | res.locals.layouts = args.layouts 252 | res.locals.languages = args.languages 253 | res.locals.footer = args.footer 254 | 255 | // required for custom views 256 | res.locals._admin.views = app.get('views') 257 | 258 | next() 259 | }) 260 | 261 | // routes 262 | 263 | // init regexes 264 | var _routes = routes.init(args.settings, args.custom) 265 | 266 | // register custom apps 267 | ;(() => { 268 | var have = false 269 | for (var key in args.custom) { 270 | var _app = args.custom[key].app 271 | if (_app && _app.path && fs.existsSync(_app.path)) { 272 | var view = require(_app.path) 273 | app.use(view) 274 | have = true 275 | } 276 | } 277 | if (have && _routes.custom) app.all(_routes.custom, r.auth.restrict, r.render.admin) 278 | })() 279 | 280 | // login/logout 281 | app.get('/login', r.login.get, r.render.admin) 282 | app.post('/login', r.auth.login) 283 | app.get('/logout', r.auth.logout) 284 | 285 | // editview 286 | app.get(_routes.editview, r.auth.restrict, r.editview.get, r.render.admin) 287 | app.post(_routes.editview, r.auth.restrict, r.editview.post, r.render.admin) 288 | 289 | // listview 290 | app.get(_routes.listview, r.auth.restrict, r.listview.get, r.render.admin) 291 | app.post(_routes.listview, r.auth.restrict, r.listview.post, r.render.admin) 292 | 293 | // mainview 294 | app.get(_routes.mainview, r.auth.restrict, r.mainview.get, r.render.admin) 295 | 296 | // not found 297 | app.all('*', r.auth.restrict, r.notfound.get, r.render.admin) 298 | 299 | return app 300 | } 301 | 302 | function init (config, done) { 303 | if (!config.config) throw new Error('Admin `config` is required!') 304 | if (!config.settings) config.settings = {} 305 | if (!config.users) config.users = {} 306 | if (!config.custom) config.custom = {} 307 | initDatabase(config, (err) => { 308 | if (err) return done(err) 309 | initSettings(config) 310 | done(null, initServer(config)) 311 | }) 312 | } 313 | 314 | module.exports = (config) => { 315 | var handle = express() 316 | 317 | init(config, (err, admin) => { 318 | if (err) { 319 | console.error(err) 320 | process.exit() 321 | } 322 | // mount lazily 323 | handle.use(admin) 324 | }) 325 | 326 | return handle 327 | } 328 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Express Admin 3 | 4 | [![npm-version]][npm] [![snyk-vulnerabilities]][snyk] 5 | 6 | > _MySQL, MariaDB, PostgreSQL, SQLite admin for Node.js_ 7 | 8 |
brief history 9 | 10 | Express Admin is a tool for creating end user administrative interfaces for relational databases in (literally) _less than 10 minutes_. 11 | 12 | It was initially conceived back in 2012 and it was released officially in 2013. The user interface is built with [Bootstrap] (v3.3.1) and it is fully responsive and it works on any device. It also comes with all [Bootswatch] themes for that version of Bootstrap. 13 | 14 | The entirety of the admin consists of just 3 views: main view, list view, and edit view. The main view lists all tables available inside the database and optionally a list of custom views. The list view lists table records with support for pagination and a powerful filtering widget for filtering and ordering the list. The edit view is responsible for viewing and editing database records and is capable of correctly modeling any table relationship, and it supports rich user input controls, such as date/time pickers and text editors. 15 | 16 | The rendering is done entirely on the backend using [Hogan.js] (a [mustache.js] derivative). The frontend is using [jQuery] (v1.9.1) mainly for initializing the beautiful plugins: [Chosen] (v1.2.0) for rendering select boxes for many-to-one and many-to-many relationships and [Bootstrap Datepicker] for rendering various time and date pickers. 17 | 18 | At the heart of the admin is the `settings.json` file. The `settings.json` file contains a robust data structure responsible for configuring all aspects of the admin, such as: table relationships, table visibility inside the main views, column settings such as: type and input control to use, verbose name, visibility etc., view settings, filter configuration etc. 19 | 20 | Lastly, the admin also comes with various extension points for: custom static files on the frontend, custom views on the backend, and pre/post save hooks to augment the behavior of the admin. 21 | 22 |
23 | 24 |
quick preview 25 | 26 | **How it Looks** 27 | 28 | - Table Relationships 29 | - [One to Many][example-one-to-many] 30 | - [Many to Many][example-many-to-many] 31 | - [Many to One][example-many-to-one] 32 | - [One to One][example-one-to-one] 33 | - [Control Types][example-control-types] 34 | - [Complex Inline][example-complex-inline] 35 | - [Listview Filter][example-listview-filter] _(click on the little funnel icon next to the page header)_ 36 | - [Custom Views][example-custom-views] 37 | 38 | **Examples** 39 | 40 | Get the admin up and running on your localhost in less than 10 minutes and play around with the **[examples]**. 41 | 42 | **Tests** 43 | 44 | Express Admin comes with a comprehensive integration **[tests]** suite: https://vimeo.com/795985128 45 | 46 |
47 | 48 | ## Table of Contents 49 | 50 | - **[Middleware](#middleware)** 51 | - **[Configuration](#configuration)** 52 | - [config](#configuration-config) / [settings](#configuration-settings) / [users](#configuration-users) / [custom](#configuration-custom) 53 | - **[Relationships](#relationships)** 54 | - [One to Many](#relationships-one-to-many) / [Many to Many](#relationships-many-to-many) / [Many to One](#relationships-many-to-one) / [One to One](#relationships-one-to-one) / [Compound Primary Key](#compound-primary-key) 55 | - **[Filter](#filter)** 56 | - **[Customization](#customization)** 57 | - [Custom Static Files](#custom-static-files) / [Custom Views](#custom-views) / [Event Hooks](#custom-event-hooks) 58 | - **[Hosting](#hosting)** 59 | - [Nginx](#hosting-nginx) / [Multiple Admins](#hosting-multiple-admins) 60 | - **[Examples]** 61 | - **[Tests]** 62 | 63 | --- 64 | 65 | ## Middleware 66 | 67 | Express Admin is an [Express.js] middleware: 68 | 69 | 70 | ```js 71 | var express = require('express') 72 | var admin = require('express-admin') 73 | 74 | express() 75 | .use(admin({ 76 | config: require('config.json'), 77 | settings: require('settings.json'), 78 | users: require('users.json'), 79 | custom: require('custom.json'), 80 | })) 81 | .listen(3000) 82 | ``` 83 | 84 | At the minimum you need Express.js, Express Admin, and a database driver: 85 | 86 | ```json 87 | { 88 | "dependencies": { 89 | "express": "^4.18.2", 90 | "express-admin": "^2.0.0", 91 | "mysql": "^2.18.1" 92 | } 93 | } 94 | ``` 95 | 96 | Express Admin was tested and is known to work with the following database drivers: 97 | 98 | | Driver | Version | Engine 99 | | :- | -: | :- 100 | | **[mysql]** | `^2.18.1` | **MySQL** _(from v5 up to latest)_ **MariaDB** _(from v10.0 up to latest)_ 101 | | **[pg]** | `^8.9.0` | **PostgreSQL** _(from v9 up to latest)_ 102 | | **[sqlite3]** | `^5.1.4` | **SQLite** _(from v3.8.0 up to latest)_ 103 | 104 | --- 105 | 106 | ## Configuration 107 | 108 | | Key | Availability | Description 109 | | :- | :- | :- 110 | | `config` | **required** | general configuration about the database and the admin 111 | | `settings` | **required** | settings for all tables, columns, relationships, views, etc. 112 | | `users` | **required** | list of admin users 113 | | `custom` | _optional_ | custom views, event hooks, and static files to be loaded with the admin 114 | 115 | For simplicity all of the [examples] are storing all of their configuration in JSON files. However, only the `settings` configuration is required to be a JSON file stored on your server, because the admin may add new default table entries in it whenever you add a new table to your database. On the contrary the `config` and the `users` configuration, potentially containing sensitive passwords in plain text, can be loaded during the initialization of your server from an external storage and fed into the admin middleware as an object. 116 | 117 | --- 118 | 119 | ## Configuration: `config` 120 | 121 | ```json 122 | { 123 | "mysql": { // or "pg" or "sqlite" 124 | "database": "...", 125 | "user": "...", 126 | "password": "..." 127 | // "schema": "... pg only" 128 | }, 129 | "admin": { 130 | "settings": "/absolute/path/to/settings.json" 131 | } 132 | } 133 | ``` 134 | 135 | ### Database Connection 136 | 137 | The `mysql`, `pg` and `sqlite` key instruct the admin to load the relevant database driver, either: [mysql], [pg] or [sqlite3]. That driver needs to be set as a dependency in your package.json file and installed: 138 | 139 | - **mysql** - accepts any [connection option][mysql-connection] for the `mysql` module 140 | - **pg** - accepts any [connection option][pg-connection] for the `pg` module 141 | - **sqlite** - accepts only a `database` option containing an absolute path to a database file to use 142 | 143 | At the minimum you need: 144 | 145 | - **mysql** or **pg** or **sqlite** - database connection options 146 | - **database** - name of the database to use for this connection (or absolute path to a database file for SQLite) 147 | - **user** - database user to authenticate with (not used wih SQLite) 148 | - **password** - password for that database user (not used wih SQLite) 149 | 150 | Outside of the regular connection options for PostgreSQL the admin also accepts a `schema` name to use for that database. It defaults to `public`. 151 | 152 | ### Admin Configuration 153 | 154 | The `admin` config key is used to customize various aspects of the admin app: 155 | 156 | - **settings** _(**required**)_ - the absolute path to the `settings.json` file 157 | 158 | - **layouts** _(default: true)_ - toggle the layouts selection button inside the header 159 | 160 | - **themes** _(default: true)_ - toggle the themes selection button inside the header 161 | 162 | - **languages** _(default: true)_ - toggle the languages selection button inside the header 163 | 164 | - **favicon** - absolute path to a folder containing a `favicon.ico` file. When this path is set the admin will serve your custom `favicon.ico` file instead of the default one 165 | 166 | - **footer** - accepts `{text: '..', url: '..'}` object to customize the default footer text and URL. Set it to empty `{}` object to hide the footer 167 | 168 | - **locale** - absolute path to a locale.json file. Copy/paste some of the existing [locale] files and customize it to your liking. The name of your file will be used as either a new locale or it will override an existing one with the same name. The locale that you set through this option will be selected and used by default for new users 169 | 170 | - **root** _(default: '')_ - path prefix for the admin instance. Required only when you mount the admin under a path prefix with `app.use('/prefix', admin({}))`. Also take a look at the [hosting examples](#hosting-multiple-admins) 171 | 172 | - **upload** _(default: 'public/upload')_ - absolute path to the upload location for columns with control type set to `file: true`. By default the admin will upload and store such files inside `your-project/node_modules/express-admin/public/upload` 173 | 174 | - **session** - accepts any of the configuration options for the underlying [session] middleware. The default configuration is: `{name: 'express-admin', secret: 'very secret', saveUninitialized: true, resave: true}` 175 | 176 | - **readonly** _(default: false)_ - when set to `true` the authentication, CSRF, session logout and database updates are off. The readonly mode is useful while configuring the admin using the `settings.json` file. Additionally a watcher can be set to reload your server on changes being made to the `settings.json` file. Don't forget to turn off the readonly mode when you are done editing the `settings.json` file! 177 | 178 | - **debug** _(default: false)_ - set this to `true` to print the underlying SQL queries that the admin performs. Additionally a logging middleware, such as [morgan], can be mounted before the admin to log the underlying HTTP requests as well 179 | 180 | --- 181 | 182 | ## Configuration: `settings` 183 | 184 | All settings related to the default Express Admin views are set inside the `settings.json` file, which is automatically generated with default values at first start up. Every time you add a new table to your database a default configuration object will be added for it to your `settings.json` file at startup. 185 | 186 | ### table 187 | 188 | The `settings.json` file contains a list of objects representing tables in your database: 189 | 190 | ```json 191 | { 192 | "table_name": { 193 | "slug": "unique-slug", 194 | "table": { 195 | "name": "table_name", 196 | "pk": "pk_name", 197 | "verbose": "Verbose Name" 198 | // "schema": "name" // pg: set specific schema for this table only 199 | }, 200 | "columns": [ 201 | {...}, // see column definition below 202 | // { "manyToMany" ... } // see 'Many to Many' documentation 203 | ], 204 | "mainview": { 205 | "show": true 206 | }, 207 | "listview": { 208 | "order": { 209 | "column_name1": "asc", 210 | "column_name2": "desc" 211 | }, 212 | "page": 25, 213 | "filter": ["column_name1", "column_name2" ...] 214 | }, 215 | "editview": { 216 | "readonly": false, 217 | // "manyToOne": { ... }, // see 'Many to One' documentation 218 | // "oneToOne": { ... } // see 'One to One' documentation 219 | } 220 | } 221 | } 222 | ``` 223 | 224 | - **slug** - unique slug among all other tables 225 | - **table** - table settings 226 | - **name** - the table name in your database _(typically you won't change this)_ 227 | - **pk** - the table's primary key _(it will be set automatically)_ 228 | - **verbose** - user friendly name to use for this table inside the admin 229 | - **columns** - array of all columns found in this table _(see below)_ 230 | - **mainview** - settings about the mainview _(where tables are listed)_ 231 | - **show** - toggle the table visibility inside the table list. Typically you want to hide tables that will be edited as [inlines](#relationships-many-to-one) of other tables, or tables that are used as links for [many to many](#relationships-many-to-many) relationships 232 | - **listview** - settings about the listview _(where the table records are listed)_ 233 | - **order** - default record order, either ascending or descending _(defaults to ascending)_ 234 | - **page** - how many records to show per page _(defaults to 25)_ 235 | - **filter** - list of column names to enable as filtering options inside the filter widget 236 | - **editview** - settings about the editview _(where the record is being shown and edited)_ 237 | - **readonly** - to make the table non editable set this flag to true, in that case the save and delete buttons below the record won't be rendered 238 | - **manyToOne** - set inline tables to be edited along with this one _(see [many to one](#relationships-many-to-one) documentation)_ 239 | - **oneToOne** - set inline tables to be edited along with this one _(see [one to one](#relationships-one-to-one) documentation)_ 240 | 241 | To re-order how the tables appear inside the mainview copy/paste the entire table object and move it to another place inside the `settings.json` file. 242 | 243 | ### column 244 | 245 | Each table object contains a list of colum objects: 246 | 247 | ```json 248 | { 249 | "verbose": "Verbose Name", 250 | "name": "column_name", 251 | "control": { 252 | "text": true 253 | }, 254 | "type": "varchar(45)", 255 | "allowNull": false, 256 | "defaultValue": null, 257 | "listview": { 258 | "show": true 259 | }, 260 | "editview": { 261 | "show": true 262 | }, 263 | // "oneToMany": { ... }, // see 'One to Many' documentation 264 | } 265 | ``` 266 | 267 | - **verbose** - user friendly name to use for this column inside the admin 268 | - **name** - the column name in your database _(typically you won't change this)_ 269 | - **control** - one of these: 270 | 271 | ```js 272 | {"text": true} // input type="text" 273 | {"textarea": true} // textarea 274 | {"textarea": true, "editor": "some-class"} // text editor (see customization section) 275 | {"number": true} // input type="number" 276 | {"date": true} // datepicker 277 | {"time": true} // timepicker 278 | {"datetime": true} // datetimepicker 279 | {"year": true} // yearpicker 280 | {"file": true} // input type="file" (uploads to file system) 281 | {"file": true, "binary": true} // input type="file" (uploads to blob|bytea fields) 282 | {"radio": true, "options": ["True","False"]} // input type="radio" 283 | {"select": true} // select single (used for one-to-many relationships) 284 | {"select": true, "multiple": true} // select multiple (used for many-to-many relationships) 285 | {"select": true, "options": ["value&text",{"value":"text"}]} // select with static options 286 | ``` 287 | 288 | - **type** - the column data type in your database _(typically you won't change this)_ 289 | - **allowNull** - allowed to be null inside the database or not 290 | - **defaultValue** - currently not used 291 | - **listview** - settings about the listview _(where the table records are listed)_ 292 | - **show** - toggle the column visibility inside the listview. Typically you want to see only colums that contain short and meaningful information describing the whole record clearly. Primary key columns and columns that contain large amount of text typically should be hidden in this view 293 | - **editview** - settings about the editview _(where the record is being shown and edited)_ 294 | - **show** - toggle the column visibility inside the listview
295 | **`All auto increment columns should be hidden!`**
296 | **`Foreign keys for inline tables should be hidden!`**
297 | Columns that are not allowed to be `null` inside the database cannot be hidden as this will result in a database error when trying to insert or update the record 298 | - **oneToMany** - configure one to many relationship _(see [one to many](#relationships-one-to-many) documentation)_ 299 | 300 | Additionally a column entry can be added manually inside the `columns` array for configuring [many to many](#relationships-many-to-many) relationships. 301 | 302 | To re-order how the columns appear inside the listview and the editview copy/paste the entire column object and move it to another place inside the `columns` array. 303 | 304 | **[Control Types Example][example-control-types]** 305 | 306 | --- 307 | 308 | ## Configuration: `users` 309 | 310 | The users configuration accepts a list of unique keys identifying the admin users: 311 | 312 | ```json 313 | { 314 | "admin": { 315 | "name": "admin", 316 | "pass": "1234abCD" 317 | } 318 | } 319 | ``` 320 | 321 | - **"unique key name"** 322 | - **name** - user name to login with 323 | - **pass** - user password to login with 324 | 325 | Each user get access to the entirety of your admin instance. There are no roles and different levels of access. 326 | 327 | For increased security you may want to load the user password from external storage dynamically and feed that into the admin middleware on startup. 328 | 329 | --- 330 | 331 | ## Configuration: `custom` 332 | 333 | The custom configuration can be used to extend the admin with additional static files, endpoints on the backend, custom views rendered inside the admin, and pre/post save event hooks. 334 | 335 | You can arrange your custom resources any way you want. The custom config should contain uniquely named objects containing any of the supported custom configuration keys: 336 | 337 | ```json 338 | { 339 | "Unique Name": { 340 | "app": { 341 | "path": "/absolute/path/to/custom/app.js", 342 | "slug": "unique-slug", 343 | "verbose": "Verbose Name", 344 | "mainview": { 345 | "show": true 346 | } 347 | }, 348 | "public": { 349 | "external": { 350 | "css": [ 351 | "https://absolute/url/external.css" 352 | ], 353 | "js": [ 354 | "https://absolute/url/external.js" 355 | ] 356 | }, 357 | "local": { 358 | "path": "/absolute/path/to/custom/public/folder", 359 | "css": [ 360 | "/relative/to/above/global.css" 361 | ], 362 | "js": [ 363 | "/relative/to/above/global.js" 364 | ] 365 | } 366 | }, 367 | "events": "/absolute/path/to/custom/events.js" 368 | } 369 | } 370 | ``` 371 | 372 | - **app** - Epress.js application (middleware) _(see [custom views](#custom-views) documentation)_ 373 | - **path** - absolute path to the file to mount 374 | - **slug** - prefix for all routes in this custom app 375 | - **verbose** - user friendly name to show inside the mainview 376 | - **mainview** - settings about the mainview 377 | - **show** - toggle the custom view visibility inside the mainview (inside the custom views list below the table list) 378 | - **public** - custom static files to include inside the `` tag _(see [custom static files](#custom-static-files) documentation)_ 379 | - **external** - external files 380 | - **css** - list of css files to be included 381 | - **js** - list of js files to be included 382 | - **local** - local files 383 | - **path** - absolute path to the static files location 384 | - **css** - list of css files to be included 385 | - **js** - list of js files to be included 386 | - **events** - path to file containing event hooks _(see [event hooks](#custom-event-hooks) documentation)_ 387 | 388 | **[Custom View Example][example-custom-views]** 389 | 390 | --- 391 | 392 | ## Relationships 393 | 394 | ## Relationships: One to Many 395 | 396 | ![img-one-to-many] 397 | 398 | 1. Find the table that you want to configure inside the `settings.json` file 399 | 2. Find the foreign key column that you want to use for the relation 400 | 3. Copy/paste the following `oneToMany` object into the column settings and configure it 401 | 4. Change the control type of the column to `select` 402 | 403 | ```json 404 | "control": { 405 | "select": true 406 | }, 407 | "oneToMany": { 408 | "table": "user", 409 | "pk": "id", 410 | "columns": [ 411 | "firstname", 412 | "lastname" 413 | ] 414 | } 415 | ``` 416 | 417 | > The `oneToMany` key can contain a `schema` name to use for the relation table (PostgreSQL only) 418 | 419 | - **oneToMany** - configuration about the table that this foreign key references 420 | - **table** - name of the table that is being referenced 421 | - **pk** - name of the referenced table primary key column _(can be array as well, see [compound primary key](#compound-primary-key) documentation)_ 422 | - **columns** - array of columns to select from the referenced table and use that as a label inside the select box _(space delimited)_ 423 | 424 | **[One To Many Example][example-one-to-many]** 425 | 426 | --- 427 | 428 | ## Relationships: Many to Many 429 | 430 | ![img-many-to-many] 431 | 432 | 1. Find the table that you want to configure inside the `settings.json` file 433 | 2. Copy/paste the following object inside the `columns` array and configure it 434 | 435 | ```json 436 | { 437 | "verbose": "Recipe Types", 438 | "name": "recipe_type", 439 | "control": { 440 | "select": true, 441 | "multiple": true 442 | }, 443 | "type": "int(11)", 444 | "allowNull": false, 445 | "listview": { 446 | "show": false 447 | }, 448 | "editview": { 449 | "show": true 450 | }, 451 | "manyToMany": { 452 | "link": { 453 | "table": "recipe_has_recipe_types", 454 | "parentPk": "recipe_id", 455 | "childPk": "recipe_type_id" 456 | }, 457 | "ref": { 458 | "table": "recipe_type", 459 | "pk": "id", 460 | "columns": [ 461 | "title" 462 | ] 463 | } 464 | } 465 | } 466 | ``` 467 | 468 | > The `link` and `ref` keys can contain a `schema` name to use for the relation table (PostgreSQL only) 469 | 470 | - **verbose** - user friendly name to use for this column inside the admin 471 | - **name** - some arbitrary name but it has to be unique among all other columns in this table 472 | - **control** - the control type have to be a multi select 473 | - **type** - leave this as it is 474 | - **allowNull** - allowed to create record in this table without selecting any item from the referenced one, or not 475 | - **listview** - settings about the listview _(where the table records are listed)_ 476 | - **show** - toggle the column visibility inside the listview 477 | - **editview** - settings about the editview _(where the record is being shown and edited)_ 478 | - **show** - toggle the column visibility inside the listview 479 | - **manyToMany** - configuration about the many to many relationship 480 | - **link** - configuration about the table that links this one and the referenced one 481 | - **table** - name of the table that acts as a link 482 | - **parentPk** - name of the primary key of the parent table _(can be array as well)_ 483 | - **childPk** - name of the primary key of the child table _(can be array as well)_ 484 | - **ref** - configuration about the referenced table 485 | - **table** - name of the table that is being referenced 486 | - **pk** - name of the referenced table primary key column _(can be array as well)_ 487 | - **columns** - array of columns to select from the referenced table and use that as a label inside the select box _(space delimited)_ 488 | 489 | > The `parentPk` and `childPk` keys of the `link` table can be array as well.
490 | > The `pk` key of the `ref` table can be array as well.
491 | > See [compound primary key](#compound-primary-key) documentation. 492 | 493 | **[Many To Many Example][example-many-to-many]** 494 | 495 | --- 496 | 497 | ## Relationships: Many to One 498 | 499 | ![img-many-to-one] 500 | 501 | 1. Find the table that you want to configure inside the `settings.json` file 502 | 2. Copy/paste the following object inside the `editview` object and configure it 503 | 504 | ```json 505 | "manyToOne": { 506 | "repair": "car_id", 507 | "driver": "car_id" 508 | } 509 | ``` 510 | 511 | - **manyToOne** - configuration about the tables that will be included and edited as inline record 512 | - **table:fk** - each item in this object is a table name and its foreign key column that is referencing the parent table _(or array of foreign keys, see [compound primary key](#compound-primary-key) documentation)_ 513 | 514 | **[Many To One Example][example-many-to-one]** 515 | 516 | --- 517 | 518 | ## Relationships: One to One 519 | 520 | ![img-one-to-one] 521 | 522 | 1. Find the table that you want to configure inside the `settings.json` file 523 | 2. Copy/paste the following object inside the `editview` object and configure it 524 | 525 | ```json 526 | "oneToOne": { 527 | "address": "user_id", 528 | "phone": "user_id" 529 | } 530 | ``` 531 | 532 | - **oneToOne** - configuration about the tables that will be included and edited as inline record 533 | - **table:fk** - each item in this object is a table name and its foreign key column that is referencing the parent table _(or array of foreign keys, see [compound primary key](#compound-primary-key) documentation)_ 534 | 535 | **[One To One Example][example-one-to-one]** 536 | 537 | --- 538 | 539 | ## Compound Primary Key 540 | 541 | ![img-compound-primary-key] 542 | 543 | Any table in `settings.json` can have multiple primary keys specified: 544 | 545 | ```json 546 | { 547 | "table": { 548 | "name": "tbl", 549 | "pk": [ 550 | "id1", 551 | "id2" 552 | ], 553 | "verbose": "tbl" 554 | } 555 | } 556 | ``` 557 | 558 | ### Compound: One to Many 559 | 560 | ![img-compound-one-to-many] 561 | 562 | In case One to Many table relationship is referenced by multiple foreign keys, the regular [One to Many](#relationships-one-to-many) configuration cannot be used, as it expects to be put inside an existing column inside the `settings.json` file. 563 | 564 | Therefore an additional column entry have to be added to the `columns` array, similar to how [Many to Many](#relationships-many-to-many) relationship is being configured. 565 | 566 | The `fk` key specifies the foreign keys in this table that are referencing the other one: 567 | 568 | ```json 569 | { 570 | "verbose": "otm", 571 | "name": "otm", 572 | "control": { 573 | "select": true 574 | }, 575 | "type": "varchar(45)", 576 | "allowNull": false, 577 | "listview": { 578 | "show": true 579 | }, 580 | "editview": { 581 | "show": true 582 | }, 583 | "fk": [ 584 | "otm_id1", 585 | "otm_id2" 586 | ], 587 | "oneToMany": { 588 | "table": "otm", 589 | "pk": [ 590 | "id1", 591 | "id2" 592 | ], 593 | "columns": [ 594 | "name" 595 | ] 596 | } 597 | } 598 | ``` 599 | 600 | ### Compound: Many to Many 601 | 602 | ![img-compound-many-to-many] 603 | 604 | In case tables with multiple primary keys are part of a Many to Many table relationship, the regular [Many to Many](#relationships-many-to-many) setting is used, but additionally the `parentPk` and `childPk` keys inside the `link` table, and the `pk` key inside the `ref` table, can be set to an array of foreign and primary keys respectively to accommodate that design: 605 | 606 | ```json 607 | { 608 | "verbose": "mtm", 609 | "name": "mtm", 610 | "control": { 611 | "select": true, 612 | "multiple": true 613 | }, 614 | "type": "varchar(45)", 615 | "allowNull": false, 616 | "listview": { 617 | "show": true 618 | }, 619 | "editview": { 620 | "show": true 621 | }, 622 | "manyToMany": { 623 | "link": { 624 | "table": "tbl_has_mtm", 625 | "parentPk": [ 626 | "tbl_id1", 627 | "tbl_id2" 628 | ], 629 | "childPk": [ 630 | "mtm_id1", 631 | "mtm_id2" 632 | ] 633 | }, 634 | "ref": { 635 | "table": "mtm", 636 | "pk": [ 637 | "id1", 638 | "id2" 639 | ], 640 | "columns": [ 641 | "name" 642 | ] 643 | } 644 | } 645 | } 646 | ``` 647 | 648 | ### Compound: Many to One 649 | 650 | ![img-compound-many-to-one] 651 | 652 | Similar to the regular [Many to One](#relationships-many-to-one) configuration, but additionally the value for each table listed there can be set to an array of foreign keys referencing this table. 653 | 654 | ```json 655 | "manyToOne": { 656 | "mto": [ 657 | "tbl_id1", 658 | "tbl_id2" 659 | ] 660 | } 661 | ``` 662 | 663 | ### Compound: One to One 664 | 665 | Similar to the regular [One to One](#relationships-one-to-one) configuration, but additionally the value for each table listed there can be set to an array of foreign keys referencing this table. 666 | 667 | ```json 668 | "oneToOne": { 669 | "oto": [ 670 | "tbl_id1", 671 | "tbl_id2" 672 | ] 673 | } 674 | ``` 675 | 676 | --- 677 | 678 | ## Filter 679 | 680 | Columns can be enabled to be available for filtering inside the listview by listing them inside the `filter` list for that table's `listview` config: 681 | 682 | ```json 683 | "listview": { 684 | "order": {}, 685 | "page": 5, 686 | "filter": [ 687 | "item_id", 688 | "user_id", 689 | "cache", 690 | "date", 691 | "deleted", 692 | "deleted_at" 693 | ] 694 | } 695 | ``` 696 | 697 | All column data types and control types will be picked from the `columns` definition for that table. For example, a foreign key identifier configured as one-to-many relationship will be rendered as select box to allow the user to pick a value from. Similarly a many-to-many column entry can be listed by its `name` key and it will be rendered as multiple select box allowing you to pick multiple values from the referenced table to filter by. Date and time pickers will be rendered as pairs of two controls to allow you to specify date and time ranges if needed. 698 | 699 | **[Listview Filter Example][example-listview-filter]** _(click on the little funnel icon next to the page header)_ 700 | 701 | --- 702 | 703 | ## Customization 704 | 705 | ## Custom: Static Files 706 | 707 | Custom static files can be included at the end of the `` tag of the base template: 708 | 709 | ```json 710 | { 711 | "something": { 712 | "public": { 713 | "external": { 714 | "js": [ 715 | "https://cdn.ckeditor.com/4.4.2/standard/ckeditor.js" 716 | ] 717 | }, 718 | "local": { 719 | "path": "/absolute/path/to/folder", 720 | "css": [ 721 | "/some.css" 722 | ], 723 | "js": [ 724 | "/some.js" 725 | ] 726 | } 727 | } 728 | } 729 | } 730 | ``` 731 | 732 | Take a look at the [examples] repository. 733 | 734 | ### CKEditor 735 | 736 | One good example of the use of custom files could be extending the admin with a rich text editor. 737 | 738 | Find the column that you want to configure in `settings.json` and set its control type to a `textarea` and additionally set a CSS class name to use for the rich text editor: 739 | 740 | ```json 741 | "control": { 742 | "textarea": true, 743 | "editor": "class-name" 744 | } 745 | ``` 746 | 747 | Then inside the `custom.json` file specify the static files to include: 748 | 749 | ```json 750 | { 751 | "Rich Text Editors": { 752 | "public": { 753 | "external": { 754 | "js": [ 755 | "https://cdn.ckeditor.com/4.4.2/standard/ckeditor.js" 756 | ] 757 | }, 758 | "local": { 759 | "path": "/absolute/path/to/folder", 760 | "js": [ 761 | "/relative/path/to/the/above/init.js" 762 | ] 763 | } 764 | } 765 | } 766 | } 767 | ``` 768 | 769 | CKEditor v4.4.2 can be initialized like this: 770 | 771 | ```js 772 | $(function () { 773 | if (typeof CKEDITOR !== 'undefined') { 774 | CKEDITOR.replaceAll(function (textarea, config) { 775 | // exclude textareas that are inside hidden inline rows 776 | if ($(textarea).parents('tr').hasClass('blank')) return false 777 | // textareas with this class name will get the default configuration 778 | if (textarea.className.includes('class-name')) return true 779 | // all other textareas won't be initialized as ckeditors 780 | return false 781 | }) 782 | } 783 | }) 784 | 785 | // executed each time an inline is added 786 | function onAddInline (rows) { 787 | if (typeof CKEDITOR !== 'undefined') { 788 | // for each of the new rows containing textareas 789 | $('textarea', rows).each(function (index) { 790 | // get the DOM instance 791 | var textarea = $(this)[0] 792 | // textareas with this class name will get the default configuration 793 | if (textarea.className.includes('class-name')) return CKEDITOR.replace(textarea) 794 | // all other textareas won't be initialized as ckeditors 795 | return false 796 | }) 797 | } 798 | } 799 | ``` 800 | 801 | Note that jQuery `$` is loaded globally for the entire admin. 802 | 803 | The `class-name` is the same class name that we specified for that column inside the `settings.json` file. 804 | 805 | The `CKEDITOR.replaceAll` method loops throgh all textareas available on the page and filters them out to only those that needs to be initialized as CKEditors. Inline records for Many to One and One to One relationships has a hidden blank row used as a template whenever the user tries to add new inline record to the page. Any textarea inside that `blank` row have to be excluded. 806 | 807 | The hidden textareas are initialized after the user clicks on the link to add a new inline record. The `onAddInline` is an event like global function that is called each time an inline record is appended to the list of inline records. The `rows` parameters contains all table rows that has been added. Again we loop through all of them and initialize only those textareas that have the class we specified in `settings.json` 808 | 809 | **[CKEditor Example][example-complex-inline]** 810 | 811 | --- 812 | 813 | ## Custom: Views 814 | 815 | The custom view config is configured by the `app` key: 816 | 817 | ```json 818 | { 819 | "My Awesome View": { 820 | "app": { 821 | "path": "/absolute/path/to/app.js", 822 | "slug": "hi", 823 | "verbose": "Basic View", 824 | "mainview": { 825 | "show": true 826 | } 827 | } 828 | } 829 | } 830 | ``` 831 | 832 | The `app.js` file contains an Express.js middleware: 833 | 834 | ```js 835 | var express = require('express') 836 | var app = module.exports = express() 837 | var path = require('path') 838 | 839 | app.set('views', __dirname) 840 | 841 | app.get('/hi', (req, res, next) => { 842 | // variable that will be available inside your template 843 | res.locals.hello = 'Hi' 844 | // realtive path from the admin's view folder to your custom folder 845 | var relative = path.relative(res.locals._admin.views, app.get('views')) 846 | // the content partial holds the main content of the page 847 | res.locals.partials = { 848 | // path to your hello.html template 849 | content: path.join(relative, 'hello') 850 | } 851 | // continue so that the admin can render the entire page 852 | next() 853 | }) 854 | ``` 855 | 856 | The `hello.html` template is an HTML file that contains [Hogan.js] ([mustache.js]) variables: 857 | 858 | ```html 859 |

{{hello}}, how are you?

860 | ``` 861 | 862 | The `res.locals` object contains all of the template variables that will be used across various partials to render the entirety of the admin UI. 863 | 864 | One additional variable called `res.locals._admin` exposes the admin internals to your route. The contents of this variable are not meant to be rendered and are there for internal use by your custom routes. 865 | 866 | For example, the `res.locals._admin.db.client` holds a reference to the underlying database client wrapper that the admin uses internally: 867 | 868 | ```js 869 | app.get('/hi', (req, res, next) => { 870 | var client = res.locals._admin.db.client 871 | // do some queries 872 | client.query('... sql ...', (err, result) => { 873 | // do something with result data 874 | }) 875 | } 876 | ``` 877 | 878 | To find more about the available data there you can put a breakpoint inside your custom route handler and inspect it with a debugger. 879 | 880 | Also have a look at the [examples] repository. 881 | 882 | **[Custom View Example][example-custom-views]** 883 | 884 | --- 885 | 886 | ## Custom: Event Hooks 887 | 888 | The supported event hooks are: 889 | 890 | - **[preSave](#custom-presave)** - before a record is saved 891 | - **[postSave](#custom-postsave)** - after a record was saved 892 | - **[preList](#custom-prelist)** - before the listview is rendered 893 | 894 | The event hooks config is configured by the `events` key: 895 | 896 | ```json 897 | { 898 | "My Awesome Event Hooks": { 899 | "events": "/absolute/path/to/event/handlers.js" 900 | } 901 | } 902 | ``` 903 | 904 | The event handlers are similar to Express.js middlewares, but they have one additional `args` parameter: 905 | 906 | ```js 907 | exports.preSave = (req, res, args, next) => { 908 | // do something 909 | next() 910 | } 911 | exports.postSave = (req, res, args, next) => { 912 | // do something 913 | next() 914 | } 915 | exports.postList = (req, res, args, next) => { 916 | // do something 917 | next() 918 | } 919 | ``` 920 | 921 | You can put a breakpoint inside any of your event hook handlers and inspect the available handler parameters with a debugger. 922 | 923 | Also have a look at the [examples] repository. 924 | 925 | ## Custom: `preSave` 926 | 927 | The `args` parameter contains: 928 | 929 | - **action** - query operation: `insert`, `update` or `remove` 930 | - **name** - the table name for which this operation was initiated for 931 | - **slug** - the slug of that table 932 | - **data** - data submitted via POST request or returned from the database 933 | - **view** - this table's data (the one currently shown inside the _editview_) 934 | - **oneToOne | manyToOne** - inline tables data 935 | ```js 936 | "table's name": { 937 | "records": [ 938 | "columns": {"column's name": "column's value", ...}, 939 | "insert|update|remove": "true" // only for inline records 940 | ] 941 | } 942 | ``` 943 | - **upath** - absolute path to the upload folder location 944 | - **upload** - list of files to be uploaded submitted via POST request 945 | - **db** - database connection instance 946 | 947 | ### `preSave` - set created_at and updated_at fields 948 | 949 | In this example we are updating the `created_at` and the `updated_at` fileds for a table called `user`: 950 | 951 | ```js 952 | var moment = require('moment') 953 | 954 | exports.preSave = (req, res, args, next) => { 955 | if (args.name === 'user') { 956 | var now = moment(new Date()).format('YYYY-MM-DD hh:mm:ss') 957 | var record = args.data.view.user.records[0].columns 958 | if (args.action === 'insert') { 959 | record.created_at = now 960 | record.updated_at = now 961 | } 962 | else if (args.action === 'update') { 963 | record.updated_at = now 964 | } 965 | } 966 | next() 967 | } 968 | ``` 969 | 970 | The `created_at` and the `updated_at` columns have to be hidden inside the editview because they will be updated internally by your event hook. Set `show: false` for the `editview` key for those columns inside the `settings.json` file. 971 | 972 | ### `preSave` - generate hash identifier 973 | 974 | In this example we are generating a hash `id` for a table called `cars`. That table view also contains `manyToOne` inline tables to be edited along with it that also needs their `id` generated: 975 | 976 | ```js 977 | var shortid = require('shortid') 978 | 979 | exports.preSave = (req, res, args, next) => { 980 | if (args.name == 'car') { 981 | if (args.action == 'insert') { 982 | var table = args.name 983 | var record = args.data.view[table].records[0].columns 984 | record.id = shortid.generate() 985 | } 986 | for (var table in args.data.manyToOne) { 987 | var inline = args.data.manyToOne[table] 988 | if (!inline.records) continue 989 | for (var i=0; i < inline.records.length; i++) { 990 | if (inline.records[i].insert != 'true') continue 991 | inline.records[i].columns.id = shortid.generate() 992 | } 993 | } 994 | } 995 | next() 996 | } 997 | ``` 998 | 999 | All of the `id` columns have to be hidden inside the editview because they will be generated internally by your event hook. Set `show: false` for the `editview` key for those columns inside the `settings.json` file. 1000 | 1001 | ### `preSave` - soft delete records 1002 | 1003 | In this example we are soft deleting records for a table called `purchase`. That table view also contains `manyToOne` inline tables to be edited along with it that also requires their records to be soft deleted: 1004 | 1005 | ```js 1006 | var moment = require('moment') 1007 | 1008 | exports.preSave = (req, res, args, next) => { 1009 | if (args.name === 'purchase') { 1010 | var now = moment(new Date()).format('YYYY-MM-DD hh:mm:ss') 1011 | // all inline oneToOne and manyToOne records should be marked as deleted 1012 | for (var table in args.data.manyToOne) { 1013 | var inline = args.data.manyToOne[table] 1014 | if (!inline.records) continue 1015 | for (var i=0; i < inline.records.length; i++) { 1016 | if (args.action !== 'remove' && !inline.records[i].remove) continue 1017 | // instead of deleting the record 1018 | delete inline.records[i].remove 1019 | // update it 1020 | inline.records[i].columns.deleted = true 1021 | inline.records[i].columns.deleted_at = now 1022 | } 1023 | } 1024 | // parent record 1025 | if (args.action == 'remove') { 1026 | // instead of deleting the record 1027 | args.action = 'update' 1028 | // update it 1029 | var record = args.data.view.purchase.records[0].columns 1030 | record.deleted = true 1031 | record.deleted_at = now 1032 | } 1033 | } 1034 | next() 1035 | } 1036 | ``` 1037 | 1038 | All of the `deleted` and `deleted_at` columns have to be hidden inside the editview because they will be managed by your event hook. Set `show: false` for the `editview` key for those columns inside the `settings.json` file. 1039 | 1040 | --- 1041 | 1042 | ## Custom: `postSave` 1043 | 1044 | The `args` parameter contains: 1045 | 1046 | - **action** - query operation: `insert`, `update` or `remove` 1047 | - **name** - the table name for which this operation was initiated for 1048 | - **slug** - the slug of that table 1049 | - **data** - data submitted via POST request or returned from the database 1050 | - **view** - this table's data (the one currently shown inside the _editview_) 1051 | - **oneToOne | manyToOne** - inline tables data 1052 | ```js 1053 | "table's name": { 1054 | "records": [ 1055 | "columns": {"column's name": "column's value", ...}, 1056 | "insert|update|remove": "true" // only for inline records 1057 | ] 1058 | } 1059 | ``` 1060 | - **upath** - absolute path to the upload folder location 1061 | - **upload** - list of files to be uploaded submitted via POST request 1062 | - **db** - database connection instance 1063 | 1064 | ### `postSave` - upload files to a third party server 1065 | 1066 | - in this example our table will be called `item` 1067 | - the item's table `image`'s column control type should be set to `file:true` in `settings.json` 1068 | - use the code below to upload the image, after the record is saved 1069 | 1070 | In this example we are uploading an image to a third-party service for a table called `item`: 1071 | 1072 | ```js 1073 | var cloudinary = require('cloudinary') 1074 | var fs = require('fs') 1075 | var path = require('path') 1076 | cloudinary.config({cloud_name: '...', api_key: '...', api_secret: '...'}) 1077 | 1078 | exports.postSave = (req, res, args, next) => { 1079 | if (args.name === 'item') { 1080 | // file upload control data 1081 | var image = args.upload.view.item.records[0].columns.image 1082 | // in case file is chosen through the file input control 1083 | if (image.name) { 1084 | // file name of the image already uploaded to the upload folder 1085 | var fname = args.data.view.item.records[0].columns.image 1086 | // upload 1087 | var fpath = path.join(args.upath, fname) 1088 | cloudinary.uploader.upload(fpath, (result) => { 1089 | console.log(result) 1090 | next() 1091 | }) 1092 | } 1093 | else next() 1094 | } 1095 | else next() 1096 | } 1097 | ``` 1098 | 1099 | The `image` column needs to have its control type set to `file: true` inside the `settings.json` file. 1100 | 1101 | --- 1102 | 1103 | ## Custom: `preList` 1104 | 1105 | The `args` parameter contains: 1106 | 1107 | - **name** - the table name for which this operation was initiated for 1108 | - **slug** - the slug of that table 1109 | - **filter** - filter data submitted via POST request 1110 | - **columns** - list of columns (and their values) to filter by 1111 | - **direction** - sort order direction 1112 | - **order** - column names to order by 1113 | - **or** - `true|false` whether to use logical _or_ or not 1114 | - **statements** - sql query strings partials 1115 | - **columns** - columns to select 1116 | - **table** - table to select from 1117 | - **join** - join statements 1118 | - **where** - where statements 1119 | - **group** - group by statements 1120 | - **order** - order by statements 1121 | - **from** - limit from number 1122 | - **to** - limit to number 1123 | - **db** - database connection instance 1124 | 1125 | ### `preList` - hide soft deleted records by default 1126 | 1127 | Have a look at the `preSave` hook example about soft deleted records. 1128 | 1129 | ```js 1130 | exports.preList = (req, res, args, next) => { 1131 | if (args.name === 'purchase') { 1132 | // check if we are using a listview filter 1133 | // and we want to see soft deleted records 1134 | var filter = args.filter.columns 1135 | if (filter && (filter.deleted == '1' || filter.deleted_at && filter.deleted_at[0])) { 1136 | return next() 1137 | } 1138 | // otherwise hide the soft deleted records by default 1139 | var filter = 1140 | ' `purchase`.`deleted` IS NULL OR `purchase`.`deleted` = 0' + 1141 | ' OR `purchase`.`deleted_at` IS NULL '; 1142 | args.statements.where 1143 | ? args.statements.where += ' AND ' + filter 1144 | : args.statements.where = ' WHERE ' + filter 1145 | } 1146 | next() 1147 | } 1148 | ``` 1149 | 1150 | --- 1151 | 1152 | ## Hosting 1153 | 1154 | By default all of the static assets needed by the admin will be served by the admin middleware itself. A good way to improve the performance of your admin instance is to serve only the dynamic routes with Node.js and leave the static files to be served by a reverse proxy instead. 1155 | 1156 | ## Hosting: Nginx 1157 | 1158 | ```nginx 1159 | # redirect HTTP to HTTPS 1160 | server { 1161 | listen 80; 1162 | server_name mywebsite.com; 1163 | return 301 https://$host$request_uri; 1164 | } 1165 | # HTTPS only 1166 | server { 1167 | listen 443 ssl; 1168 | server_name mywebsite.com; 1169 | 1170 | # (optional) you can put an additional basic auth in front of your admin 1171 | auth_basic 'Restricted'; 1172 | auth_basic_user_file /absolute/path/to/.htpasswd; 1173 | 1174 | access_log /var/log/nginx/mywebsite.com-access.log; 1175 | error_log /var/log/nginx/mywebsite.com-error.log debug; 1176 | 1177 | # certificates for HTTPS 1178 | ssl_certificate /etc/letsencrypt/live/mywebsite.com/fullchain.pem; 1179 | ssl_certificate_key /etc/letsencrypt/live/mywebsite.com/privkey.pem; 1180 | 1181 | # forward all requests to Node.js except for the static files below 1182 | location / { 1183 | # this is where your admin instance is listening to 1184 | proxy_pass http://127.0.0.1:3000/$uri$is_args$args; 1185 | # (optional) hide the fact that your app was built with Express.js 1186 | proxy_hide_header X-Powered-By; 1187 | } 1188 | 1189 | # express-admin - static files bundled with the admin 1190 | location /express-admin.css { 1191 | root /absolute/path/to/express-admin/node_modules/express-admin/public; 1192 | try_files $uri =404; 1193 | } 1194 | location /express-admin.js { 1195 | root /absolute/path/to/express-admin/node_modules/express-admin/public; 1196 | try_files $uri =404; 1197 | } 1198 | location /favicon.ico { 1199 | root /absolute/path/to/express-admin/node_modules/express-admin/public; 1200 | try_files $uri =404; 1201 | } 1202 | 1203 | # express-admin-static - third-party static files bundled with the admin 1204 | location /jslib/ { 1205 | root /absolute/path/to/express-admin/node_modules/express-admin-static; 1206 | try_files $uri =404; 1207 | } 1208 | location /csslib/ { 1209 | root /absolute/path/to/express-admin/node_modules/express-admin-static; 1210 | try_files $uri =404; 1211 | } 1212 | location /font/ { 1213 | root /absolute/path/to/express-admin/node_modules/express-admin-static; 1214 | try_files /csslib/fonts/$uri =404; 1215 | } 1216 | location /bootswatch/ { 1217 | root /absolute/path/to/express-admin/node_modules/express-admin-static; 1218 | try_files $uri =404; 1219 | } 1220 | 1221 | # (optional) any custom static file that you may have 1222 | location /custom.css { 1223 | root /absolute/path/to/custom/static/files; 1224 | try_files $uri =404; 1225 | } 1226 | location /custom.js { 1227 | root /absolute/path/to/custom/static/files; 1228 | try_files $uri =404; 1229 | } 1230 | } 1231 | ``` 1232 | 1233 | ## Hosting: Multiple Admins 1234 | 1235 | Multiple admin instances can be served with a single Node.js server: 1236 | 1237 | ```js 1238 | var express = require('express') 1239 | var admin = require('express-admin') 1240 | 1241 | express() 1242 | .use('/admin1', admin({ 1243 | config: require('/path1/config.json'), 1244 | settings: require('/path1/settings.json'), 1245 | users: require('/path1/users.json'), 1246 | custom: require('/path1/custom.json'), 1247 | })) 1248 | .use('/admin2', admin({ 1249 | config: require('/path2/config.json'), 1250 | settings: require('/path2/settings.json'), 1251 | users: require('/path2/users.json'), 1252 | custom: require('/path2/custom.json'), 1253 | })) 1254 | .use('/admin3', admin({ 1255 | config: require('/path3/config.json'), 1256 | settings: require('/path3/settings.json'), 1257 | users: require('/path3/users.json'), 1258 | custom: require('/path3/custom.json'), 1259 | })) 1260 | .listen(3000) 1261 | ``` 1262 | 1263 | In case you are serving them directly with Node.js then you have to set the `root` prefix for each admin instance inside the `config.json` file. 1264 | 1265 | However, in case you are using Nginx on top of Node.js to route the traffic, you can setup different sub domains for each admin instance and route the traffic to the correct path prefix: 1266 | 1267 | ```nginx 1268 | # map the sub domain being used to the path prefix for that admin instance 1269 | map $http_host $admin_prefix { 1270 | admin1.mywebsite.com admin1; 1271 | admin2.mywebsite.com admin2; 1272 | admin3.mywebsite.com admin3; 1273 | } 1274 | ``` 1275 | 1276 | and then update the above Nginx configuration by prepending the `$admin_prefix` variable to the path: 1277 | 1278 | ```nginx 1279 | location / { 1280 | # route to the appropriate admin prefix based on the sub domain being used 1281 | proxy_pass http://127.0.0.1:3000/$admin_prefix$uri$is_args$args; 1282 | # (optional) hide the fact that your app was built with Express.js 1283 | proxy_hide_header X-Powered-By; 1284 | } 1285 | ``` 1286 | 1287 | In that case there is no need to set the `root` configuration for the admin inside the `config.json` file because the routing will be done in Nginx, and for the Node.js (Express.js) server it will look like as if that was served on the default root `/` path. 1288 | 1289 | --- 1290 | 1291 | [npm-version]: https://img.shields.io/npm/v/express-admin.svg?style=flat-square (NPM Version) 1292 | [snyk-vulnerabilities]: https://img.shields.io/snyk/vulnerabilities/npm/express-admin.svg?style=flat-square (Vulnerabilities) 1293 | [screenshot]: https://i.imgur.com/6wFggqg.png (Express Admin) 1294 | 1295 | [npm]: https://www.npmjs.com/package/express-admin 1296 | [snyk]: https://snyk.io/test/npm/express-admin 1297 | 1298 | [tests]: https://github.com/simov/express-admin-tests 1299 | [examples]: https://github.com/simov/express-admin-examples 1300 | 1301 | [mysql]: https://www.npmjs.com/package/mysql 1302 | [pg]: https://www.npmjs.com/package/pg 1303 | [sqlite3]: https://www.npmjs.com/package/sqlite3 1304 | [mysql-connection]: https://github.com/mysqljs/mysql#connection-options 1305 | [pg-connection]: https://node-postgres.com/apis/client 1306 | 1307 | [bootstrap]: https://getbootstrap.com/docs/3.4/ 1308 | [bootswatch]: https://bootswatch.com/ 1309 | [express.js]: https://expressjs.com/ 1310 | [hogan.js]: https://twitter.github.io/hogan.js/ 1311 | [mustache.js]: https://github.com/janl/mustache.js/ 1312 | [jquery]: https://jquery.com/ 1313 | [chosen]: https://harvesthq.github.io/chosen/ 1314 | [bootstrap datepicker]: https://github.com/uxsolutions/bootstrap-datepicker 1315 | 1316 | [example-one-to-many]: https://simov.github.io/express-admin/examples/one-to-many.html 1317 | [example-many-to-many]: https://simov.github.io/express-admin/examples/many-to-many.html 1318 | [example-many-to-one]: https://simov.github.io/express-admin/examples/many-to-one.html 1319 | [example-one-to-one]: https://simov.github.io/express-admin/examples/one-to-one.html 1320 | [example-control-types]: https://simov.github.io/express-admin/examples/column.html 1321 | [example-complex-inline]: https://simov.github.io/express-admin/examples/controls.html 1322 | [example-listview-filter]: https://simov.github.io/express-admin/examples/filter.html 1323 | [example-custom-views]: https://simov.github.io/express-admin/examples/custom-views-apps.html 1324 | 1325 | [img-one-to-many]: https://simov.github.io/express-admin/images/one-to-many.png 1326 | [img-many-to-many]: https://simov.github.io/express-admin/images/many-to-many.png 1327 | [img-many-to-one]: https://simov.github.io/express-admin/images/many-to-one.png 1328 | [img-one-to-one]: https://simov.github.io/express-admin/images/one-to-one.png 1329 | [img-compound-primary-key]: https://simov.github.io/express-admin/images/compound-primary-key.png 1330 | [img-compound-one-to-many]: https://simov.github.io/express-admin/images/compound-one-to-many.png 1331 | [img-compound-many-to-many]: https://simov.github.io/express-admin/images/compound-many-to-many.png 1332 | [img-compound-many-to-one]: https://simov.github.io/express-admin/images/compound-many-to-one.png 1333 | [img-compound-one-to-one]: https://simov.github.io/express-admin/images/compound-one-to-one.png 1334 | 1335 | [morgan]: https://www.npmjs.com/package/morgan 1336 | [session]: https://www.npmjs.com/package/express-session 1337 | 1338 | [locale]: https://github.com/simov/express-admin/tree/master/config/lang 1339 | --------------------------------------------------------------------------------