Express Admin

Express Admin is a NodeJS tool for easy creation of user friendly administrative interface for MySQL, MariaDB, SQLite and PostgreSQL databases.

It's built with: Hogan.js (mustache.js), Express, mysql and Bootstrap.

The configuration is done through hand editing json files, although in the future there might be some kind of a GUI tool for that.

Features

  • All types of sql table relationships
  • Internationalization
  • Custom views and events
  • All kinds of browser side libraries and controls
  • All themes from Bootswatch

Resources



Install

Get the module

$ [sudo] npm install [-g] express-admin
# PostgreSQL only: run this inside the express-admin folder
$ npm install pg@2.8.2
# SQLite only: run this inside the express-admin folder
$ npm install sqlite3@2.2.0

Create a project

Depending on how you installed it creating a project is simple

$ admin path/to/config/dir
# or
$ node path/to/express-admin/app.js path/to/config/dir

Either way the config path should exist. If it doesn't contains any config files in it already you'll be prompted to add required information for your database, server port and admin account credentials.

After that you can navigate to localhost:[specified port] and see your admin up and running.

The next step is to configure it.



Configuration

config.json

The application's configuration is stored inside the config.json file.

  • The mysql key accepts any option from the node-mysql's connection options.
  • When using PostgreSQL, the pg key will accept any option from the node-postgres connection options.
  • When using SQLite, the sqlite key will contain only a database key with the absolute path to the database set as a value.
{
    "mysql": { // or "pg" or "sqlite"
        "database": "express-admin-examples",
        "user": "liolio",
        "password": "karamba"
        // "schema": "schema-name"
    },
    "server": {
        "port": 3000
    },
    "app": {
        "layouts": true,
        "themes": true,
        "languages": true,
        "root": "/admin",
        "upload": "/upload/folder"
    }
}
  • mysql | pg | sqlite - connection options
    • database - name of the database to use for this connection (sqlite: absolute path to a database file)
    • user - database user to authenticate with
    • password - password for that database user
    • schema - used only with PostgreSQL (default: "public")
  • server - server configuration
    • port - the server's port number (default: 3000)
  • app - admin application configuration
    • layouts - toggle the layout button
    • themes - toggle the themes button
    • languages - toggle the languages button
    • root - root location for the admin (used only when embedding - see the docs)
    • upload - absolute path to the upload folder (default: "public/upload")



Configuration

settings.json

All settings related to the default Express Admin views are in the settings.json file which is automatically generated with default values at first start up.

"table_name": {
    "slug": "unique-slug",
    "table": {
        "name": "table_name",
        "pk": "pk_name",
        "verbose": "Verbose Name"
    },
    "columns": [
        {...}, // see column definition below
        // { "manyToMany" ... } // see 'Many to Many' documentation
    ],
    "mainview": {
        "show": true
    },
    "listview": {
        "order": {
            "column_name1": "asc",
            "column_name2": "desc"
        },
        "page": 25,
        "filter": ["column_name1", "column_name2" ...]
    },
    "editview": {
        "readonly": false,
        // "manyToOne": { ... }, // see 'Many to One' documentation
        // "oneToOne": { ... } // see 'One to One' documentation
    }
}

settings.json contains a list of objects representing the database's tables. Each table object contains:

  • slug - unique slug among all other tables
  • table - contains table's settings
    • name - table's database name (typically you won't change this)
    • pk - table's primary key (it will be set automatically)
    • verbose - table's name used inside the admin's user interface
  • columns - array of all table's columns. To reorder the columns appearance cut the entire column object and paste it on the desired position in this array
  • mainview - table's settings for admin's root page
    • show - toggle table visibility inside the mainview table's list. Typically you want to hide tables that will be edited as inlines of other tables, or tables that are used as links for many-to-many relationships
  • listview - table's settings for the admin's listview
    • order - list of columns by which to sort and their respective order direction
    • page - how many records to show per page
    • filter - list of column names to enable for filtering
  • editview - settings specifically related to the page where the record is edited
    • readonly - this will omit the save and delete buttons at the bottom of the page effectively making the table's records readonly
    • manyToOne - see Many to One documentation
    • oneToOne - see One to One documentation

Column

