diff --git a/src/main/resources/META-INF/xnat/spawner/site-admin-elements.yaml b/src/main/resources/META-INF/xnat/spawner/site-admin-elements.yaml
index 8117452b170ec0ee00577716d23f8e2d9ea15ea1..fc5cd386dcd443c41d75cde56a2706a39cdd0549 100644
--- a/src/main/resources/META-INF/xnat/spawner/site-admin-elements.yaml
+++ b/src/main/resources/META-INF/xnat/spawner/site-admin-elements.yaml
@@ -1,7 +1,7 @@
 
 tabGroups:
 #    dashboard: Dashboard
-    xnatSetup: XNAT Setup
+    xnatSetup: Site Settings
     manageAccess: Manage Access
     manageData: Manage Data
     processing: Processing
@@ -14,7 +14,7 @@ siteId:
     kind: panel.input.text
     name: siteId
     label: Site ID
-    validation: required id onblur
+    validation: required id-strict max-length:24
     description: >
         The id used to refer to this site (also used to generate database ids).
         The Site ID must start with a letter and contain only letters, numbers
@@ -43,6 +43,7 @@ passwordExpirationInterval:
 #    id: passwordExpirationInterval
     name: passwordExpirationInterval
     label: Password Expiration (Interval)
+    validation: interval
     after: >
       <p class="description">Interval of time after which unchanged passwords expire and users have to change them.
       Uses <a target="_blank" href="http://www.postgresql.org/docs/9.0/static/functions-datetime.html">PostgreSQL interval notation</a>.</p>
@@ -52,6 +53,7 @@ passwordExpirationDate:
 #    id: passwordExpirationDate
     name: passwordExpirationDate
     label: Password Expiration (Date)
+    validation: date-us
     after:
         datePicker:
             tag: span#datePicker
@@ -106,6 +108,7 @@ siteLoginLanding:
     kind: panel.input.text
     name: siteLoginLanding
     label: Site Login Landing
+    validation: required uri
     description: "The page users will land on immediately after logging in."
 
 siteLandingLayout:
@@ -117,6 +120,7 @@ siteHome:
     kind: panel.input.text
     name: siteHome
     label: Site Home
+    validation: required uri
     description: "The page users will land on by clicking the XNAT logo in the menu bar."
 
 siteHomeLayout:
@@ -139,6 +143,7 @@ adminEmail:
     kind: panel.input.email
     name: adminEmail
     label: Site Admin Email
+    validation: required email
     description: >
         The administrative email account to receive system emails. This address will receive frequent emails
         on system events, such as errors, processing completion, new user registration and so on. These emails
@@ -194,7 +199,7 @@ archivePath:
     kind: panel.input.text
     name: archivePath
     label: Archive Path
-    validation: required path
+    validation: required uri
     description: ""
     element:
         disabled: true
@@ -202,31 +207,31 @@ cachePath:
     kind: panel.input.text
     name: cachePath
     label: Cache Path
-    validation: required path
+    validation: required uri
     description: ""
 prearchivePath:
     kind: panel.input.text
     name: prearchivePath
     label: Prearchive Path
-    validation: required path
+    validation: required uri
     description: ""
 ftpPath:
     kind: panel.input.text
     name: ftpPath
     label: FTP Path
-    validation: required path
+    validation: required uri
     description: ""
 buildPath:
     kind: panel.input.text
     name: buildPath
     label: Build Path
-    validation: required id onblur
+    validation: required id
     description: ""
 pipelinePath:
     kind: panel.input.text
     name: pipelinePath
     label: Pipeline Path
-    validation: required id onblur
+    validation: required id
     description: ""
 zipExtensions:
     kind: panel.input.text
@@ -344,6 +349,7 @@ userLoginsSessionControls:
             kind: panel.input.text
             name: sessionTimeout
             label: Session Timeout
+            validation: required interval
             description: >
               Interval for timing out user sessions. Uses
               <a target="_blank" href="http://www.postgresql.org/docs/9.0/static/functions-datetime.html">PostgreSQL interval notation</a>.
@@ -351,6 +357,7 @@ userLoginsSessionControls:
             kind: panel.input.text
             name: aliasTokenTimeout
             label: Alias Token Timeout
+            validation: required interval
             description: >
               Interval for timing out alias tokens. Uses
               <a target="_blank" href="http://www.postgresql.org/docs/9.0/static/functions-datetime.html">PostgreSQL interval notation</a>.
@@ -358,6 +365,7 @@ userLoginsSessionControls:
             kind: panel.input.text
             name: aliasTokenTimeoutSchedule
             label: Alias Token Timeout Schedule
