diff options
| -rw-r--r-- | app/index.js | 42 | ||||
| -rw-r--r-- | app/node_modules/okadminview/index.js | 320 | ||||
| -rw-r--r-- | app/node_modules/okadminview/package.json | 17 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | themes/okadmin/public/css/main.css | 103 | ||||
| -rw-r--r-- | themes/okadmin/templates/index.liquid | 26 | ||||
| -rw-r--r-- | themes/okadmin/templates/partials/head.liquid | 13 | ||||
| -rw-r--r-- | themes/okadmin/templates/partials/inputs.liquid | 19 | ||||
| -rw-r--r-- | themes/okadmin/templates/partials/tail.liquid | 3 | ||||
| -rw-r--r-- | themes/okadmin/templates/resource.liquid | 15 | ||||
| -rw-r--r-- | themes/okadmin/templates/resource_new.liquid | 15 |
11 files changed, 571 insertions, 3 deletions
diff --git a/app/index.js b/app/index.js index 94d87dc..4ec8730 100644 --- a/app/index.js +++ b/app/index.js @@ -7,6 +7,7 @@ var express = require('express'); var Q = require('q'); var OKQuery = require('okquery'); var OKView = require('okview'); +var OKAdminView = require('okadminview'); var OKDB = require('okdb'); var OKResource = require('okresource') var OKTemplate = require('oktemplate'); @@ -80,11 +81,16 @@ function OKCMS(options) { // Create view instances from config var views = this._views = this._createViews(viewConfig, db, meta, resourceCache, templateProvider); + var adminViews = this._adminViews = + this._createAdminViews(adminPath, app, express, resourceConfig, + resourceCache, adminTemplateProvider, adminMeta); var server = this._server = new OKServer({ express: express, app: app, - views: views, + // Merge admin views with normal views + views: assign(views, adminViews), + // Specify root folders and paths for serving static assets root: root, adminRoot: adminRoot, adminPath: adminPath @@ -166,7 +172,37 @@ OKCMS.prototype._createViews = function(viewConfig, db, else return '404'; } -} +}; + +OKCMS.prototype._createAdminViews = function(path, app, express, + resourceConfig, resourceCache, templateProvider, meta) { + var views = {}; + var withTrail = withTrailingSlash(path); + var withoutTrail = withoutTrailingSlash(path); + // Stoopid fix for a bug in Express. Need to do this + // to ensure strict routing is not broken for the nested + // admin router. + // See: https://github.com/strongloop/express/issues/2281 + // TODO Get rid of this crap + views[withoutTrail] = { + mount: 'get', + middleware: function() { + return function(req, res) { + res.redirect(301, withTrail); + } + } + }; + // Add real view at trailing slash route + views[withTrail] = OKAdminView({ + app: app, + express: express, + resourceConfig: resourceConfig, + resourceCache: resourceCache, + templateProvider: templateProvider, + meta: meta + }); + return views; +}; OKCMS.prototype._createQueries = function(queryConfig, resourceCache) { queryConfig = queryConfig || {}; @@ -218,7 +254,7 @@ ResourceCache.prototype.get = function(type, id) { module.exports = { createApp: function(options) { - return new OKCMS(options); + return OKCMS(options); }, OKResource: OKResource, diff --git a/app/node_modules/okadminview/index.js b/app/node_modules/okadminview/index.js new file mode 100644 index 0000000..42e7f36 --- /dev/null +++ b/app/node_modules/okadminview/index.js @@ -0,0 +1,320 @@ +var bodyParser = require('body-parser'); +var methodOverride = require('method-override'); +var Q = require('q'); +var pluralize = require('pluralize'); +var OKQuery = require('okquery'); + +/** + * OKAdminView! + */ +function OKAdminView(options) { + if (!(this instanceof OKAdminView)) return new OKAdminView(options); + if (!options.app) + throw new Error('No Express app provided to OKAdminView'); + if (!options.express) + throw new Error('No Express provided to OKAdminView'); + if (!options.resourceConfig) + throw new Error('No resourceConfig provided to OKAdminView'); + if (!options.resourceCache) + throw new Error('No resourceCache provided to OKAdminView'); + if (!options.templateProvider) + throw new Error('No templateProvider provided to OKAdminView'); + if (!options.meta) + throw new Error('No meta query provided to OKAdminView'); + var app = options.app; + var express = options.express; + var meta = options.meta; + var resourceCache = this._resourceCache = options.resourceCache; + var resourceConfig = this._resourceConfig = options.resourceConfig; + var provider = options.templateProvider; + // Load templates + var templates = this._templates = + ['index', 'resource', 'resource_new'].reduce(function(cache, name) { + var template = provider.getTemplate(name); + if (!template) + throw new Error('Admin theme does not have needed template: "' + name + '"'); + cache[name] = template; + return cache; + }, {}); + // OKAdmin middleware is a router, so mounts on 'use' + Object.defineProperty(this, 'mount', { + value: 'use', + writable: false, + enumerable: true + }); + + // Resources which apper on the index are a function of + // the resource configuration. + var indexQueries = this._indexQueries = Object.keys(resourceConfig) + .map(function(key) { + var config = resourceConfig[key]; + var type = config.type; + var staticData = config.static || {}; + var resource = resourceCache.get(config.type); + if (!resource) + throw new Error('Something weird is going on'); + var id = staticData[resource.idField]; + resource = resourceCache.get(type, id) || resource; + if (resource.bound) { + // Resource instances implement the query API + return OKQuery({resource: resource});; + } else { + return OKQuery({resource: resource, query: config.query}) + } + }); + + this._middleware = createMiddleware(this); + + function createMiddleware(view) { + var router = express.Router({ + strict: app.get('strict routing') + }); + + // Parse form data + router.use(bodyParser.urlencoded({extended: true})); + // HTML forms only support POST and GET methods + // We extend this by adding hidden input fields to the forms which + // specify the actual method desired. + router.use(methodOverride(function(req, res) { + // Parse out the hidden field + if (req.body && typeof req.body === 'object' && '_method' in req.body) { + var method = req.body._method; + delete req.body._method + return method + } + })); + + router.get('/', function readIndex(req, res, next) { + fetchIndexTemplateData(meta, indexQueries).then(function(data) { + view.renderIndex(req, res, data); + }).fail(errorHandler(req, res)); + }); + + router.get('/:type/new/', function createResourceView(req, res, next) { + var type = req.params.type || ''; + var resource = resourceCache.get(type); + if (!resource) { + errorHandler(req, res)(new Error('No such resource ' + type)); + } else { + meta.get().then(function(metadata) { + view.renderResourceNew(req, res, { + meta: metadata, + resource: { + type: resource.type, + spec: resource.spec + } + }); + }).fail(errorHandler(req, res)); + } + }); + + + router.get('/:type/:id/', function readResource(req, res, next) { + var type = req.params.type || ''; + var id = req.params.id || ''; + var resource = resourceCache.get(type, id); + if (!resource) { + errorHandler(req, res)(new Error('No such resource')); + } else { + var query = OKQuery({ + resource: resource, + query: id + }); + fetchResourceTemplateData(meta, query, transform).then(function(data) { + if (!data) { + resourceMissingHandler(req, res)() + } else { + view.renderResource(req, res, data); + } + }).fail(errorHandler(req, res)); + } + + function transform(meta, resource, data) { + meta = meta || {}; + resource = resource || {}; + data = data || {}; + var spec = Object.keys(resource.spec).reduce(function(cache, prop) { + var propSpec = resource.spec[prop]; + var value = data[prop]; + cache[prop].id = data[resource.idField]; + cache[prop].value = value; + return cache; + }, resource.spec); + return { + meta: meta, + resource: { + type: resource.type, + spec: spec + } + }; + } + }); + + router.post('/:type/', function createResource(req, res, next) { + var type = req.params.type; + var resource = resourceCache.get(type); + var data = req.body; + if (!resource) { + errorHandler(req, res)(new Error('No such resource ' + type)); + } else { + meta.get().then(function(metadata) { + var templateData = { + meta: metadata, + resource: { + type: resource.type, + spec: resource.spec, + data: data + } + }; + try { + resource.assertValid(data); + resource.create(data).then(function(created) { + res.redirect(303, data[resource.idField]); + }).fail(errorHandler(req, res)); + } catch (errors) { + view.renderResource(req, res, templateData); + } + }).fail(errorHandler(req, res));; + } + }); + + router.put('/:type/:id/', function updateResource(req, res, next) { + var type = req.params.type; + var id = req.params.id; + var data = req.body; + var resource = resourceCache.get(type, id); + if (!resource) { + errorHandler(req, res)(new Error('No such resource ' + type)); + } else { + // TODO Maybe should make metadata synchronous... + meta.get().then(function(metadata) { + var templateData = { + meta: metadata, + resource: { + spec: resource.spec, + data: data + } + }; + try { + resource.assertValid(data); + resource.update(id, data).then(function(updated) { + res.redirect(303, '../' + updated[resource.idField]); + }).fail(errorHandler(req, res)); + } catch (errors) { + view.renderResource(req, res, templateData); + } + }).fail(errorHandler(req, res)); + } + }); + + return router; + } +} + +OKAdminView.prototype.middleware = function() { + return this._middleware; +}; + +OKAdminView.prototype.renderIndex = function(req, res, data) { + data = data || {}; + this._templates['index'].render(data).then(function(rendered) { + res.send(rendered); + }).fail(errorHandler(req, res)); +}; + +OKAdminView.prototype.renderResource = function(req, res, data) { + data = data || {}; + this._templates['resource'].render(data).then(function(rendered) { + res.send(rendered); + }).fail(errorHandler(req, res)); +}; + +OKAdminView.prototype.renderResourceNew = function(req, res, data) { + data = data || {meta: {}, resource: {}}; + this._templates['resource_new'].render(data).then(function(rendered) { + res.send(rendered); + }).fail(errorHandler(req, res)); +}; + +/** + * Annotate template data with schema info + */ +function fetchIndexTemplateData(meta, queries) { + return Q.Promise(function(resolve, reject) { + Q.all([meta.get()].concat(queries.map(function(query) { + return query.get(); + }))).then(function(results) { + var meta = results.shift(); + var resources = results.reduce(function(cache, result, i) { + if (!result) + return cache; + var resource = queries[i].resource; + // We want the raw object spec + var spec = resource.spec; + var key = pluralize(resource.type); + if (!cache[key]) { + cache[key] = { + type: resource.type, + spec: spec, + data: [] + }; + } + + if (result.length) { + result.forEach(addToCache) + } else { + addToCache(result); + } + + function addToCache(data) { + // Report id to template under standard name + data.id = data[resource.idField]; + cache[key].data.push(data); + } + + return cache; + }, {}); + + resolve({ + meta: meta, + resources: resources + }); + }).fail(reject); + }); +} + +/** + * Annotate template data with schema info + */ +function fetchResourceTemplateData(meta, query, fn) { + fn = fn || function(m, r, d) { return {meta: m, resource: d}; }; + return Q.Promise(function(resolve, reject) { + meta.get().then(function(metadata) { + query.get().then(function(data) { + var resource = query.resource; + resolve(fn(metadata, resource, data)); + }).fail(reject); + }).fail(reject) + }); +} + +/** + * TODO Real error handling + */ +function errorHandler(req, res) { + return function(err) { + res.send(err.stack); + }; +} + +/** + * TODO Real 404 handling + */ +function resourceMissingHandler(req, res) { + return function() { + res.status(404); + res.send('404'); + } +} + +module.exports = OKAdminView; diff --git a/app/node_modules/okadminview/package.json b/app/node_modules/okadminview/package.json new file mode 100644 index 0000000..b07cb1a --- /dev/null +++ b/app/node_modules/okadminview/package.json @@ -0,0 +1,17 @@ +{ + "name": "okadminview", + "version": "1.0.0", + "description": "administrate!", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "OKFocus", + "license": "None", + "dependencies": { + "body-parser": "^1.12.2", + "method-override": "^2.3.2", + "pluralize": "^1.1.2", + "q": "^1.2.0" + } +} diff --git a/package.json b/package.json index 58f04e1..1e7c1cd 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "license": "None", "dependencies": { "express": "^4.12.3", + "object-assign": "^2.0.0", "q": "^1.2.0" } } diff --git a/themes/okadmin/public/css/main.css b/themes/okadmin/public/css/main.css new file mode 100644 index 0000000..88628e3 --- /dev/null +++ b/themes/okadmin/public/css/main.css @@ -0,0 +1,103 @@ +html, body { + margin: 0; + padding: 0; + font-family: "Helvetica", sans-serif; + font-size: 16px; +} + +ul { + padding: 0; + list-style: none; +} + +a { + color: #0000ff; + text-decoration: none; +} + +a:hover { + border-bottom: 3px solid #0000ff; +} + +a:visited { + color: #0000ff; +} + +.admin-header { + height: 50px; + background-color: rgb(233, 233, 233); +} + +.admin-header .breadcrumb { + margin-left: 2em; + font-size: 2em; + color: rgba(0, 0, 0, 0.25); + line-height: 50px; +} + +.admin-header .site-link { + font-size: 1.5em; + float: right; + margin-right: 10%; + line-height: 50px; +} + +.main.index .resource-category { + float: left; + min-width: 200px; + margin: 1em; + padding: 1em; + background-color: rgba(0, 0, 0, 0.1); +} + +.main.index .resource-category a.add-new { + border-bottom: 3px solid rgba(0, 0, 0, 0); + float: right; + font-size: 1.5em; + color: rgba(0, 0, 0, 0.25); +} + +.main.index .resource-category li { + margin: 1em 0; +} + +.main.index .resource-category a.add-new:hover { + border-bottom: 3px solid rgba(0, 0, 0, 0.25); +} + +.main.resource > * { + margin: 1em 1em; +} + +.main.resource form { + background-color: rgba(0, 0, 0, 0.1); + max-width: 500px; + padding: 1em; + font-size: 1.25em; +} + +.main.resource form label { + display: block; + margin-bottom: 0.25em; + color: rgba(0, 0, 0, 0.75); +} + +.main.resource form .property { + margin: 1em 0; +} + +.main.resource form input { + display: block; + font-size: 1.25em; + min-height: 2em; + padding: 0 0.5em; +} + +.main.resource form button { + font-size: 1.25em; + float: right; +} + +.clear { + clear: both; +} diff --git a/themes/okadmin/templates/index.liquid b/themes/okadmin/templates/index.liquid new file mode 100644 index 0000000..95c64dd --- /dev/null +++ b/themes/okadmin/templates/index.liquid @@ -0,0 +1,26 @@ +{% include 'partials/head' %} + +<section class="index main"> + {% for pair in resources %} + {% assign name = pair[0] %} + {% assign resource = pair[1] %} + {% assign spec = resource.spec %} + + <section class="resource-category {{name}}"> + <header> + <h2>{{name | capitalize}}</h2> + </header> + <ul class="resource-list"> + {% for data in resource.data %} + <li><a href="{{resource.type}}/{{data.id}}/">{{data.id}}</a></li> + {% endfor %} + </ul> + <footer> + <a class="add-new" href="{{resource.type}}/new/">+</a> + </footer> + </section> + + {% endfor %} +</section> + +{% include 'partials/tail' %} diff --git a/themes/okadmin/templates/partials/head.liquid b/themes/okadmin/templates/partials/head.liquid new file mode 100644 index 0000000..86915a4 --- /dev/null +++ b/themes/okadmin/templates/partials/head.liquid @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf8"> + <title>{{meta.title}}</title> + <link rel="stylesheet" href="{{meta.static}}/css/main.css"> + </head> + <body> + <header class="admin-header"> + <span class="breadcrumb">Admin</span> + <a class="site-link" href="/">View Site</a> + </header> + <div class="container"> diff --git a/themes/okadmin/templates/partials/inputs.liquid b/themes/okadmin/templates/partials/inputs.liquid new file mode 100644 index 0000000..4dd600d --- /dev/null +++ b/themes/okadmin/templates/partials/inputs.liquid @@ -0,0 +1,19 @@ +{% for pair in resource.spec %} + {% assign name = pair[0] %} + {% assign spec = pair[1] %} + {% assign type = spec.type %} + + <div class="property"> + {% if type == 'string' %} + <label for="{{name}}">{{name | capitalize}}</label> + <input + {% if spec.disabled %} + disabled="true" + {% endif %} + name="{{name}}" type="text" value="{{spec.value}}"> + {% else %} + <p><pre style="color: red">Admin template doesn't support '{{type}}' properties!</pre></p> + {% endif %} + </div> + +{% endfor %} diff --git a/themes/okadmin/templates/partials/tail.liquid b/themes/okadmin/templates/partials/tail.liquid new file mode 100644 index 0000000..773c8d4 --- /dev/null +++ b/themes/okadmin/templates/partials/tail.liquid @@ -0,0 +1,3 @@ + </div> {% comment %} closes container tag {% endcomment %} + </body> +</html> diff --git a/themes/okadmin/templates/resource.liquid b/themes/okadmin/templates/resource.liquid new file mode 100644 index 0000000..9c1b71c --- /dev/null +++ b/themes/okadmin/templates/resource.liquid @@ -0,0 +1,15 @@ +{% include 'partials/head' %} + +<section class="resource main"> + <nav> + <a href="../..">Back</a> + </nav> + <form action="." method="POST"> + <input type="hidden" name="_method" value="PUT"> + {% include 'partials/inputs' %} + <button type="submit">Save</button> + <div class="clear"></div> + </form> +</section> + +{% include 'partials/tail' %} diff --git a/themes/okadmin/templates/resource_new.liquid b/themes/okadmin/templates/resource_new.liquid new file mode 100644 index 0000000..1e414be --- /dev/null +++ b/themes/okadmin/templates/resource_new.liquid @@ -0,0 +1,15 @@ +{% include 'partials/head' %} + +<section class="main resource resource-new"> + <nav> + <a href="../..">Back</a> + </nav> + <form action=".." method="POST"> + <input type="hidden" name="_method" value="POST"> + {% include 'partials/inputs' %} + <button type="submit">Create</button> + <div class="clear"></div> + </form> +</section> + +{% include 'partials/tail' %} |