Each table object contains an array of colums. Each column object have:

{
    "verbose": "Verbose Name",
    "name": "column_name",
    "control": {
        "text": true
    },
    "type": "varchar(45)",
    "allowNull": false,
    "defaultValue": null,
    "listview": {
        "show": true
    },
    "editview": {
        "show": true
    },
    // "oneToMany": { ... }, // see 'One to Many' documentation
}
  • verbose - column's name inside the admin's UI
  • name - column's database name (typically you won't change this)
  • control - one of these

    {"text": true} // input type="text"
    {"textarea": true} // textarea
    {"textarea": true, "editor": "some-class"} // html editor (see the docs)
    {"number": true} // input type="number"
    {"date": true} // datepicker
    {"time": true} // timepicker
    {"datetime": true} // datetimepicker
    {"year": true} // yearpicker
    {"file": true} // input type="file" (uploads to file system)
    {"file": true, "binary": true} // input type="file" (uploads to blob|bytea fields)
    {"radio": true, "options": ["True","False"]} // input type="radio"
    {"select": true} // select (used for one-to-many relationships)
    {"select": true, "multiple": true} // select multiple (used for many-to-many relationships)
    {"select": true, "options": ["one","two"]} // select with static options
    
  • type - column's data type (typically you won't change this)

  • allowNull - allowed to be null inside the database
  • defaultValue - currently not used
  • listview - settings about how this column should behave in admin's listview
    • show - column's visibility inside the listview which is the page where all records from the table are listed. Typically you want to see only colums that have short and meaningful data describing the record clearly. Primary key columns and columns that contain large amount of text should be hidden in this view
  • editview - settings about how this column should behave in admin's editview
    • show - column's visibility inside the listview which is the page where a record can be edited.
      All auto increment columns should be hidden!
      Foreign keys for inline tables should be hidden!
      Columns that can't be null inside the database, can't be hidden here as this will result in a mysql error when insert/update the record
  • oneToMany - see One to Many documentation



Configuration

custom.json

All objects in this file are user created. The public key is related entirely to inclusion of custom static files. It's possible to have an object only with public key inside as well as object with only app key in it.

"unique-key-here": {
    "app": {
        "path": "/absolute/path/to/custom/app.js",
        "slug": "unique-slug",
        "verbose": "Verbose Name",
        "mainview": {
            "show": true
        }
    },
    "public": {
        "local": {
            "path": "/absolute/path/to/custom/public/dir",
            "css": [
                "/relative/to/above/global.css"
            ],
            "js": [
                "/relative/to/above/global.js"
            ]
        },
        "external": {
            "css": [
                "//absolute/url/external.css"
            ],
            "js": [
                "//absolute/url/external.js"
            ]
        }
    },
    "events": "/absolute/path/to/custom/events.js"
}
  • app - expressjs application
    • path - absolute path to the custom view's app.js file
    • slug - entry point for all routes in this custom app
    • verbose - view's name inside the admin's UI
    • mainview - settings related to the mainview
      • show - include/exclude from admin's list of custom views
  • public - custom static files
    • local - local files
      • path - absolute path to the static files location
      • css - list of stylesheet files to be included
      • js - list of javascript files to be included
    • external - external files
      • css - list of stylesheet urls to be included
      • js - list of javascript urls to be included
  • events - path to file containing event hooks

See the custom view's documentation and the examples.



Relationships

One to Many

One to Many

In settings.json find the table you are searching for and under its columns find the foreign key columnt by its name key. Inside the column's object insert oneToMany key. Also don't forget to change the column's control type to select.

"control": {
    "select": true
},
"oneToMany": {
    "table": "user",
    "pk": "id",
    "columns": [
        "firstname",
        "lastname"
    ]
}
  • oneToMany - contains information about the table that this foreign key references
    • table - name of the table that you are referencing
    • pk - name of the referenced table's primary key column
    • columns - array of columns that you want to see for each record of the referenced table



Relationships

Many to Many

Many to Many

In settings.json find the table you are searching for and insert this object inside its columns array.