+            validation: required cron
             description: >
               How often to check alias tokens for timeout (0 0 * * * * means it runs every hour). Uses basic
               <a target="_blank" href="http://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/scheduling/support/CronSequenceGenerator.html">Cron notation</a> (lists and ranges aren't supported).
@@ -377,6 +385,7 @@ userLoginsSessionControls:
             kind: panel.input.number
             name: ":sessions.concurrent_max"
             label: Maximum Concurrent Sessions
+            validation: required integer gte:1 lte:9999
             description: The maximum number of permitted sessions a user can have open simultaneously. You must restart Tomcat for changes to this to take effect.
         loginFailureMessage:
             kind: panel.textarea
@@ -389,11 +398,13 @@ userLoginsSessionControls:
             kind: panel.input.number
             name: maxFailedLogins
             label: Maximum Failed Logins
+            validation: required integer gte:1 lte:9999
             description: Number of failed login attempts before accounts are temporarily locked. (-1 disables feature)
         failedLoginLockoutDuration:
             kind: panel.input.text
             name: maxFailedLoginsLockoutDuration
             label: Failed Logins Lockout Duration
+            validation: required interval
             description: >
               Interval of time to lock user accounts that have exceeded the max_failed_logins count. Uses
               <a target="_blank" href="http://www.postgresql.org/docs/9.0/static/functions-datetime.html">PostgreSQL interval notation</a>.
@@ -401,6 +412,7 @@ userLoginsSessionControls:
             kind: panel.input.text
             name: resetFailedLoginsSchedule
             label: Reset Failed Logins Schedule
+            validation: required cron
             description: >
               How often to check if the Failed Logins Lockout Duration time has expired so locked out users can be allowed to log in again (0 0 * * * * means it runs every hour). Uses basic
               <a target="_blank" href="http://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/scheduling/support/CronSequenceGenerator.html">Cron notation</a> (lists and ranges aren't supported).
@@ -408,6 +420,7 @@ userLoginsSessionControls:
             kind: panel.input.text
             name: inactivityBeforeLockout
             label: User Inactivity Lockout
+            validation: required interval
             description: >
               Interval of inactivity before a user account is disabled. Uses
               <a target="_blank" href="http://www.postgresql.org/docs/9.0/static/functions-datetime.html">PostgreSQL interval notation</a>.
@@ -415,6 +428,7 @@ userLoginsSessionControls:
             kind: panel.input.text
             name: inactivityBeforeLockoutSchedule
             label: Inactivity Lockout Schedule
+            validation: required cron
             description: >
               How often to check user accounts for inactivity (0 0 1 * * * means it runs at 1AM every day). Uses basic
               <a target="_blank" href="http://docs.spring.io/spring/docs/current/javadoc-api/org/springframework/scheduling/support/CronSequenceGenerator.html">Cron notation</a> (lists and ranges aren't supported).
@@ -778,17 +792,30 @@ pluginTable:
     id: xnat-plugin-list
     name: pluginList
     load: /xapi/plugins
+    # specify which properties to show in the table
     items:
-        id: ~  # '~' creates hidden column
+        #id: ~!  # '~!' creates hidden column
+        id: ~data  # '~data' adds this item as a [data-] attribute to each <tr>
+        beanName: ~data.bean
         name: Plugin Name
-        pluginClass: Plugin Class
-        dataModelBeans:
+        pluginClass:
+            label: Plugin Class
+            className: center
+        viewPluginInfo:
             label: Contents
-            cells:
-                apply: formatJSON  # function that accepts value as sole argument
-                html: >
-                    <a href="#!" class="view-plugin-info link">View Plugin Info</a>
-                    <div class="hidden plugin-json-string">__VALUE__</div>
+            value: "-" # this gets overwritten with 'html' below
+            className: center
+            # function that accepts current item as sole argument and parent object as 'this'
+            # returned value will be new value - if nothing's returned, value is passed through
+            call: (function(){ return formatJSON(this) })
+            html: >
+                <a href="#!" class="view-plugin-info link">View Plugin Info</a>
+                <div data-code-language="json" class="hidden plugin-json-string">__VALUE__</div>
+            td:
+                title: "Plugin JSON"
+            #$: addClass('plugin-info') # this can be a string or object
+            $:
+                addClass: plugin-info
     messages:
         noData: >
             There are no plugins installed in this XNAT. Try searching for plugins on <a href="http://marketplace.xnat.org/plugins/" title="XNAT Marketplace" target="_blank">XNAT Marketplace</a>.
@@ -796,29 +823,7 @@ pluginTable:
             An error occurred retrieving information for installed plugins.
 
 pluginTableScript:
-    tag: script
-    content: >
-        var $body = $('body');
-        $body.on('click', '.view-plugin-info', function(){
-            var $this = $(this);
-            var $tr = $this.closest('tr');
-            var _id = $tr.find('td.id').text().trim();
-            var _url = XNAT.url.restUrl('/xapi/plugins/' + _id);
-            XNAT.xhr.getJSON(_url).done(function(data){
-                var _source = spawn('textarea', JSON.stringify(data, null, 4));
-            XNAT.app.codeEditor.init(_source, {
-                language: 'json'
-            }).openEditor({
-                title: 'Plugin Info',
-                classes: 'plugin-json',
-                footerContent: '(read-only)',
-                buttons: { close: { label: 'Close' } }
-            });
-            });
-        });
-        $body.on('focus', '.plugin-json textarea.ace_text-input', function(){
-            this.disabled = true;
-        });
+    tag: script|src=/scripts/xnat/admin/pluginManager.js
 
 pluginList:
     kind: panel
@@ -1016,7 +1021,7 @@ sessionBuilder:
             name: sessionXmlRebuilderRepeat
             label: Session Idle Check Interval
             placeholder: Interval in milliseconds
-            afterElement: milliseconds
+            afterElement: <span class="after">milliseconds</span>
             description: >
                 This controls how often the system checks to see if any incoming DICOM sessions in the prearchive have
                 been idle for longer than the configured session idle time. This value should be specified in
@@ -1026,7 +1031,7 @@ sessionBuilder:
             name: sessionXmlRebuilderInterval
             label: Session Idle Time
             placeholder: Time in minutes
-            afterElement: minutes
+            afterElement: <span class="after">minutes</span>
             description: >
                 This tells the system how long a DICOM session should sit idle—that is, with no new data added to the
                 session—before attempting to build a session document from the DICOM data. This value is specified in
@@ -1259,6 +1264,55 @@ automationSettings:
             offText: Disabled
             description: "When enabled, administrators can create and edit scripts that run internally to the XNAT process. This can be a powerful feature, but also can pose security hazards that may be unacceptable for certain deployments. For these situations, configurable internal scripting can be disabled (XNAT itself may still use some scripting for feature implementation, but these scripts can not be modified or updated by users)."
 
+investigators:
+    kind: panel
+    name: investigators
+    label: List of Investigators
+    contents:
+        investigatorsTable:
+            kind: table.dataTable
+            load: /xapi/investigators
+            sortable: firstname, lastname, email, institution
+            before:
+                investigatorsjs:
+                    tag: script|src=/scripts/xnat/app/investigators.js
+                script:
+                    tag: script
+                    content: >
+                        XNAT.admin = getObject(XNAT.admin);
+                        XNAT.admin.investigatorTable = XNAT.app.investigators.init().dataTable();
+                        XNAT.admin.investigatorFieldValue = function(val){ return val || '-' };
+                        XNAT.admin.investigatorArrayList = function(val){ return val.join(', ') };
+            items:
+                xnatInvestigatordataId:
+                    label: <div class="hidden"></div>
+                    className: center
+                    content: <a href="#!" class="view-investigator link" data-id="__VALUE__">View</a>
+#                title:
+#                    label: Title
+#                    call: function(val){ return val || '-' }
+                firstname:
+                    label: First Name
+                    call: XNAT.admin.investigatorFieldValue
+                lastname:
+                    label: Last Name
+                    call: XNAT.admin.investigatorFieldValue
+                email:
+                    label: Email
+                    call: XNAT.admin.investigatorFieldValue
+                institution:
+                    label: Institution
+                    call: XNAT.admin.investigatorFieldValue
+                primaryProjects:
+                    label: Project PI
+                    call: XNAT.admin.investigatorArrayList
+                investigatorProjects:
+                    label: ~!
+                    call: XNAT.admin.investigatorArrayList
+        newInvestigatorButton:
+            tag: button#new-investigator.btn1|style=margin-top:15px
+            content: Create New Investigator
+
 misc:
     kind: panel.form
     name: misc
@@ -1431,6 +1485,13 @@ adminPage:
             group: advanced
             contents:
                 ${automationSettings}
+        manageInvestigators:
+            kind: tab
+            name: manageInvestigators
+            label: Manage Investigators
+            group: other
+            contents:
+                ${investigators}
         misc:
             kind: tab
             name: misc
diff --git a/src/main/webapp/scripts/polyfills.js b/src/main/webapp/scripts/polyfills.js
index 20b0a733d9e7dd47166597591be62d15f3d6d4ce..5104deb203e95da47a745c62524a161759fc82d6 100644
--- a/src/main/webapp/scripts/polyfills.js
+++ b/src/main/webapp/scripts/polyfills.js
@@ -256,6 +256,101 @@ if (!Array.prototype.filter) {
     };
 }
 
+// trim spaces from all string items in an array
+// optionally removing empty strings from the array
+// and optionally purging non-string items
+if (!Array.prototype.trim) {
+    Array.prototype.trim = function(strip, purge) {
+        'use strict';
+        if (this == null) {
+            throw new TypeError('Array.prototype.trim called on null or undefined');
+        }
+        var i = -1;
+        var len = this.length;
+        var outputArray = [];
+        var item;
+        while (++i < len) {
+            item = this[i];
+            // return non-string items as-is
+            if (typeof item !== 'string') {
+                // if not purging them
+                if (purge !== true) {
+                    outputArray.push(item);
+                }
+                continue;
+            }
+            // trim whitespace from string
+            item = item.trim();
+            // only push empty item if not stripping them
+            if (item === '') {
+                if (!strip) {
+                    outputArray.push(item);
+                }
+            }
+            else {
+                // always push non-empty strings
+                outputArray.push(item);
+            }
+        }
+        return outputArray;
+    }
+}
+
+// force 'stringable' array elements to string,
+// optionally including non-stringable items
+if (!Array.prototype.strings) {
+    Array.prototype.strings = function(other){
+        'use strict';
+        if (this == null) {
+            throw new TypeError('Array.prototype.strings called on null or undefined');
+        }
+        var stringArray = [];
+        this.forEach(function(item){
+            var _item = null;
+            if (/string|number|boolean/.test(typeof item)) {
+                _item = item+'';
+            }
+            else if (other) {
+                if (Array.isArray(item)){
+                    _item = '[' + item.strings(other) + ']';
+                }
+                else if (typeof item === 'function') {
+                    _item = item.toString().replace(/\s+/, ' ');
+                }
+                else if (typeof item === 'object') {
+                    try {
+                        _item = JSON.stringify(item)
+                    }
+                    catch(e1) {
+                        try {
+                            _item = item.toString()
+                        }
+                        catch(e2) {
+                            try {
+                                _item = item+''
+                            }
+                            catch(e3) {
+                                console.error('Could not stringify. ' + [e1, e2, e3].join(' '));
+                            }
+                        }
+                    }
+                }
+            }
+            if (_item) {
+                stringArray.push(_item);
+            }
+        });
+        return stringArray;
+    }
+}
+
+// simple check for a plain object
+if (typeof Object.isObject != 'function') {
+    Object.isObject = function(obj) {
+        return Object.prototype.toString.call(obj) === '[object Object]';
+    };
+}
+
 if (typeof Object.assign != 'function') {
     Object.assign = function (target) {
         var undefined;
diff --git a/src/main/webapp/scripts/utils.js b/src/main/webapp/scripts/utils.js
index f394ccea730194c039987d910122b71a88386840..581df582764f0c61505c682a08369d3dd0166b42 100755
--- a/src/main/webapp/scripts/utils.js
+++ b/src/main/webapp/scripts/utils.js
@@ -183,6 +183,75 @@ jQuery.loadScript = function (url, arg1, arg2) {
 };
 
 
+// return defined attributes for selection
+// optionally passing a list of attributes to get
+// (as an array or space- or comma-separated list)
+(function($attr){
+
+    function notEmpty(item){
+        return item > ''
+    }
+
+    function mapAttrs(attrs){
+        var i = -1,
+            attr,
+            obj = {};
+        while (++i < attrs.length){
+            attr = attrs[i];
+            if (attr.specified) {
+                obj[attr.name] =
+                    obj[toCamelCase(attr.name)] =
+                        attr;
+            }
+        }
+        return obj;
+    }
+
+    $.fn.getAttr =
+    $.fn.getAttrs =
+    $.fn.getAttributes = function(attrs){
+        var attributes = this[0].attributes,
+            attrMap = mapAttrs(attributes),
+            names = [],
+            obj = {};
+        if (!this.length) {
+            return null;
+        }
+        // normalize to an array of attribute names
+        if (attrs) {
+            names = [].concat(attrs||[]).join(' ').split(/[,\s+]/).filter(notEmpty);
+        }
+        else {
+            names = toArray(attributes).map(function(item){ return item.name });
+        }
+        if (!names.length) {
+            return attrMap;
+        }
+        names.forEach(function(name){
+            obj[name] =
+                obj[toCamelCase(name)] =
+                    attrMap[name].value;
+        });
+        return obj;
+    }
+
+    // given: <div id="foo" title="Foo" class="bar">Foo</div>
+    // $('#foo').attr();
+    // --> { id: 'foo', title: 'Foo', "class": 'bar' }
+    $.fn.attr =
+    $.fn.attrs = function() {
+        if (!arguments.length) {
+            if (!this.length) {
+                return null;
+            }
+            return mapAttrs(this[0].attributes);
+        }
+        return $attr.apply(this, arguments);
+    };
+
+})($.fn.attr);
+
+
 // set the value of a form element, then fire the
 // 'onchange' event ONLY if the value actually changed
 // (works on hidden inputs too)
@@ -340,12 +409,14 @@ jQuery.fn.tableSort = function(){
     //     return this.innerHTML.trim() > '';
     // }).addClass('sort');
     $table.find('th.sort')
-          .append('<i>&nbsp;</i>')
           // wrapInner('<a href="#" class="nolink" title="click to sort on this column"/>').
           .each(function(){
+              var $this = $(this);
+              $this.find('i').remove();
+              $this.append('<i>&nbsp;</i>');
               // don't overwrite existing title
               this.title += ' (click to sort) ';
-              $(this).on('click.sort', function(){
+              $this.on('click.sort', function(){
                   var $th = $(this),
                       thIndex = $th.index(),
                       sorted = $th.hasAnyClass('asc desc'),
@@ -513,6 +584,7 @@ function menuInit(select, opts, width){
 }
 
 function menuUpdate(select){
+    if (!select) return false;
     return $$(select||'select.xnat-menu').trigger('chosen:updated');
 }
 
diff --git a/src/main/webapp/scripts/xnat/admin/passwords.js b/src/main/webapp/scripts/xnat/admin/passwords.js
index 9fa3adfa66a707bbf5f4949cdb3c955013e9978d..30b020b3d0cb4b81b261a1a29c32f40ff4c0286d 100644
--- a/src/main/webapp/scripts/xnat/admin/passwords.js
+++ b/src/main/webapp/scripts/xnat/admin/passwords.js
@@ -1,5 +1,5 @@
 // interactions with 'Security Passwords' section of admin ui
-console.log('passwordExpirationType.js');
+console.log('passwords.js');
 
 (function(){
 
diff --git a/src/main/webapp/scripts/xnat/admin/pluginManager.js b/src/main/webapp/scripts/xnat/admin/pluginManager.js
new file mode 100644
index 0000000000000000000000000000000000000000..1b9d91eecd51aa78e6e1006d29fdd2ce45a47f60
--- /dev/null
+++ b/src/main/webapp/scripts/xnat/admin/pluginManager.js
@@ -0,0 +1,66 @@
+/*!
+ * Display list of installed plugins and view plugin info
+ */
+
+$(function(){
+
+    console.log('pluginManager.js');
+
+    var $body = $('body');
+
+    // open dialog to view JSON
+    $body.on('click', 'a.view-plugin-info', function(){
+
+        var _id  = $(this).closest('tr').dataAttr('id');
+        var _url = XNAT.url.restUrl('/xapi/plugins/' + _id);
+
+        XNAT.xhr.getJSON(_url).done(function(data){
+
+            var _source = spawn('textarea', JSON.stringify(data, null, 4));
+
+            var _editor = XNAT.app.codeEditor.init(_source, {
+                language: 'json'
+            });
+
+            _editor.openEditor({
+                title: data.name,
+                classes: 'plugin-json',
+                footerContent: '(read-only)',
+                buttons: {
+                    // json: {
+                    //     label: 'View JSON',
+                    //     link: true,
+                    //     action: function(){
+                    //         xmodal.iframe({
+                    //             title: data.id,
+                    //             url: _url,
+                    //             width: 720, height: 480,
+                    //             buttons: { close: { label: 'Close' } }
+                    //         })
+                    //     }
+                    // },
+                    close: { label: 'Close' }
+                },
+                afterShow: function(dialog, obj){
+                    obj.aceEditor.setReadOnly(true);
+                }
+            });
+        });
+    });
+
+    // // alternate function that gets JSON from a hidden <div>
+    // $body.on('click', 'a.view-plugin-info', function(){
+    //     var _source = $(this).next('div.plugin-json-string');
+    //     XNAT.app.codeEditor.init(_source).openEditor({
+    //         title: 'Plugin Info',
+    //         classes: 'plugin-json',
+    //         footerContent: '(read-only)',
+    //         buttons: { close: { label: 'Close' } },
+    //         afterShow: function(dialog, obj){
+    //             obj.aceEditor.setReadOnly(true);
+    //         }
+    //     });
+    // });
+
+});
+
diff --git a/src/main/webapp/scripts/xnat/app/codeEditor.js b/src/main/webapp/scripts/xnat/app/codeEditor.js
index ee021e8a585ce78ffeb846e1a1437196d359dd6f..4b53d0194315e1882e9e74ab4abe87006e904b4e 100644
--- a/src/main/webapp/scripts/xnat/app/codeEditor.js
+++ b/src/main/webapp/scripts/xnat/app/codeEditor.js
@@ -10,6 +10,8 @@ var XNAT = getObject(XNAT || {});
     var codeEditor,
         xhr = XNAT.xhr;
 
+    console.log('codeEditor.js');
+
     var csrfParam = {
         XNAT_CSRF: csrfToken
     };
@@ -132,12 +134,13 @@ var XNAT = getObject(XNAT || {});
                 _this.aceEditor.setTheme('ace/theme/eclipse');
                 _this.aceEditor.getSession().setMode('ace/mode/' + stringLower(_this.language||''));
                 _this.aceEditor.session.setValue(_this.code);
+                // _this.aceEditor.setReadOnly(_this.readonly);
             }
         });
 
         // put the new editor div in the wrapper
         _this.$editor.empty().append(editor);
-        
+
         return this;
 
     };
@@ -154,7 +157,8 @@ var XNAT = getObject(XNAT || {});
     // open code in a dialog for editing
     Editor.fn.openEditor = function(opts){
 
-        var _this = this;
+        var _this = this,
+            fn = {};
 
         var modal = {};
         modal.width = 880;
@@ -190,13 +194,31 @@ var XNAT = getObject(XNAT || {});
                 'Changes will be submitted on save.' :
                 'Changes are not submitted automatically.<br>The containing form will need to be submitted to save.') +
             '</span>';
-        modal.beforeShow = function(obj){
-            _this.$editor = obj.$modal.find('div.code-editor');
+
+        // the 'beforeShow' and 'afterShow' methods
+        // get an extra argument - the Editor instance
+
+        var _beforeShow = opts.beforeShow;
+
+        fn.beforeShow = function(dialog){
+            _this.$editor = this.$modal.find('div.code-editor');
             _this.load();
+            if (isFunction(_beforeShow)) {
+                // '_this' is the Editor instance
+                _beforeShow.call(this, dialog, _this)
+            }
         };
-        modal.afterShow = function(){
-            _this.$editor.focus();
+
+        var _afterShow = opts.afterShow;
+
+        fn.afterShow = function(dialog){
+            if (isFunction(_afterShow)) {
+                // '_this' is the Editor instance
+                _afterShow.call(this, dialog, _this)
+            }
+            _this.aceEditor.focus();
         };
+
         modal.buttons = {
             save: {
                 label: _this.isUrl ? 'Submit Changes' : 'Apply Changes',
@@ -212,9 +234,7 @@ var XNAT = getObject(XNAT || {});
         };
         
         // override modal options with {opts}
-        extend(modal, opts);
-
-        this.dialog = xmodal.open(modal);
+        this.dialog = xmodal.open(extend({}, modal, opts, fn));
         
         return this;
 
diff --git a/src/main/webapp/scripts/xnat/app/investigators.js b/src/main/webapp/scripts/xnat/app/investigators.js
index b35ff3291f2c915ddbd503d8bb55a5c5f71483d6..b4f00892751ab6e659b34f7487b27434fc3c233a 100644
--- a/src/main/webapp/scripts/xnat/app/investigators.js
+++ b/src/main/webapp/scripts/xnat/app/investigators.js
@@ -25,6 +25,8 @@ var XNAT = getObject(XNAT);
     XNAT.app = getObject(XNAT.app||{});
     XNAT.xapi = getObject(XNAT.xapi||{});
 
+    console.log('investigators.js');
+
     function setupUrl(part){
         part = part ? '/' + part : '';
         return xurl.rootUrl(BASE_URL + part);
@@ -74,6 +76,7 @@ var XNAT = getObject(XNAT);
 
     function Investigators(opts){
         extend(true, this, opts);
+        this.menu = null; // this will be updated when a menu is created
     }
 
     Investigators.fn = Investigators.prototype;
@@ -238,7 +241,7 @@ var XNAT = getObject(XNAT);
             }
         }
 
-        var isPrimary = self.menu.value == investigators.primary;
+        var isPrimary = self.menu ? self.menu.value == investigators.primary : false;
 
         function investigatorForm(){
             return {
@@ -258,7 +261,7 @@ var XNAT = getObject(XNAT);
                         department: createInput('Department', 'department'),
                         email: createInput('Email', 'email', 'email'),
                         phone: createInput('Phone', 'phone', 'numeric-dash'),
-                        primary: {
+                        primary: self.menu ? {
                             kind: 'panel.element',
                             label: false,
                             contents:
@@ -266,6 +269,9 @@ var XNAT = getObject(XNAT);
                                 '<input type="checkbox" class="set-primary">' +
                                 ' Set as Primary' +
                                 '</label>'
+                        } : {
+                            tag: 'i.hidden',
+                            content: '(no menu, no checkbox)'
                         }
                         // ID: createInput('ID', 'ID'),
                         // invId: {
@@ -288,13 +294,15 @@ var XNAT = getObject(XNAT);
                         invForm.render(obj.$modal.find('div.add-edit-investigator'));
                     },
                     afterShow: function(obj){
-                        obj.$modal.find('input.set-primary').prop('checked', isPrimary);
+                        if (self.menu) {
+                            obj.$modal.find('input.set-primary').prop('checked', isPrimary);
+                        }
                     },
                     okLabel: 'Submit',
                     okClose: false,
                     okAction: function(obj){
                         var _form = obj.$modal.find('form[name="editInvestigator"]'),
-                            setPrimary = _form.find('input.set-primary')[0].checked;
+                            setPrimary = self.menu ? _form.find('input.set-primary')[0].checked : false;
                         $(_form).submitJSON({
                             delim: '!',
                             validate: function(){
@@ -315,20 +323,22 @@ var XNAT = getObject(XNAT);
 
                                 return errors === 0;
                             },
-                            success: function(data){
+                            success: function(data, status, xhrObj){
                                 var selected = data.xnatInvestigatordataId;
                                 ui.banner.top(2000, 'Investigator data saved.', 'success');
                                 // update other menus, if specified
-                                if (menus) {
-                                    [].concat(menus).forEach(function(menu){
-                                        menu.updateMenu(setPrimary ? selected : '');
-                                    })
-                                }
-                                // update the menu associated with the dialog
-                                self.updateMenu([].concat(self.getSelected(), (!setPrimary ? selected : [])));
-                                // set the PI if editing/creating PI
-                                if (setPrimary) {
-                                    investigators.primary = selected;
+                                if (self.menu) {
+                                    if (menus) {
+                                        [].concat(menus).forEach(function(menu){
+                                            menu.updateMenu(setPrimary ? selected : '');
+                                        })
+                                    }
+                                    // update the menu associated with the dialog
+                                    self.updateMenu([].concat(self.getSelected(), (!setPrimary ? selected : [])));
+                                    // set the PI if editing/creating PI
+                                    if (setPrimary) {
+                                        investigators.primary = selected;
+                                    }
                                 }
                                 dialog.close();
                             }
@@ -346,6 +356,75 @@ var XNAT = getObject(XNAT);
     };
 
 
+    Investigators.fn.dataTable = function(container){
+
+        var self = this;
+        
+        var tableConfig = {
+            "investigatorsTable": {
+                kind: "table.dataTable",
+                load: "/xapi/investigators",
+                "items": {
+                    "xnatInvestigatordataId": {
+                        label: '<div class="hidden"></div>',
+                        className: "center",
+                        content: '<a href="#!" class="view-investigator" data-id="__VALUE__">View</a>',
+                        call: function(id){}
+                    },
+                    // "title": {
+                    //     label: "Title",
+                    //     call: function(val){ return val || '-' }
+                    // },
+                    "firstname": {
+                        label: "First Name",
+                        call: function(val){ return val || '-' }
+                    },
+                    "lastname": {
+                        label: "Last Name",
+                        call: function(val){ return val || '-' }
+                    },
+                    "email": {
+                        label: "Email",
+                        call: function(val){ return val || '-' }
+                    },
+                    "institution": {
+                        label: "Institution",
+                        call: function(val){ return val || '-' }
+                    },
+                    "primaryProjects": {
+                        label: "PI",
+                        call: function(val){ return val.join(', ') }
+                    },
+                    "investigatorProjects": {
+                        label: "~!",
+                        call: function(val){ return val.join(', ') }
+                    }
+                }
+            }
+        };
+
+        this.spawnedTable = XNAT.spawner.spawn(tableConfig);
+        this.table = this.spawnedTable.get();
+
+        if (container) {
+            this.spawnedTable.render(container);
+        }
+
+        var $body = $('body');
+
+        $body.on('click', 'a.view-investigator', function(){
+            self.dialog($(this).data('id'));
+        });
+
+        $body.on('click', 'button#new-investigator', function(){
+            self.dialog();
+        });
+
+        return this;
+
+    };
+    
+    
     // init function for XNAT.misc.blank
     investigators.init = function(opts){
         return new Investigators(opts);
diff --git a/src/main/webapp/scripts/xnat/ui/panel.js b/src/main/webapp/scripts/xnat/ui/panel.js
index bcc141ad457941df46514cab57712cb5471f120c..88a4a85d7321d7c6dd1db139b75188f0734a5372 100644
--- a/src/main/webapp/scripts/xnat/ui/panel.js
+++ b/src/main/webapp/scripts/xnat/ui/panel.js
@@ -387,6 +387,7 @@ var XNAT = getObject(XNAT || {});
         // custom event for reloading data (refresh)
         $formPanel.on('reload-data', function(){
             var _load = opts.refresh || opts.load || opts.url;
+            $(this).find('.valid, .invalid').removeClass('valid invalid');
             loadData(this, {
                 load: _load ? ('$?' + _load.replace(/^\$\?/, '')) : ''
             });
diff --git a/src/main/webapp/scripts/xnat/ui/table.js b/src/main/webapp/scripts/xnat/ui/table.js
index 97aa14c640f6d4455fc142dfa0b4cbdee6033e62..ea7b5283e1bb31500655faeb296ac2e4e9dd91c6 100755
--- a/src/main/webapp/scripts/xnat/ui/table.js
+++ b/src/main/webapp/scripts/xnat/ui/table.js
@@ -397,7 +397,19 @@ var XNAT = getObject(XNAT);
 
         // create a div to hold the table
         // or message (if no data or error)
-        var tableContainer = spawn('div.data-table-container', [newTable.table]);
+        var $tableContainer = $.spawn('div.data-table-container', [newTable.table]);
+        var tableContainer = $tableContainer[0];
+
+        // if (opts.before) {
+        //     $tableContainer.prepend(opts.before);
+        // }
+
+        // add the table
+        // $tableContainer.append(newTable.table);
+
+        // if (opts.after) {
+        //     $tableContainer.append(opts.after);
+        // }
 
         function createTable(rows){
             var props = [], objRows = [];
@@ -408,6 +420,7 @@ var XNAT = getObject(XNAT);
                 });
                 rows = objRows; // now it's an array
             }
+            // create header row
             if (!allItems && (opts.items || opts.properties)) {
                 newTable.tr();
                 forOwn(opts.items||opts.properties, function(name, val){
@@ -415,16 +428,24 @@ var XNAT = getObject(XNAT);
                     // if it's an object, get the 'label' property
                     //var label = stringable(val) ? val+'' : val.label;
                     props.push(name);
+
+                    // don't create <th> for items labeled as '~data'
+                    if (/^~data/.test(val)) {
+                        return;
+                    }
+
                     newTable.th(val.label || val);
-                    if (val === '~') {
+
+                    if (/^~!/.test(val.label || val)) {
                         $(newTable.last.th).html(name)
                                 .addClass('hidden')
                                 .dataAttr('prop', name);
                         return;
                     }
-                    if (!opts.sortable) return;
-                    if (opts.sortable === true || opts.sortable.indexOf(name) !== -1) {
+                    //if (!opts.sortable) return;
+                    if (val.sort || opts.sortable === true || (opts.sortable||[]).indexOf(name) !== -1) {
                         addClassName(newTable.last.th, 'sort');
+                        newTable.last.th.appendChild(spawn('i', '&nbsp;'))
                     }
                 });
             }
@@ -435,7 +456,7 @@ var XNAT = getObject(XNAT);
                 forOwn(rows[0], function(name, val){
                     if (allItems) {
                         newTable.th(name);
-                        if (val === '~') {
+                        if (/^~!/.test(val)) {
                             addClassName(newTable.last.th, 'hidden');
                         }
                     }
@@ -444,23 +465,77 @@ var XNAT = getObject(XNAT);
             }
             rows.forEach(function(item){
                 newTable.tr();
+                // iterate properties for each row
                 props.forEach(function(name){
-                    var cellObj = { className: name };
+
+                    var hidden = false;
                     var itemVal = item[name];
-                    if (opts.items && opts.items[name].cells) {
-                        extend(true, cellObj, opts.items[name].cells);
-                    }
-                    else {
-                        cellObj.html = itemVal;
+                    var cellObj = {};
+                    var tdElement = {
+                        className: name,
+                        html: itemVal
+                    };
+
+                    if (opts.items) {
+                        cellObj = opts.items[name];
+                        if (typeof cellObj === 'string') {
+                            // set item label to '~data' to add as a
+                            // [data-*] attribute to the <tr>
+                            if (/^~data/.test(cellObj)) {
+                                var dataName = cellObj.split('.')[1] || name;
+                                newTable.last$('tr').dataAttr(dataName, itemVal);
+                                return;
+                            }
+                            hidden = /^~!/.test(cellObj);
+                        }
+                        else {
+                            if (cellObj.td || cellObj.element) {
+                                extend(true, tdElement, cellObj.td || cellObj.element);
+                            }
+                            if (cellObj.value) {
+                                // explicitly override value
+                                itemVal = cellObj.value;
+                            }
+                            if (cellObj.className) {
+                                addClassName(tdElement, cellObj.className);
+                            }
+                            // if (cellObj.apply) {
+                            //     itemVal = eval(cellObj.apply).apply(item, [itemVal]);
+                            // }
+                            if (cellObj['call']) {
+                                if (isFunction(cellObj['call'])) {
+                                    itemVal = cellObj['call'].call(item, itemVal) || itemVal;
+                                }
+                                else {
+                                    itemVal = eval('('+cellObj['call'].trim()+')').call(item, itemVal) || itemVal;
+                                }
+                            }
+                            // special __VALUE__ string gets replaced
+                            if (cellObj.html || cellObj.content) {
+                                tdElement.html = (cellObj.html || cellObj.content).replace(/__VALUE__/g, itemVal);
+                            }
+                            hidden = /^~!/.test(cellObj.label);
+                        }
                     }
-                    if (cellObj.apply) {
-                        itemVal = eval(cellObj.apply).apply(item, itemVal);
+
+                    newTable.td(tdElement);
+
+                    var $td = $(newTable.last.td);
+
+                    // evaluate jQuery methods
+                    if (cellObj.$) {
+                        if (typeof cellObj.$ === 'string') {
+                            eval('$(newTable.last.td).'+(cellObj.$).trim());
+                        }
+                        else {
+                            forOwn(cellObj.$, function(method, args){
+                                $td[method].apply($td, [].concat(args))
+                            });
+                        }
                     }
-                    // special __VALUE__ string gets replaced
-                    cellObj.html = cellObj.html.replace(/__VALUE__/, itemVal);
-                    newTable.td(cellObj);
-                    if (opts.items[name] === '~') {
-                        addClassName(newTable.last.td, 'hidden');
+
+                    if (hidden) {
+                        $td.addClass('hidden');
                     }
 
                 });
diff --git a/src/main/webapp/scripts/xnat/validate.js b/src/main/webapp/scripts/xnat/validate.js
index b5dab661e86717e709ade2f2f6ccbc5ea325d154..210a75004a869fffe18619ab5d4555f67a17def2 100644
--- a/src/main/webapp/scripts/xnat/validate.js
+++ b/src/main/webapp/scripts/xnat/validate.js
@@ -17,7 +17,7 @@ var XNAT = getObject(XNAT);
     }
 }(function(){
 
-    var undefined, validate;
+    var undefined, undef, validate;
 
     XNAT.validation = getObject(XNAT.validation || {});
 
@@ -158,7 +158,7 @@ var XNAT = getObject(XNAT);
         var units = /\s+(sec|second|min|minute|hour|day|week|month|year)(s)?\s*/;
         var num = true;
         var valid = true;
-        var i = 1; // start i at 1 since parts[0] will be an empty string
+        var i = parts[0] === '' ? 1 : 0; // start i at 1 if parts[0] is an empty string
         var part;
         while (parts[i] && valid === true) {
             part = (parts[i] + '');
@@ -549,7 +549,7 @@ var XNAT = getObject(XNAT);
             var elValidate = new Validator(this);
             elValidate.is(type, args);
             //valid = regex[type].test(this.value);
-            if (elValidate.isValid(false)) {
+            if (!elValidate.isValid(true)) {
                 invalid++
             }
         });
@@ -629,9 +629,20 @@ var XNAT = getObject(XNAT);
     // function (must return true or false),
     // or custom regex
     Validator.fn.check = function(type){
+        var self = this,
+            types = [];
         if (type) {
             this.is(type);
         }
+        else if (type !== false) {
+            if (this.element$.dataAttr('validate')) {
+                types = this.element$.dataAttr('validate').split(/\s+/);
+                types.forEach(function(_type){
+                    var parts = _type.split(/[:=]/);
+                    self.is(parts[0], parts[1]);
+                })
+            }
+        }
         return this.isValid(true);
     };
 
diff --git a/src/main/webapp/scripts/xnat/xhr.js b/src/main/webapp/scripts/xnat/xhr.js
index ff51eae79c990831bacb28221ef6c6e012e154a0..83e57cda11eab13bd99d87e953c7593d9ca5a65f 100755
--- a/src/main/webapp/scripts/xnat/xhr.js
+++ b/src/main/webapp/scripts/xnat/xhr.js
@@ -480,6 +480,7 @@ var XNAT = getObject(XNAT||{}),
                 if (this.value === '') {
                     this.value = val;
                 }
+                changeValue($this, val);
             }
             else if (/radio/i.test(this.type)) {
                 this.checked = isEqual(this.value, val);