Browse Source

Refactor, add autocomplete

Alan Colon 7 years ago
parent
commit
d31188d666

+ 38 - 0
TODO.md

@@ -0,0 +1,38 @@
+# TODO
+
+### User permissions
+
+- Register permissions from CRUDs.
+  - CREATE_ENTITY, READ_ENTITY, WRITE_ENTITY, DELETE_ENTITY
+- Create Role table that bundles permissions together
+- Add roles list to User
+
+### CRUD UI
+
+- Create linked object select field
+  - ~~Utilize auto-complete~~
+  - Allow for multiple entries to be saved as
+    - Comma delimited string of IDs
+    - Comma delimited string of Keys
+    - Series of M:N records
+
+### Child objects
+
+- Allow for a parent-child / owner relationship with entities.
+- Display lists of owned entities in the entity detail page.
+- Allow for different child-edit modes:
+  - Inline: CRUD in the table list.
+  - Modal: Create and Update in modal, Edit and Delete in table list.
+  - Page: Link to the child entity as a whole page.
+  - New Tab: Link to the child entity as a whole page in a new tab.
+
+### Field formatting
+
+- Allow for a formula to be specified on a field that can reference other fields, and determine what the value of that field will be. 
+- Allow for a format to be specified for a field that will determine how that field is displayed.
+
+### Tax rates
+
+- Need to figure out how tax rates can be specified for a region, and associated to an invoice.
+- The tax calculation may be several steps removed... `Region.taxRate` -> `Customer.regionId` -> `Invoice.customerId` -> `InvoiceItem.subtotal * Region.taxRate`
+

+ 7 - 6
app/api-service.js

@@ -5,11 +5,11 @@ app.service('api', function($http) {
     withCredentials: true
   }
   this.postProcess = (res) => res.data
-  this.get = (path) => $http.get(path, this.opts).then(this.postProcess)
-  this.post = (path, data) => $http.post(path, data, this.opts).then(this.postProcess)
-  this.put = (path, data) => $http.put(path, data, this.opts).then(this.postProcess)
-  this.patch = (path, data) => $http.patch(path, data, this.opts).then(this.postProcess)
-  this.delete = (path) => $http.delete(path, this.opts).then(this.postProcess)
+  this.get = (path, opts = {}) => $http.get(path, Object.assign({}, opts, this.opts)).then(this.postProcess)
+  this.post = (path, data, opts = {}) => $http.post(path, data, Object.assign({}, opts, this.opts)).then(this.postProcess)
+  this.put = (path, data, opts = {}) => $http.put(path, data, Object.assign({}, opts, this.opts)).then(this.postProcess)
+  this.patch = (path, data, opts = {}) => $http.patch(path, data, Object.assign({}, opts, this.opts)).then(this.postProcess)
+  this.delete = (path, opts = {}) => $http.delete(path, Object.assign({}, opts, this.opts)).then(this.postProcess)
 
   this.login = data => this.post('/api/auth/login', data)
   this.setToken = token => {
@@ -24,6 +24,7 @@ app.service('api', function($http) {
     update: (id, data) => this.patch(`${apiPrefix}/${id}`, data),
     delete: (id) => this.delete(`${apiPrefix}/${id}`),
     trash: () => this.get(`${apiPrefix}/trash`),
-    undelete: (id) => this.delete(`${apiPrefix}/trash/${id}`)
+    undelete: (id) => this.delete(`${apiPrefix}/trash/${id}`),
+    autocomplete: (searchText) => this.get(`${apiPrefix}`, { params: { q: searchText } })
   })
 })

+ 6 - 0
app/app.js

@@ -1,5 +1,6 @@
 const angular = require('angular')
 const routes = require('./routes')
+const crudRoutes = require('./crud/routes')
 require('angular-material/angular-material.css')
 require('angular-material-data-table/dist/md-data-table.min.css')
 require('angular-route')
@@ -13,13 +14,18 @@ const es6Html = require('es6-string-html-template')
 window.html = es6Html.html
 window.raw = es6Html.raw
 window.encode = es6Html.encode
+window.identity = x => x
 
 const app = angular.module('material-framework', ['ngRoute', 'ngMaterial', 'async', 'md.data.table', 'chart.js'])
 
 app.config(($routeProvider, $locationProvider, $mdThemingProvider) => {
+  $routeProvider.crudRoutes = crudRoutes($routeProvider)
   routes($routeProvider)
   $locationProvider.html5Mode(true)
 })
 