{
    "verbose": "Recipe Types",
    "name": "recipe_type",
    "control": {
        "select": true,
        "multiple": true
    },
    "type": "int(11)",
    "allowNull": false,
    "listview": {
        "show": false
    },
    "editview": {
        "show": true
    },
    "manyToMany": {
        "link": {
            "table": "recipe_has_recipe_types",
            "parentPk": "recipe_id",
            "childPk": "recipe_type_id"
        },
        "ref": {
            "table": "recipe_type",
            "pk": "id",
            "columns": [
                "title"
            ]
        }
    }
}
  • verbose - exactly as a regular column this is the name that's shown inside the admin UI for this column
  • name - should be unique among all other columns in this table
  • control - the control type (you won't change this)
  • type - you won't change this
  • allowNull - indicates whether you'll be able to save a record without selecting any item from the referenced table or not
  • listview -
    • show - include or exclude this column from the listview
  • editview -
    • show - include or exclude this column from the editview
  • manyToMany - indicates that this is not a regular table column
    • link - linking table information
      • table - name of the link table
      • parentPk - name of the primary key for the parent table
      • childPk - name of the primary key for the child table
    • ref - information about the referenced table
      • table - name of the table that you are referencing
      • pk - name of the referenced table's primary key columns
      • columns - array of columns that you want to see for each record of the referenced table



Relationships

Many to One

Many to One

In settings.json find the table you are searching for and under its editview key add a manyToOne key.

"manyToOne": {
    "repair": "car_id",
    "driver": "car_id"
}
  • manyToOne - contains information about the tables that are referencing this one
    • table:fk - Inside there is a list of key-value pairs where the key is the name of the table that is referencing this one and the value is its foreign key



Relationships

One to One

One to One

In settings.json find the table you are searching for and under its editview key add a oneToOne key.

"oneToOne": {
    "address": "user_id",
    "phone": "user_id"
}
  • oneToOne - contains information about the tables that are referencing this one
    • table:fk - Inside there is a list of key-value pairs where the key is the name of the table that is referencing this one and the value is its foreign key



Compound Primary Key

Compound Primary Key

In settings.json each table can be configured to have multiple primary keys through its pk field (this is set automatically on project's first run)

"table": {
    "name": "tbl",
    "pk": [
        "id1",
        "id2"
    ],
    "verbose": "tbl"
}

Compound One to Many

Compound One to Many

In case One to Many table relationship is referenced by multiple foreign keys, the regular One to Many setting can't be used, as it expects to be put inside an existing column in settings.json Therefore the following fake column entry must be added to the columns array (similar to how Many to Many relationship is configured) The fk key specifies the foreign keys in this table that references the other one

{
    "verbose": "otm",
    "name": "otm",
    "control": {
        "select": true
    },
    "type": "varchar(45)",
    "allowNull": false,
    "listview": {
        "show": true
    },
    "editview": {
        "show": true
    },
    "fk": [
        "otm_id1",
        "otm_id2"
    ],
    "oneToMany": {
        "table": "otm",
        "pk": [
            "id1",
            "id2"
        ],
        "columns": [
            "name"
        ]
    }
}

Compound Many to Many

Compound Many to Many

In case tables with multiple primary keys are part of a Many to Many table relationship, the regular Many to Many setting is used, but additionally the parentPk, childPk and the pk field inside ref can be set to array of keys to accommodate that design

{
    "verbose": "mtm",
    "name": "mtm",
    "control": {
        "select": true,
        "multiple": true
    },
    "type": "varchar(45)",
    "allowNull": false,
    "listview": {
        "show": true
    },
    "editview": {
        "show": true
    },
    "manyToMany": {
        "link": {
            "table": "tbl_has_mtm",
            "parentPk": [
                "tbl_id1",
                "tbl_id2"
            ],
            "childPk": [
                "mtm_id1",
                "mtm_id2"
            ]
        },
        "ref": {
            "table": "mtm",
            "pk": [
                "id1",
                "id2"
            ],
            "columns": [
                "name"
            ]
        }
    }
}

Compound Many to One

Compound Many to One

Same as the regular Many to One setting, plus it can contain multiple foreign keys that references this table

"manyToOne": {
    "mto": [
        "tbl_id1",
        "tbl_id2"
    ]
}

Compound One to One

Same as the regular One to One setting, plus it can contain multiple foreign keys that references this table

"oneToOne": {
    "oto": [
        "tbl_id1",
        "tbl_id2"
    ]
}



Editors

CKEditor

Install

You should either download CKEditor from ckeditor.com or use it directly from cdnjs.com //cdnjs.cloudflare.com/ajax/libs/ckeditor/4.2/ckeditor.js

settings.json

In settings.json add additional editor property to the column's control type key specifiyng the class name for this editor instance.

"control": {
    "textarea": true,
    "editor": "class-name"
}

custom.json

In custom.json add a unique key for your custom stuff.

"unique-key-here": {
    "public": {
        "local": {
            "path": "/absolute/path/to/custom/files/location",
            "js": [
                "/relative/to/above/path/ckeditor/ckeditor.js",
                "/relative/to/above/path/my-custom.js"
            ]
        }
    }
}
  • public - static files configuration
    • path - absolute path to the static files location
    • css - list of stylesheet files to be included
    • js - list of javascript files to be included

my-custom.js

Your custom editor is initialized here. It's just plain javascript/jquery and you can write whatever you want to. However there are a few requirements.

$(function () {
    if (typeof CKEDITOR !== 'undefined') {
        CKEDITOR.replaceAll(function (textarea, config) {
            // exclude textareas that are inside hidden inline rows
            if ($(textarea).parents('tr').hasClass('blank')) return false;
            // textareas with this class name will get the default configuration
            if (textarea.className.indexOf('class-name') != -1) return true;
            // all other textareas won't be initialized as ckeditors
            return false;
        });
    }
});

// executed each time an inline is added
function onAddInline (rows) {
    if (typeof CKEDITOR !== 'undefined') {
        // for each of the new rows containing textareas
        $('textarea', rows).each(function (index) {
            // get the DOM instance
            var textarea = $(this)[0];
            // textareas with this class name will get the default configuration
            if (textarea.className.indexOf('class-name') != -1) return CKEDITOR.replace(textarea);
            // all other textareas won't be initialized as ckeditors
            return false;
        });
    }
}

Here class-name is the same class name you specified in settings.json for this column.

The CKEDITOR.replaceAll loop throgh all textareas on the page and filter out only those that need to be initialized as ckeditors. The most important bit is that you should always exclude the textareas that are inside the hidden row for an inline record. That's easy because all of them have class blank on their containing row.

The hidden textareas are initialized when they are appended to the document body. The onAddInline is an event like global function that is called each time an inline record is appended to the list of records. The rows parameters contain all table rows that's been added. Again we loop through all of them and initialize only those textareas that have the class we specified in settings.json.



Editors

TinyMCE

Install

You should either download TinyMCE from tinymce.com or use it directly from cdnjs.com //cdnjs.cloudflare.com/ajax/libs/tinymce/3.5.8/tiny_mce.js

settings.json

In settings.json add additional editor property to the column's control type key specifiyng the class name for this editor instance.

"control": {
    "textarea": true,
    "editor": "class-name"
}

custom.json

In custom.json add a unique key for your custom stuff.

"unique-key-here": {
    "public": {
        "local": {
            "path": "/absolute/path/to/custom/files/location",
            "js": [
                "/relative/to/above/path/tinymce/jscripts/tiny_mce/tiny_mce.js",
                "/relative/to/above/path/tinymce/jscripts/tiny_mce/jquery.tinymce.min.js",
                "/relative/to/above/path/my-custom.js"
            ]
        }
    }
}
  • public - static files configuration
    • path - absolute path to the static files location
    • css - list of stylesheet files to be included
    • js - list of javascript files to be included

my-custom.js

Your custom editor is initialized here. It's just plain javascript/jquery and you can write whatever you want to. However there are a few requirements.

$(function () {
    if (typeof tinyMCE !== 'undefined') {
        // it's important to initialize only the visible textareas
        $('tr:not(.blank) .class-name').tinymce({});
    }
});

// executed each time an inline is added
function onAddInline (rows) {
    if (typeof tinyMCE !== 'undefined') {
        // init tinymce editors
        $('.class-name', rows).tinymce({});
    }
}

Here class-name is the same class name you specified in settings.json for this column.

The tr:not(.blank) .tinymce selector filter only to those textareas that have the class-name specified in settings.json but not contained inside hidden rows. You should always exclude the textareas that are inside the hidden row for an inline record. That's easy because all of them have class blank on their containing row.

The hidden textareas are initialized when they are appended to the document body. The onAddInline is an event like global function that is called each time an inline record is appended to the list of records. The rows parameters contain all table rows that's been added. Again we initialize only those textareas that have the class we specified in settings.json.



Editors

Multiple

Install

You should either download CKEditor and TinyMCE from their official sites ckedtor.com and tinymce.com or use them directly from cdnjs.com.

settings.json

In settings.json add additional editor property to the column's control type key specifiyng the class name for each editor instance.

"control": {
    "textarea": true,
    "editor": "ck-full"
}
"control": {
    "textarea": true,
    "editor": "ck-compact"
}
"control": {
    "textarea": true,
    "editor": "tinymce"
}

custom.json

In custom.json add a unique key for your custom stuff.

"unique-key-here": {
    "public": {
        "external": {
            "js": [
                "//cdnjs.cloudflare.com/ajax/libs/ckeditor/4.2/ckeditor.js"
            ]
        },
        "local": {
            "path": "/absolute/path/to/custom/files/location",
            "js": [
                "/relative/to/above/path/tinymce/jscripts/tiny_mce/tiny_mce.js",
                "/relative/to/above/path/tinymce/jscripts/tiny_mce/jquery.tinymce.min.js",
                "/relative/to/above/path/my-custom.js"
            ]
        }
    }
}
  • public - static files configuration
    • path - absolute path to the static files location
    • css - list of stylesheet files to be included
    • js - list of javascript files to be included

my-custom.js

Your custom editors are initialized here. It's just plain javascript/jquery and you can write whatever you want to. However there are a few requirements.

$(function () {
    if (typeof CKEDITOR !== 'undefined') {
        CKEDITOR.replaceAll(function (textarea, config) {
            // exclude textareas that are inside hidden inline rows
            if ($(textarea).parents('tr').hasClass('blank')) return false;
            // textareas with this class name will get the default configuration
            if (textarea.className.indexOf('ck-full') != -1) return true;
            // textareas with this class name will have custom configuration
            if (textarea.className.indexOf('ck-compact') != -1)
                return setCustomConfig(config);
            // all other textareas won't be initialized as ckeditors
            return false;
        });
    }

    if (typeof tinyMCE !== 'undefined') {
        // it's important to initialize only the visible textareas
        $('tr:not(.blank) .tinymce').tinymce({});
    }
});

// ckeditor only
function setCustomConfig (config) {
    config = config || {};
    // toolbar
    config.toolbarGroups = [
        { name: 'basicstyles', groups: [ 'basicstyles', 'cleanup' ] },
        { name: 'paragraph',   groups: [ 'list', 'indent', 'blocks', 'align' ] },
        '/',
        { name: 'styles' },
        { name: 'colors' }
    ];
    config.removeButtons = 'Smiley,SpecialChar,PageBreak,Iframe,CreateDiv';
    return config;
}

// executed each time an inline is added
function onAddInline (rows) {
    if (typeof CKEDITOR !== 'undefined') {
        // for each of the new rows containing textareas
        $('textarea', rows).each(function (index) {
            // get the DOM instance
            var textarea = $(this)[0];
            // textareas with this class name will get the default configuration
            if (textarea.className.indexOf('ck-full') != -1) return CKEDITOR.replace(textarea);
            // textareas with this class name will have custom configuration
            if (textarea.className.indexOf('ck-compact') != -1)
                return CKEDITOR.replace(textarea, setCustomConfig());
            // all other textareas won't be initialized as ckeditors
            return false;
        });
    }

    if (typeof tinyMCE !== 'undefined') {
        // init tinymce editors
        $('.tinymce', rows).tinymce({});
    }
}

Here based on the specific class-name specified in settings.json each textarea is initialized accordingly.

The CKEDITOR.replaceAll loop throgh all textareas on the page and filter out only those that need to be initialized as ckeditors. The most important bit is that you should always exclude the textareas that are inside the hidden row for an inline record. That's easy because all of them have class blank on their containing row.

The tr:not(.blank) .tinymce selector filter only to those textareas that have the class-name specified in settings.json but not contained inside hidden rows. You should always exclude the textareas that are inside the hidden row for an inline record. That's easy because all of them have class blank on their containing row.

The hidden textareas are initialized when they are appended to the document body. The onAddInline is an event like global function that is called each time an inline record is appended to the list of records. The rows parameters contain all table rows that's been added. Again we initialize all textareas according to their specific class-name specified in settings.json.



Custom views (apps)

The admin's custom views follow the Modular web applications with Node.js and Express approach presented by TJ Holowaychuk.

The only difference here is that we're dealing with mustache templates.

custom.json

"view1": {
    "app": {
        "path": "/absolute/path/to/custom/app.js",
        "slug": "view1",
        "verbose": "My Custom View",
        "mainview": {
            "show": true
        }
    }
}

app.js

var express = require('express');
var app = module.exports = express();
var path = require('path');

// set your custom views path
app.set('views', path.join(__dirname, 'views'));

app.get('/view1', function (req, res, next) {

    // create a realtive from the admin's view folder to your custom folder
    var relative = path.relative(res.locals._admin.views, app.get('views'));

    res.locals.partials = {

        // set the path to your templates like this
        // the content partial is declared inside the admin's base.html
        // this is the entry point for all your custom stuff
        content: path.join(relative, 'template')
    };

    // typically you want your stuff to be rendered inside the admin's UI
    // so no need to render here
    next();
});

You should definitely run the examples from the examples repository as each of the custom views (apps) presented there is really well commented.

Route wide variables

Several variables are exposed to every route callback.

// _admin's variable content is for internal purposes only and is not being rendered
res.locals._admin.db.connection// the admin's database connection can be reused from here
res.locals._admin.config       // the contents of the config.json file
res.locals._admin.settings     // the contents of the settings.json file
res.locals._admin.custom       // the contents of the custom.json file
res.locals._admin.users        // the contents of the users.json file

// shortcut variables that are being rendered
res.locals.libs       // list of all client side libraries
res.locals.themes     // list of all themes
res.locals.layouts    // flag indicating layouts button visibility
res.locals.languages  // list of all languages

// holds the absolute path to the admin's view directory
res.locals._admin.views



Events

Currently the supported event hooks are:

  • preSave - before a record is saved
  • postSave - after a record is saved
  • preList - before listview is shown

custom.json

"some-key": {
    "events": "/absolute/path/to/custom/events.js"
}

events.js

Run the admin with the --debug-brk flag and use node-inspector to drill down into the event hook parameters

exports.preSave = function (req, res, args, next) {
    if (args.debug) console.log('preSave');
    debugger;
    next();
}
exports.postSave = function (req, res, args, next) {
    if (args.debug) console.log('postSave');
    debugger;
    next();
}

Take a look at the event examples here and here

preSave args

  • action - insert|update|remove
  • name - this table's name
  • slug - this table's slug
  • data - data submitted via POST request or select'ed from the database

    • view - this table's data (the one currently shown inside the editview)
    • oneToOne | manyToOne - inline tables data

      "table's name": {
          "records": [
              "columns": {"column's name": "column's value", ...},
              "insert|update|remove": "true" // only for inline records
          ]
      }
      
  • upath - absolute path to the upload folder location
  • upload - list of files submitted via POST request
  • db - database connection instance

examples

created_at / updated_at
  • in this example our table will be called user
  • the created_at and update_at columns in user table should be set to show: false for editview in settings.json
  • use the code below to set the created_at and updated_at columns, before the record is saved
var moment = require('moment');

exports.preSave = function (req, res, args, next) {
    if (args.name == 'user') {
        var now = moment(new Date()).format('YYYY-MM-DD hh:mm:ss'),
            record = args.data.view.user.records[0].columns;
        if (args.action == 'insert') {
            record.created_at = now;
            record.updated_at = now;
        }
        else if (args.action == 'update') {
            record.updated_at = now;
        }
    }
    next();
}
generate hash id
  • in this example our table will be called car and will contain manyToOne inline tables
  • the car's table primary key that needs to be generated (in this case it's called id) should be set to show: false for editview in settings.json (the same applies for any inline table that needs its primary key to be generated)
  • use the code below to set the columns that needs to be generated, before the record is saved
var shortid = require('shortid');

exports.preSave = function (req, res, args, next) {
    if (args.name == 'car') {
        if (args.action == 'insert') {
            var table = args.name,
                record = args.data.view[table].records[0].columns;
            record.id = shortid.generate();
        }
        for (var table in args.data.manyToOne) {
            var inline = args.data.manyToOne[table];
            if (!inline.records) continue;
            for (var i=0; i < inline.records.length; i++) {
                if (inline.records[i].insert != 'true') continue;
                inline.records[i].columns.id = shortid.generate();
            }
        }
    }
    next();
}
soft delete records
  • in this example our table will be called purchase and will contain manyToOne inline tables
  • the purchase's table deleted and deleted_at columns should be set to show: false for editview in settings.json
  • use the code below to set the deleted and deleted_at columns, before the record is saved
var moment = require('moment');

exports.preSave = function (req, res, args, next) {
    if (args.name == 'purchase') {
        var now = moment(new Date()).format('YYYY-MM-DD hh:mm:ss');
        // all inline oneToOne and manyToOne records should be marked as deleted
        for (var table in args.data.manyToOne) {
            var inline = args.data.manyToOne[table];
            if (!inline.records) continue;
            for (var i=0; i < inline.records.length; i++) {
                if (args.action != 'remove' && !inline.records[i].remove) continue;
                // instead of deleting the record
                delete inline.records[i].remove;
                // update it
                inline.records[i].columns.deleted = true;
                inline.records[i].columns.deleted_at = now;
            }
        }
        // parent record
        if (args.action == 'remove') {
            // instead of deleting the record
            args.action = 'update';
            // update it
            var record = args.data.view.purchase.records[0].columns;
            record.deleted = true;
            record.deleted_at = now;
        }
    }
    next();
}

postSave args

  • action - insert|update|remove
  • name - this table's name
  • slug - this table's slug
  • data - data submitted via POST request or select'ed from the database
    • view - this table's data (the one currently shown inside the editview)
    • oneToOne | manyToOne - inline tables data
      "table's name": {
          "records": [
              "columns": {"column's name": "column's value", ...},
              "insert|update|remove": "true" // only for inline records
          ]
      }
      
  • upath - absolute path to the upload folder location
  • upload - list of files submitted via POST request
  • db - database connection instance

examples

upload files to a third party server
  • in this example our table will be called item
  • the item's table image's column control type should be set to file:true in settings.json
  • use the code below to upload the image, after the record is saved
var cloudinary = require('cloudinary'),
    fs = require('fs'),
    path = require('path');
cloudinary.config({
    cloud_name: '',
    api_key: '',
    api_secret: ''
});
exports.postSave = function (req, res, args, next) {
    if (args.name == 'item') {
        // file upload control data
        var image = args.upload.view.item.records[0].columns.image;
        // in case file is chosen through the file input control
        if (image.name) {
            // file name of the image already uploaded to the upload folder
            var fname = args.data.view.item.records[0].columns.image;
            // upload
            var fpath = path.join(args.upath, fname);
            cloudinary.uploader.upload(fpath, function (result) {
                console.log(result);
                next();
            });
        }
        else next();
    }
    else next();
}

preList args

  • name - this table's name
  • slug - this table's slug
  • filter - filter data submitted via POST request
    • columns - list of columns (and their values) to filter by
    • direction - sort order direction
    • order - column names by which to order
    • or - true|false whether to use logical or or not
  • statements - sql query strings partials
    • columns - columns to select
    • table - table to select from
    • join - join statements
    • where - where statements
    • group - group by statements
    • order - order statements
    • from - limit from number
    • to - limit to number
  • db - database connection instance

examples

soft deleted records
  • check out the preSave example about soft deleted records above
exports.preList = function (req, res, args, next) {
    if (args.name == 'purchase') {
        // check if we're using listview's filter
        // and actually want to see soft deleted records
        var filter = args.filter.columns;
        if (filter && (filter.deleted=='1' || filter.deleted_at && filter.deleted_at[0])) {
            return next();
        }
        // otherwise hide the soft deleted records by default
        var filter = 
            ' `purchase`.`deleted` IS NULL OR `purchase`.`deleted` = 0' +
            ' OR `purchase`.`deleted_at` IS NULL ';
        args.statements.where
            ? args.statements.where += ' AND ' + filter
            : args.statements.where = ' WHERE ' + filter
    }
    next();
}



Embedding

You need to add one additional property inside your config.json file under the app key

config.json

"app": {
    ...
    "root": "/some-path"
}

Initialization

In this example Express Admin will be located under the /admin path of your app

var express = require('express');
var expressAdmin = require('express-admin');
var app = express();

app.get('/', function(req, res){
    res.send('Hello World');
});

var expressAdminArgs = {
    dpath: './express-admin-config/',
    config: require('./express-admin-config/config.json'),
    settings: require('./express-admin-config/settings.json'),
    custom: require('./express-admin-config/custom.json'),
    users: require('./express-admin-config/users.json')
};

expressAdmin.initDatabase(expressAdminArgs, function (err) {
    if(err) return console.log(err);

    expressAdmin.initSettings(expressAdminArgs);

    var admin = expressAdmin.initServer(expressAdminArgs);
    app.use('/admin', admin);
    app.listen(3000);
});



Themes

Use the Themes button to switch between the themes.



Tools

Shell

Debugging

Set a breakpoint somewhere inside your custom view files and debug as usual.

$ admin --debug-brk path/to/project/config/
# or
$ node --debug-brk path/to/express-admin/app path/to/project/config/

Supervisor

Auto reload your project while configuring it using supervisior.

Create a folder for your project's configuration files.

$ mkdir project

Then while you're still there you can use supervisor to watch your project folder, specifying that the extensions are json.

$ supervisor -w project -e json -- path/to/express-admin/app -v project/

The -v flag that you're passing to the Express Admin app means that you are running the admin in development mode. In this mode the authentication and the database record updates are disabled.

You can also pass the -l flag which will log out all sql queries that Express Admin make for you.

Forever

Run an admin instance continuously in the background using forever.

$ forever start path/to/express-admin/app path/to/project/config/



Tools

NginX

You can serve all your static files with NginX.

server {
    listen 80;

    # vhost
    server_name admin.example.com;

    #log
    access_log /var/log/nginx/admin-access.log;
    error_log /var/log/nginx/admin-error.log debug;

    # NODE
    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    # NGINX

    # static
    location ~ \.(jpg|jpeg|png|gif|swf|flv|mp4|mov|avi|wmv|m4v|mkv|ico|css|js|txt|html|htm)$ {
        root /;
        try_files
            # express admin
            /absolute/path/to/express-admin/public/$uri

            # custom views
            /absolute/path/to/your/project/custom/app1/public/$uri
            /absolute/path/to/your/project/custom/app2/public/$uri
        /;
    }

    # LIBS

    # csslib
    location ^~ /csslib/ {
        alias /absolute/path/to/express-admin/node_modules/express-admin-static/csslib/;
    }
    # jslib
    location ^~ /jslib/ {
        alias /absolute/path/to/express-admin/node_modules/express-admin-static/jslib/;
    }
    # bootswatch
    location ^~ /bootswatch/ {
        alias /absolute/path/to/express-admin/node_modules/express-admin-static/bootswatch/;
    }
}



Tools

Apache

If some of your custom view's requests need to be handled by Apache, add this to the nginx vhost file.

# apache
location /some-slug/ {
    proxy_pass http://127.0.0.1:8008/some-slug/;
    proxy_set_header Host $http_host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

Edit /etc/apache2/ports.conf.

# When nginx is set as proxy
NameVirtualHost 127.0.0.1:8008
Listen 127.0.0.1:8008

Create apache's virtual host file.

<VirtualHost 127.0.0.1:8008>
    ServerAdmin admin@localhost
    # apache's vhost name MUST MATCH the nginx's vhost name
    ServerName admin.example.com
    ServerAlias admin.example.com
    # some aliases etc.
    Alias /some-slug /path/to/some/dir
    <Location /some-slug>
        # some options ...
    </Location>
    # log
    ErrorLog /var/log/apache2/admin-error.log
    CustomLog /var/log/apache2/admin-access.log combined
</VirtualHost>
Fork me on GitHub