From bff7f5e6197c85ebae37cde7cf5acace1e9a69b5 Mon Sep 17 00:00:00 2001
From: "Mark M. Florida" <markflorida@wustl.edu>
Date: Thu, 15 Sep 2016 18:42:51 -0500
Subject: [PATCH] XNAT-4399, XNAT-4242, XNAT-277, XNAT-1365: added hooks for
 validation and fixed some validation methods; updated codeEditor to handle
 read-only code blocks (for use in plugin inspector); added UI for managing
 investigators; improvements to table.dataTable widget for plugin registry and
 investigator tables.

---
 .../xnat/spawner/site-admin-elements.yaml     | 143 +++++++++++++-----
 src/main/webapp/scripts/polyfills.js          |  95 ++++++++++++
 src/main/webapp/scripts/utils.js              |  76 +++++++++-
 .../webapp/scripts/xnat/admin/passwords.js    |   2 +-
 .../scripts/xnat/admin/pluginManager.js       |  66 ++++++++
 .../webapp/scripts/xnat/app/codeEditor.js     |  38 +++--
 .../webapp/scripts/xnat/app/investigators.js  | 109 +++++++++++--
 src/main/webapp/scripts/xnat/ui/panel.js      |   1 +
 src/main/webapp/scripts/xnat/ui/table.js      | 111 +++++++++++---
 src/main/webapp/scripts/xnat/validate.js      |  17 ++-
 src/main/webapp/scripts/xnat/xhr.js           |   1 +
 11 files changed, 570 insertions(+), 89 deletions(-)
 create mode 100644 src/main/webapp/scripts/xnat/admin/pluginManager.js

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 8117452b..fc5cd386 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 20b0a733..5104deb2 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 f394ccea..581df582 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 9fa3adfa..30b020b3 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 00000000..1b9d91ee
--- /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 ee021e8a..4b53d019 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 b35ff329..b4f00892 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 bcc141ad..88a4a85d 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 97aa14c6..ea7b5283 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 b5dab661..210a7500 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 ff51eae7..83e57cda 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);
-- 
GitLab