+app.run(($rootScope) => {
+  $rootScope.identity = x => x
+})
 
 module.exports = app;

+ 1 - 0
app/assets/index.js

@@ -3,6 +3,7 @@ module.exports = {
   logo: require('./generic-logo.svg'),
   menuIcon: require('@alancnet/material-design-icons/navigation_ic_menu_48px.svg'),
   userIcon: require('@alancnet/material-design-icons/social_ic_person_48px.svg'),
+  roleIcon: require('@alancnet/icomoon-svg/user-check.svg'),
   addUserIcon: require('@alancnet/material-design-icons/social_ic_person_add_48px.svg'),
   dashboardIcon: require('@alancnet/material-design-icons/action_ic_dashboard_48px.svg'),
   createIcon: require('@alancnet/material-design-icons/content_ic_add_48px.svg'),

+ 0 - 70
app/components/crud-pages/details.js

@@ -1,70 +0,0 @@
-const _ = require('lodash')
-const app = require('../../app')
-/**
- * @param {CrudPagesOptions} opts 
- */
-const details = (opts) => {
-  const defaultInput = column => html`
-    <md-input-container>
-      <label>${column.titleName}</label>
-      <input type="${column.type || 'text'}" ng-model="model.${raw(column.camelName)}" />
-    </md-input-container>
-  `
-
-  app.component(`app${opts.pascalName}DetailsPage`, {
-
-    template: html`
-      <app-user-area>
-        <h1>{{ctrl.isNew ? 'New ${opts.titleName}' : '${opts.titleName} Details'}}</h1>
-        <form name="form" ng-submit="ctrl.submit()">
-          ${opts.columns.map(c => c.input || defaultInput(c))}
-
-          <div>
-            <md-button type="submit" class="md-raised md-primary">Submit</md-button>
-          </div>
-
-        </form>
-      </app-user-area>
-    `,
-    controllerAs: 'ctrl',
-    controller: function(api, $scope, $routeParams, $mdToast, $location) {
-      this.isNew = $routeParams.id === 'new'
-      const crud = api.crud(opts.apiPrefix)
-      let original
-      if (this.isNew) {
-        original = {}
-        $scope.model = Object.create(original)
-      } else {
-        crud.read($routeParams.id).then(model => {
-          original = model
-          $scope.model = Object.create(original)
-        })
-      }
-
-      this.submit = async () => {
-        try {
-          if (this.isNew) {
-            await crud.create($scope.model)
-          } else {
-            const obj = {}
-            for (var key in $scope.model) {
-              if ($scope.model.hasOwnProperty(key)) {
-                obj[key] = $scope.model[key]
-              }
-            }
-            await crud.update(original.id, obj)
-          }
-          $mdToast.showSimple(`${opts.titleName} saved.`)
-          $location.url(opts.appPrefix)
-        } catch (err) {
-          console.error(err)
-          $mdToast.showSimple(`Could not save ${opts.titleName}: ${err.message || err}`)
-        }
-      }
-
-    }
-
-  })
-}
-
-module.exports = details

+ 1 - 0
app/components/index.js

@@ -4,3 +4,4 @@ require('./test-page')
 require('./user-area')
 require('./home-page')
 require('./user-pages')
+require('./role-pages')

+ 0 - 1
app/components/login-page.js

@@ -25,7 +25,6 @@ app.component('appLoginPage', {
         api.setToken(res.token)
         $location.url('/dashboard')
       } catch (err) {
-        console.log($mdToast)
         $mdToast.showSimple(`Login failed`)
       }
     }

+ 23 - 0
app/components/role-pages.js

@@ -0,0 +1,23 @@
+const { pages } = require('../crud')
+
+pages({
+  camelName: 'role',
+  columns: [
+    {
+      camelName: 'name'
+    },
+    {
+      camelName: 'key'
+    },
+    {
+      camelName: 'permissions',
+      cell: html`<td md-cell>
+        <md-tooltip ng-if="role.permissions">{{role.permissions}}</md-tooltip>
+        <ng-pluralize count="(role.permissions.split(',') | filter:length).length" when="{
+        '0': 'Empty',
+        'one': '1 permission',
+        'other': '{} permissions'  
+      }"></ng-pluralize></td>`
+    }
+  ]
+})

+ 7 - 2
app/components/user-area.js

@@ -1,5 +1,5 @@
 const app = require('../app')
-const { logo, menuIcon, userIcon, dashboardIcon } = require('../assets')
+const { logo, menuIcon, userIcon, dashboardIcon, roleIcon } = require('../assets')
 
 
 app.component('appUserArea', {
@@ -31,6 +31,12 @@ app.component('appUserArea', {
             Users
           </md-button>
         </md-menu-item>
+        <md-menu-item>
+          <md-button ng-href="/roles">
+            <md-icon md-svg-icon="${roleIcon}"></md-icon>
+            Roles
+          </md-button>
+        </md-menu-item>
       </md-sidenav>
       <md-content flex>
         <md-toolbar>
@@ -51,7 +57,6 @@ app.component('appUserArea', {
     $scope.$mdMedia = $mdMedia
     this.showNav = false
     this.toggleNav = () => {
-      console.log('toggle')
       $mdSidenav('left').toggle()
     }
   }

+ 2 - 2
app/components/user-pages.js

@@ -1,6 +1,6 @@
-const crudPages = require('./crud-pages')
+const { pages } = require('../crud')
 
-crudPages({
+pages({
   titleName: 'User',
   titlePlural: 'Users',
   pascalName: 'User',

+ 1 - 0
app/crud/defaults.js

@@ -0,0 +1 @@
+module.exports = require('../../lib/crud/defaults')

+ 9 - 0
app/crud/index.js

@@ -0,0 +1,9 @@
+const defaults = require('./defaults')
+const pages = require('./pages')
+const routes = require('./routes')
+
+module.exports = {
+  defaults,
+  pages,
+  routes
+}

+ 0 - 0
app/components/crud-pages/README.md → app/crud/pages/README.md


+ 151 - 0
app/crud/pages/details.js

@@ -0,0 +1,151 @@
+const _ = require('lodash')
+const app = require('../../app')
+/**
+ * @param {CrudPagesOptions} opts 
+ */
+const details = (opts) => {
+
+  const autocompleteInput = column => {
+    if (!column.apiPrefix) throw new Error('apiPrefix is required for autocomplete fields')
+
+    return html`
+      <md-autocomplete flex
+        md-selected-item="ctrl.autocomplete.${raw(column.camelName)}.selectedItem"
+        md-search-text="ctrl.autocomplete.${raw(column.camelName)}.searchText"
+        md-items="item in ctrl.autocomplete.${raw(column.camelName)}.getItems(ctrl.autocomplete.${raw(column.camelName)}.searchText)"
+        md-item-text="item.name || item.key || item.id"
+        md-require-match="true"
+        md-selected-item-change="ctrl.autocomplete.${raw(column.camelName)}.onChange()"
+        md-min-length="0"s
+        placeholder="${column.titleName}"
+        >
+        <md-item-template>
+          <span md-highlight-text="ctrl.searchText.${raw(column.camelName)}" md-highlight-flags="^i">{{item.name || item.key || item.id}}</span>
+        </md-item-template>
+      </md-autocomplete>
+    `
+  }
+
+  const standardInput = column => html`
+    <md-input-container flex>
+      <label>${column.titleName}</label>
+      <input type="${column.type || 'text'}" ng-model="model.${raw(column.camelName)}" />
+    </md-input-container>
+  `
+
+  const defaultField = column =>
+    column.type === 'autocomplete'
+    ? autocompleteInput(column)
+    : standardInput(column)
+
+  const layout = () => {
+    if (opts.layout) {
+      const cols = _.fromPairs(opts.columns.map(col => [col.camelName, col]))
+
+      return opts.layout.map(section => html`
+        ${section.section ? html`<md-subheader>${section.section}</md-subheader>`:''}
+        ${raw(section.rows ? section.rows.map(
+          row => html`
+            <div layout-gt-sm="row" layout-padding>
+              ${raw(row
+                .map(field => cols[field])
+                .map(c => c.field || defaultField(c))
+                .join('\n')
+              )}
+            </div>
+          `).join('\n')
+        : '')}
+      `).join('\n')
+    } else {
+      return html`
+        <div layout-gt-sm="row" layout-padding>
+          ${opts.columns.map(c => c.field || defaultField(c))}
+        </div>
+      `
+    }
+  }
+
+  const template = html`
+      <app-user-area>
+        <h1>{{ctrl.isNew ? 'New ${opts.titleName}' : '${opts.titleName} Details'}}</h1>
+        <form name="form" ng-submit="ctrl.submit()">
+          ${raw(layout())}
+          <div>
+            <md-button type="submit" class="md-raised md-primary">Submit</md-button>
+          </div>
+
+        </form>
+      </app-user-area>
+    `
+  app.component(`app${opts.pascalName}DetailsPage`, {
+
+    template,
+    controllerAs: 'ctrl',
+    controller: function(api, $scope, $routeParams, $mdToast, $location, $q) {
+      this.template = template // For inspection purposes
+      this.isNew = $routeParams[opts.routeParam] === 'new'
+      const crud = api.crud(opts.apiPrefix)
+      let original
+      if (this.isNew) {
+        original = {}
+        $scope.model = Object.create(original)
+        this.loadingPromise = $q.resolve($scope.model)
+      } else {
+        this.loadingPromise = crud.read($routeParams[opts.routeParam]).then(model => {
+          original = model
+          $scope.model = Object.create(original)
+          return $scope.model
+        })
+      }
+
+      this.submit = async () => {
+        try {
+          if (this.isNew) {
+            await crud.create($scope.model)
+          } else {
+            const obj = {}
+            for (var key in $scope.model) {
+              if ($scope.model.hasOwnProperty(key)) {
+                obj[key] = $scope.model[key]
+              }
+            }
+            await crud.update(original.id, obj)
+          }
+          $mdToast.showSimple(`${opts.titleName} saved.`)
+          $location.url(opts.appPrefix)
+        } catch (err) {
+          console.error(err)
+          $mdToast.showSimple(`Could not save ${opts.titleName}: ${err.message || err}`)
+        }
+      }
+
+      /* Autocomplete fields */
+      this.searchText = {}
+      this.autocomplete = {}
+
+      opts.columns.filter(c => c.type === 'autocomplete').forEach(c => {
+        const crud = api.crud(c.apiPrefix)
+        const ac = this.autocomplete[c.camelName] = {
+          onChange() {
+            $scope.model[c.camelName] = ac.selectedItem
+            ? ac.selectedItem.id
+            : null
+          },
+          getItems(searchText) {
+            return crud.autocomplete(searchText)
+          }
+        }
+        this.loadingPromise.then(model => {
+          if (model[c.camelName]) {
+            crud.read(model[c.camelName]).then(record => {
+              ac.selectedItem = record
+            })
+          }
+        })
+      })
+
+    }
+  })
+}
+
+module.exports = details

+ 25 - 0
app/crud/pages/index.js

@@ -0,0 +1,25 @@
+const app = require('../../app')
+const defaults = require('../defaults')
+const list = require('./list')
+const details = require('./details')
+const trash = require('./trash')
+
+
+/**
+ * @param {CrudPagesOptions} opts 
+ */
+const crudPages = (opts) => {
+  opts = defaults(opts)
+  
+  if (!opts.columns) throw new Error('Columns are required')
+  
+  list(opts)
+  details(opts)
+  trash(opts)
+}
+
+
+  // TODO: Create Read Update Delete pages...
+
+
+module.exports = crudPages

+ 0 - 0
app/components/crud-pages/list.js → app/crud/pages/list.js


+ 0 - 0
app/components/crud-pages/options.js → app/crud/pages/options.js


+ 0 - 0
app/components/crud-pages/trash.js → app/crud/pages/trash.js


+ 9 - 0
app/crud/routes.js

@@ -0,0 +1,9 @@
+const defaults = require('./defaults')
+const crudRoutes = ($routeProvider) => (opts) => {
+  opts = defaults(opts)
+  $routeProvider.when(opts.appPrefix, {template: `<app-${opts.paramPlural}-page />`})
+  $routeProvider.when(`${opts.appPrefix}/trash`, {template: `<app-${opts.paramPlural}-trash-page />`})
+  $routeProvider.when(`${opts.appPrefix}/:${opts.camelName}Id`, {template: `<app-${opts.paramName}-details-page />`})
+}
+
+module.exports = crudRoutes

+ 2 - 3
app/routes.js

@@ -3,8 +3,7 @@ module.exports = function($routeProvider) {
   $routeProvider.when('/login', {template: '<app-login-page />'})
   $routeProvider.when('/dashboard', {template: '<app-dashboard-page />'})
   $routeProvider.when('/', {template: '<app-home-page />'})
-  $routeProvider.when('/users', {template: '<app-users-page />'})
-  $routeProvider.when('/users/trash', {template: '<app-users-trash-page />'})
-  $routeProvider.when('/users/:id', {template: '<app-user-details-page />'})
+  $routeProvider.crudRoutes({ camelName: 'user' })
+  $routeProvider.crudRoutes({ camelName: 'role' })
   $routeProvider.otherwise({template: '<h1>404</h1>'})
 }

+ 20 - 0
lib/controllers/auth/permissions.js

@@ -0,0 +1,20 @@
+const permissions = []
+
+const register = (perm) => {
+
+  if (!permissions.includes(perm)) {
+    permissions.push(perm)
+    permissions.sort()
+  }
+
+}
+
+const list = (req, res) => {
+  res.status(200).send(permissions)
+}
+
+
+module.exports = {
+  register,
+  list
+}

+ 2 - 1
lib/controllers/index.js

@@ -1,4 +1,5 @@
 module.exports = {
   auth: require('./auth'),
-  user: require('./user')
+  user: require('./user'),
+  role: require('./role')
 }

+ 6 - 0
lib/controllers/role.js

@@ -0,0 +1,6 @@
+const { controller } = require('../crud')
+const { Role } = require('../database')
+
+module.exports = controller({
+  Type: Role
+})

+ 2 - 2
lib/controllers/user.js

@@ -1,6 +1,6 @@
-const crudController = require('./crud-controller')
+const { controller } = require('../crud')
 const { User } = require('../database')
 
-module.exports = crudController({
+module.exports = controller({
   Type: User
 })

+ 0 - 37
lib/crud-route.js

@@ -1,37 +0,0 @@
-const { pascal, camel, param } = require('change-case')
-const plural = require('plural')
-const asyncHandler = require('express-async-handler')
-
-module.exports = ({
-  app,
-  controller,
-  typeName,
-  typePlural,
-  camelName,
-  camelPlural,
-  paramName,
-  paramPlural,
-  apiPrefix  
-}) => {
-  if (!typeName) throw new Error('typeName is required')
-  if (typeName !== pascal(typeName)) throw new Error('typeName should be PascalCased')
-  if (!typePlural) typePlural = plural(typeName)
-  if (typePlural !== pascal(typePlural)) throw new Error('typePlural should be PascalCased')
-  if (!camelName) camelName = camel(typeName)
-  if (camelName !== camel(camelName)) throw new Error('camelName should be camelCased')
-  if (!camelPlural) camelPlural = plural(camelName)
-  if (camelPlural !== camel(camelPlural)) throw new Error('camelPlural should be camelCased')
-  if (!paramName) paramName = param(typeName)
-  if (paramName !== camel(paramName)) throw new Error('paramName should be param-cased')
-  if (!paramPlural) paramPlural = plural(paramName)
-  if (paramPlural !== camel(paramPlural)) throw new Error('paramPlural should be param-cased')
-  if (apiPrefix) apiPrefix = `/${paramPlural}`
-
-  if (controller.list) app.get(`/api/${paramPlural}`, asyncHandler(controller.list))
-  if (controller.create) app.post(`/api/${paramPlural}`, asyncHandler(controller.create))
-  if (controller.trash) app.get(`/api/${paramPlural}/trash`, asyncHandler(controller.trash))
-  if (controller.read) app.get(`/api/${paramPlural}/:id`, asyncHandler(controller.read))
-  if (controller.update) app.patch(`/api/${paramPlural}/:id`, asyncHandler(controller.update))
-  if (controller.delete) app.delete(`/api/${paramPlural}/:id`, asyncHandler(controller.delete))
-  if (controller.undelete) app.delete(`/api/${paramPlural}/trash/:id`, asyncHandler(controller.undelete))
-}

+ 14 - 2
lib/controllers/crud-controller.js → lib/crud/controller.js

@@ -4,8 +4,20 @@ const crudController = ({
   Type
 }) => ({
   list: async (req, res) => {
-    const data = (await Type.findAll()).map(d => d.sanitize ? d.sanitize() : d)
-    res.status(200).send(data && data.sanitize ? data.sanitize() : data)
+    // TODO Pagination (http://docs.sequelizejs.com/manual/tutorial/querying.html#pagination-limiting)
+    if (req.query && req.query.q) {
+      const fields = [Type.tableAttributes.name, Type.tableAttributes.tag].filter(x => x)
+      if (!fields) throw new Error('Table has no searchable fields')
+      const or = fields.map(field => ({
+        [field.fieldName]: { [Op.like]: `%${req.query.q}%` }
+      }))
+      const where = { [Op.or]: or }
+      const data = (await Type.findAll({ where })).map(d => d.sanitize ? d.sanitize() : d)
+      res.status(200).send(data)
+    } else {
+      const data = (await Type.findAll()).map(d => d.sanitize ? d.sanitize() : d)
+      res.status(200).send(data && data.sanitize ? data.sanitize() : data)
+    }
   },
   create: async (req, res) => {
     const data = (await Type.create(req.body))

+ 20 - 27
app/components/crud-pages/index.js → lib/crud/defaults.js

@@ -1,11 +1,7 @@
-const app = require('../../app')
-const { pascal, camel, param, title } = require('change-case')
+const { pascal, title, camel, param } = require('change-case')
 const plural = require('plural')
-const list = require('./list')
-const details = require('./details')
-const trash = require('./trash')
 
-/** @define CrudPagesOptions
+/** @define CrudOptions
  * @property {string} titleName Type name in Title Case. This is used for labels like "New {Title Name}"
  * @property {string} titlePlural Plural type name in Title Case. This is used for labels like "View All {Title Plurals}"
  * @property {string} pascalName Type name in PascalCase. This is used for Class definitions like "app{PascalName}Page"
@@ -28,10 +24,9 @@ const trash = require('./trash')
  * @property {boolean} inList Default: true. Includes column in list page.
  */
 
-/**
- * @param {CrudPagesOptions} opts 
- */
-const crudPages = (opts) => {
+
+const defaults = (opts) => {
+  opts = Object.assign({}, opts)
   if (!opts.pascalName) opts.pascalName = pascal(opts.titleName || opts.camelName || opts.paramName || '')
   if (!opts.pascalName) throw new Error('pascalName is required')
   if (opts.pascalName !== pascal(opts.pascalName)) throw new Error('pascalName should be PascalCased')
@@ -49,6 +44,7 @@ const crudPages = (opts) => {
   if (opts.paramPlural !== param(opts.paramPlural)) throw new Error('paramPlural should be param-cased')
   if (!opts.apiPrefix) opts.apiPrefix = `/api/${opts.paramPlural}`
   if (!opts.appPrefix) opts.appPrefix = `/${opts.paramPlural}`
+  if (!opts.routeParam) opts.routeParam = `${opts.camelName}Id`
   if (!opts.listComponentName) opts.listComponentName = `app${opts.pascalPlural}List`
   if (opts.listComponentName !== camel(opts.listComponentName)) throw new Error('listComponentName should be camelCased')
   if (!opts.listComponentTag) opts.listComponentTag = `app-${opts.paramName}-list`
@@ -57,24 +53,21 @@ const crudPages = (opts) => {
   if (opts.listPageComponentName !== camel(opts.listPageComponentName)) throw new Error('listPageComponentName should be camelCased')
   if (!opts.listPageComponentTag) opts.listPageComponentTag = `app-${opts.paramPlural}-page`
   if (opts.listPageComponentTag !== param(opts.listPageComponentTag)) throw new Error('listPageComponentTag should be param-cased')
-  
-  if (!opts.columns) throw new Error('Columns are required')
-  opts.columns.forEach(col => {
-    if (!col.camelName) col.camelName = camel(opts.titleName)
-    if (!col.camelName) throw new Error('camelName is required')
-    if (col.camelName !== camel(col.camelName)) throw new Error('column.camelName should be camelCased')
-    if (!col.titleName) col.titleName = title(col.camelName)
-    if (col.type === undefined) col.type = 'text'
-    if (col.inList === undefined) col.inList = true
-  })
 
-  list(opts)
-  details(opts)
-  trash(opts)
+  if (opts.columns) {
+    opts.columns = opts.columns.map(col => {
+      col = Object.assign({}, col)
+      if (!col.camelName) col.camelName = camel(opts.titleName)
+      if (!col.camelName) throw new Error('camelName is required')
+      if (col.camelName !== camel(col.camelName)) throw new Error('column.camelName should be camelCased')
+      if (!col.titleName) col.titleName = title(col.camelName)
+      if (col.type === undefined) col.type = 'text'
+      if (col.inList === undefined) col.inList = true
+      return col
+    })
+  }
+  return opts;
 }
 
 
-  // TODO: Create Read Update Delete pages...
-
-
-module.exports = crudPages
+module.exports = defaults

+ 9 - 0
lib/crud/index.js

@@ -0,0 +1,9 @@
+const defaults = require('./defaults')
+const routes = require('./routes')
+const controller = require('./controller')
+
+module.exports = {
+  defaults,
+  routes,
+  controller
+}

+ 14 - 0
lib/crud/routes.js

@@ -0,0 +1,14 @@
+const defaults = require('./defaults')
+const asyncHandler = require('express-async-handler')
+
+module.exports = (opts) => {
+  opts = defaults(opts)
+  const { app, controller } = opts
+  if (controller.list) app.get(`/api/${opts.paramPlural}`, asyncHandler(controller.list))
+  if (controller.create) app.post(`/api/${opts.paramPlural}`, asyncHandler(controller.create))
+  if (controller.trash) app.get(`/api/${opts.paramPlural}/trash`, asyncHandler(controller.trash))
+  if (controller.read) app.get(`/api/${opts.paramPlural}/:id`, asyncHandler(controller.read))
+  if (controller.update) app.patch(`/api/${opts.paramPlural}/:id`, asyncHandler(controller.update))
+  if (controller.delete) app.delete(`/api/${opts.paramPlural}/:id`, asyncHandler(controller.delete))
+  if (controller.undelete) app.delete(`/api/${opts.paramPlural}/trash/:id`, asyncHandler(controller.undelete))
+}

+ 6 - 2
lib/database/index.js

@@ -3,10 +3,14 @@ const bcrypt = require('bcrypt')
 const sequelize = require('./sequelize')
 const User = require('./user')
 const Session = require('./session')
+const Role = require('./role')
 
 module.exports = {
-  init: () => sequelize.sync(),
+  init: () => {
+    return sequelize.sync()
+  },
   sequelize,
   User,
-  Session
+  Session,
+  Role
 }

+ 27 - 0
lib/database/role.js

@@ -0,0 +1,27 @@
+const _ = require('lodash')
+const Sequelize = require('sequelize')
+const sequelize = require('./sequelize')
+
+const Role = sequelize.define('role', {
+  id: {
+    type: Sequelize.UUID,
+    defaultValue: Sequelize.UUIDV1,
+    primaryKey: true
+  },
+  name: Sequelize.STRING,
+  key: {
+    type: Sequelize.STRING,
+    unique: true
+  },
+  permissions: Sequelize.STRING
+}, {
+  paranoid: true,
+  indexes: [
+    {
+      unique: true,
+      fields: ['key']
+    }
+  ]
+})
+
+module.exports = Role

+ 3 - 2
lib/routes.js

@@ -1,8 +1,9 @@
-const crudRoute = require('./crud-route')
+const { routes: crudRoutes } = require('./crud')
 const asyncHandler = require('express-async-handler')
 const C = require('./controllers')
 
 module.exports = app => {
   app.post('/api/auth/login', asyncHandler(C.auth.login.post))
-  crudRoute({ app, controller: C.user, typeName: 'User' })
+  crudRoutes({ app, controller: C.user, pascalName: 'User' })
+  crudRoutes({ app, controller: C.role, pascalName: 'Role' })
 }

+ 2 - 2
server.js

@@ -1,11 +1,11 @@
 const app = require('./lib/server')
-const crudRoute = require('./lib/crud-route')
+const crud = require('./lib/crud')
 const controllers = require('./lib/controllers')
 const database = require('./lib/database')
 
 module.exports = {
   app,
-  crudRoute,
+  crud,
   controllers,
   database
 }