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> </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> </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', ' ')) } }); } @@ -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