diff --git a/modules/survey_module/ajax/survey_api.php b/modules/survey_module/ajax/survey_api.php new file mode 100644 index 00000000000..7897f761f6c --- /dev/null +++ b/modules/survey_module/ajax/survey_api.php @@ -0,0 +1,364 @@ + + * @license Loris license + * @link https://www.github.com/aces/Loris/ + */ + +/** + * Class to handle request types + * + * PHP Version 7 + * + * @category Survey + * @package Loris + * @author Jordan Stirling + * @license Loris license + * @link https://www.github.com/aces/Loris/ + */ +class DirectDataEntryMainPage +{ + var $tpl_data = array(); + + /** + * Initialize all of the class variables and things required from the + * REQUEST. + * + * @return null + */ + function initialize() + { + ob_start('ob_gzhandler'); + $client = new NDB_Client(); + $client->makeCommandLine(); + $client->initialize(); + + $factory = NDB_Factory::singleton(); + $config = $factory->config(); + $settings = $factory->settings(); + + $this->tpl_data['baseurl'] = $settings->getBaseURL(); + $this->key = $_REQUEST['key']; + + $DB = Database::singleton(); + $this->SurveyInfo = $DB->pselect( + "SELECT Status, Test_name, CommentID FROM participant_accounts + WHERE OneTimePassword=:key", + array('key' => $this->key) + ); + + if (empty($this->SurveyInfo)) { + throw new Exception("The given survey doesn't exist", 404); + } else if (count($this->SurveyInfo) > 1) { + throw new Exception("Well looks like we made a mistake :(", 500); + } else if ($this->SurveyInfo[0]['Status'] === 'Complete') { + throw new Exception("Data has already been submitted.", 403); + } + + $this->SurveyInfo = $this->SurveyInfo[0]; + + } + + /** + * Handles a request by delegating to the appropriate + * handle method + * + * @return null + */ + function handleRequest() + { + switch($_SERVER['REQUEST_METHOD']) { + case 'GET': + $this->handleGET(); + break; + case 'PUT': + $this->handlePUT(); + break; + case 'PATCH': + $this->handlePATCH(); + break; + case 'POST': + $this->handlePOST(); + break; + default: + $this->header("HTTP/1.1 501 Not Implemented"); + break; + } + } + + /** + * Handle a GET request. This will render and display the page. + * + * @return null + */ + function handleGET() + { + + try { + $this->Instrument = \NDB_BVL_Instrument::factory( + $this->SurveyInfo['Test_name'], + $this->SurveyInfo['CommentID'], + null, + true + ); + } catch(\Exception $e) { + throw new Exception("Instrument not found", 405); + } + + $this->tpl_data['InstrumentJSON'] = $this->Instrument->toDirectJSON(); + + $Values = \NDB_BVL_Instrument::loadInstanceData($this->Instrument); + + unset($Values['CommentID']); + unset($Values['UserID']); + // unset($Values['Testdate']); + unset($Values['Data_entry_completion_status']); + + // Unset score values + $json_instrument = json_decode($this->tpl_data['InstrumentJSON']); + $this->unsetScores($Values, $json_instrument->Elements); + + $this->tpl_data['Values'] = json_encode($Values); + + echo json_encode($this->tpl_data); + + // $this->display(); + } + + /** + * Unsets Score values so that they are not transferred to the frontend + * + * @param array $values pointer to the values array + * @param array $elements the elements to check if + * score fields are present + * + * @return null + */ + function unsetScores(&$values, $elements) + { + foreach ($elements as $element) { + if ($element->Type === 'ElementGroup') { + $this->unsetScores($values, $element->Elements); + } else if ($element->Type === 'label' + && array_key_exists($element->Name, $values) + ) { + unset($values[$element->Name]); + } + } + } + + /** + * Handle a PATCH request. This will update a single field + * + * @return null + */ + function handlePATCH() + { + + try { + $this->Instrument = \NDB_BVL_Instrument::factory( + $this->SurveyInfo['Test_name'], + $this->SurveyInfo['CommentID'], + null, + true + ); + } catch(\Exception $e) { + throw new Exception("Instrument not found", 405); + } + + $fp = fopen("php://input", "r"); + $data = ''; + while (!feof($fp)) { + $data .= fread($fp, 1024); + } + fclose($fp); + + $data = json_decode($data); + $instrument_name = $this->Instrument->testName; + + if (count($data) !== 1) { + // The survey module will only PATCH one variable at a time. + header("HTTP/1.0 400 Bad Request"); + } + + if ($this->Instrument->validate($data)) { + try { + $this->Instrument->_save($data); + + } catch (Exception $e) { + header("HTTP/1.0 400 Bad Request"); + } + } else { + $this->Header("HTTP/1.1 403 Forbidden"); + if (!$this->Instrument->determineDataEntryAllowed()) { + $msg = "Can not update instruments that" + . " are flagged as complete"; + // $this->JSON = array('error' => $msg); + } else { + // $this->JSON = array("error" => "Could not update."); + } + } + } + + /** + * Handle a PATCH request. This will update a single field + * + * @return null + */ + function handlePUT() + { + + $fp = fopen("php://input", "r"); + $data = ''; + while (!feof($fp)) { + $data .= fread($fp, 1024); + } + fclose($fp); + + $data = json_decode($data, true); + $subtest = null; + + if ($data['page'] !== 0) { + $subtest = $data['page']; + } + + try { + $this->Instrument = \NDB_BVL_Instrument::factory( + $this->SurveyInfo['Test_name'], + $this->SurveyInfo['CommentID'], + $subtest, + true + ); + } catch(\Exception $e) { + throw new Exception("Instrument not found", 405); + } + + $this->Instrument->form->directEntry = true; + $this->Instrument->form->directValues = $data['data']; + + if ($this->Instrument->form->validate()) { + if ($data['FinalPage']) { + echo $this->Instrument->getReactReview(); + } else { + echo $this->Instrument->toDirectJSON(); + } + } else { + header("HTTP/1.0 400 Bad Request"); + echo json_encode($this->Instrument->form->errors); + } + + } + + /** + * Handle a PATCH request. This will update a single field + * + * @return null + */ + function handlePOST() + { + + $fp = fopen("php://input", "r"); + $data = ''; + while (!feof($fp)) { + $data .= fread($fp, 1024); + } + fclose($fp); + + $data = json_decode($data, true); + + try { + $this->Instrument = \NDB_BVL_Instrument::factory( + $this->SurveyInfo['Test_name'], + $this->SurveyInfo['CommentID'], + null, + true + ); + } catch(\Exception $e) { + throw new Exception("Instrument not found", 405); + } + + $valid = $this->Instrument->directEntryValidation(); + + if ($valid === true) { + header("HTTP/1.0 200 OK"); + $DB = Database::singleton(); + $DB->update( + "participant_accounts", + array('Status' => "Complete"), + array('OneTimePassword' => $this->key) + ); + } else { + header("HTTP/1.0 400 Bad Request"); + echo json_encode($valid); + } + + } + + /** + * Run the current page, consists of initializing and then displaying the page + * + * @return null + */ + function run() + { + try { + $this->initialize(); + $this->handleRequest(); + } catch(Exception $e) { + $this->displayError($e); + } + } + + /** + * Loads the correct page and renders it to the user + * + * @return null + */ + function display() + { + $smarty = new Smarty_neurodb; + $smarty->assign($this->tpl_data); + $smarty->display('directentryreact.tpl'); + } + + /** + * Display an error page in the event of an exception. + * + * @param Exception $e The exception which was thrown by the code + * + * @return null + */ + function displayError($e) + { + switch($e->getCode()) + { + case 404: + header("HTTP/1.1 404 Not Found"); + break; + case 403: + header("HTTP/1.1 403 Forbidden"); + break; + } + + $this->tpl_data['workspace'] = $e->getMessage(); + $this->tpl_data['complete'] = false; + $smarty = new Smarty_neurodb; + $smarty->assign($this->tpl_data); + $smarty->display('directentry.tpl'); + + } +} + +if (!class_exists('UnitTestCase')) { + $Runner = new DirectDataEntryMainPage(); + $Runner->run(); +} +?> diff --git a/modules/survey_module/js/CheckInput.js b/modules/survey_module/js/CheckInput.js new file mode 100644 index 00000000000..c4b0aefee76 --- /dev/null +++ b/modules/survey_module/js/CheckInput.js @@ -0,0 +1,8 @@ +/** + * Check if the input is supported by the Browser + */ +function checkInput(type) { + var input = document.createElement("input"); + input.setAttribute("type", type); + return input.type == type; +} diff --git a/modules/survey_module/js/DirectEntry.js b/modules/survey_module/js/DirectEntry.js new file mode 100644 index 00000000000..f17656cbd5d --- /dev/null +++ b/modules/survey_module/js/DirectEntry.js @@ -0,0 +1,2 @@ +!function(modules){function __webpack_require__(moduleId){if(installedModules[moduleId])return installedModules[moduleId].exports;var module=installedModules[moduleId]={exports:{},id:moduleId,loaded:!1};return modules[moduleId].call(module.exports,module,module.exports,__webpack_require__),module.loaded=!0,module.exports}var installedModules={};return __webpack_require__.m=modules,__webpack_require__.c=installedModules,__webpack_require__.p="",__webpack_require__(0)}({0:function(module,exports,__webpack_require__){"use strict";function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _defineProperty(obj,key,value){return key in obj?Object.defineProperty(obj,key,{value:value,enumerable:!0,configurable:!0,writable:!0}):obj[key]=value,obj}function _classCallCheck(instance,Constructor){if(!(instance instanceof Constructor))throw new TypeError("Cannot call a class as a function")}function _possibleConstructorReturn(self,call){if(!self)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!call||"object"!=typeof call&&"function"!=typeof call?self:call}function _inherits(subClass,superClass){if("function"!=typeof superClass&&null!==superClass)throw new TypeError("Super expression must either be null or a function, not "+typeof superClass);subClass.prototype=Object.create(superClass&&superClass.prototype,{constructor:{value:subClass,enumerable:!1,writable:!0,configurable:!0}}),superClass&&(Object.setPrototypeOf?Object.setPrototypeOf(subClass,superClass):subClass.__proto__=superClass)}Object.defineProperty(exports,"__esModule",{value:!0});var _createClass=function(){function defineProperties(target,props){for(var i=0;i=0?React.createElement(_DirectEntryForm2.default,{elements:this.state.InstrumentJSON.Elements[this.state.page].Elements,values:this.state.values,updateAnswer:this.updateAnswer,errors:this.state.errors}):React.createElement(_DirectEntryForm2.default,{elements:this.state.InstrumentJSON.Elements,values:this.state.values,updateAnswer:this.updateAnswer,errors:this.state.errors}),buttons=this.state.page===this.state.InstrumentJSON.Elements.length?React.createElement("div",null,React.createElement("button",{type:"button",className:"btn btn-primary btn-lg",onClick:this.prevPage},"Prev"),React.createElement("button",{type:"button",className:"btn btn-primary btn-lg",onClick:this.submit},"Submit")):this.state.page===-1||0===this.state.page&&1===this.state.InstrumentJSON.Elements.length?React.createElement("button",{type:"button",className:"btn btn-primary btn-lg"},"Done"):0===this.state.page?React.createElement("button",{type:"button",className:"btn btn-primary btn-lg",onClick:this.nextPage},"Next"):this.state.page===this.state.InstrumentJSON.Elements.length-1?React.createElement("div",null,React.createElement("button",{type:"button",className:"btn btn-primary btn-lg",onClick:this.prevPage},"Prev"),React.createElement("button",{type:"button",className:"btn btn-primary btn-lg",onClick:this.nextPage},"Done")):React.createElement("div",null,React.createElement("button",{type:"button",className:"btn btn-primary btn-lg",onClick:this.prevPage},"Prev"),React.createElement("button",{type:"button",className:"btn btn-primary btn-lg",onClick:this.nextPage},"Next"));var style={width:this.state.completionStats.completed/this.state.completionStats.total*100+"%"};return React.createElement("div",null,React.createElement("nav",{className:"navbar navbar-default navbar-fixed-top"},React.createElement("span",_defineProperty({className:"h1"},"className","navbar-brand"),"LORIS")),React.createElement("div",{id:"page",className:"container-fluid"},DirectEntryFormElements,React.createElement("div",{className:"question-container col-xs-12 col-sm-10 col-sm-offset-1"},buttons)),React.createElement("div",{className:"navbar navbar-default navbar-fixed-bottom"},React.createElement("div",{className:"col-xs-5 footer-bar"},this.state.completionStats.completed," of ",this.state.completionStats.total," Answered"),React.createElement("div",{className:"col-xs-4 footer-bar"},React.createElement("div",{className:"progress"},React.createElement("div",{className:"progress-bar-info progress-bar-striped",role:"progressbar","aria-valuenow":"60","aria-valuemin":"0","aria-valuemax":"100",style:style}," ")))))}}]),DirectEntry}(React.Component),ReviewPage=function(_React$Component2){function ReviewPage(props){return _classCallCheck(this,ReviewPage),_possibleConstructorReturn(this,(ReviewPage.__proto__||Object.getPrototypeOf(ReviewPage)).call(this,props))}return _inherits(ReviewPage,_React$Component2),_createClass(ReviewPage,[{key:"render",value:function(){var questions=this.props.reviewData.questions.map(function(element){return console.log(element),React.createElement("tr",{className:"reviewPage"},React.createElement("td",null,element.question),React.createElement("td",null,element.response))});return React.createElement("div",{className:"question-container col-xs-12 col-sm-10 col-sm-offset-1"},React.createElement("h3",null,"Review You Submission"),React.createElement("table",{className:"table table-striped table-bordered"},React.createElement("tbody",null,questions)))}}]),ReviewPage}(React.Component);window.DirectEntry=DirectEntry,exports.default=DirectEntry},25:function(module,exports,__webpack_require__){"use strict";function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj}}function _classCallCheck(instance,Constructor){if(!(instance instanceof Constructor))throw new TypeError("Cannot call a class as a function")}function _possibleConstructorReturn(self,call){if(!self)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return!call||"object"!=typeof call&&"function"!=typeof call?self:call}function _inherits(subClass,superClass){if("function"!=typeof superClass&&null!==superClass)throw new TypeError("Super expression must either be null or a function, not "+typeof superClass);subClass.prototype=Object.create(superClass&&superClass.prototype,{constructor:{value:subClass,enumerable:!1,writable:!0,configurable:!0}}),superClass&&(Object.setPrototypeOf?Object.setPrototypeOf(subClass,superClass):subClass.__proto__=superClass)}Object.defineProperty(exports,"__esModule",{value:!0});var _createClass=function(){function defineProperties(target,props){for(var i=0;i { + // TODO display error to user + console.log(responseData); + }); + + + } + + setupPageValues(page) { + const pageElements = this.state.InstrumentJSON.Elements[page].Elements; + let pageValues = {}; + + for (let i = 0; i < pageElements.length; i++) { + const name = this.getElementName(pageElements[i]); + if(name instanceof Array) { + for(let j = 0; j < name.length; j++) { + if(name[j] in this.state.values) { + pageValues[name[j]] = this.state.values[name[j]]; + } + } + } else if(name in this.state.values) { + pageValues[name] = this.state.values[name]; + } + } + + this.setState({ + pageValues: pageValues + }); + } + + getElementName(element) { + let name; + if (element.Type === 'ElementGroup') { + name = []; + for(let i = 0; i < element.Elements.length; i++) { + name.push(this.getElementName(element.Elements[i])); + } + } else { + name = element.Name + } + + return name; + } + + nextPage() { + + let page = 0; + let finalPage = false; + if(this.state.page != 0) { + page = this.state.InstrumentJSON.Elements[this.state.page].Subtest; + } + + let data = { + "data" : this.state.pageValues, + "page" : page + }; + const that = this; + + if(this.state.page === this.state.InstrumentJSON.Elements.length - 1) { + data['FinalPage'] = true; + finalPage = true; + } + + $.ajax({ + url : this.state.api_url, + data : JSON.stringify(data), + type : 'PUT', + contentType : 'application/json', + success : function(result){ + const page = that.state.page + 1; + let InstrumentJSON; + let reviewPage; + + if(finalPage) { + InstrumentJSON = that.state.InstrumentJSON; + reviewPage = JSON.parse(result); + } else { + InstrumentJSON = JSON.parse(result); + } + + that.setState({ + page: page, + errors: {}, + InstrumentJSON: InstrumentJSON, + ReviewData: reviewPage + }); + + that.setupPageValues(page); + window.scrollTo(0,0); + }, + }).fail((responseData) => { + if(responseData.status === 400) { + const response = JSON.parse(responseData.responseText) + this.setState({ + errors: response + }); + swal({ + title: "Error", + text: "Please resolve page errors before continuing" + }, function(e){ + $('html, body').animate({ + scrollTop: $($(".questionError")[0]).offset().top - 100 + }, 100); + }); + } + }); + + + } + + prevPage() { + const page = this.state.page - 1; + + this.setState({ + page: page, + errors: {} + }); + this.setupPageValues(page); + + window.scrollTo(0,0); + } + + updateAnswer(fieldName, value) { + let data = {}; + data[fieldName] = value; + + $.ajax({ + url : this.state.api_url, + data : JSON.stringify(data), + type : 'PATCH', + contentType : 'application/json' + }); + + this.setState(function(state) { + let values = state.values; + let pageValues = state.pageValues; + let stats = state.completionStats; + + if(values[fieldName] == null || values[fieldName] == '') { + stats.completed = stats.completed + 1; + } else if (value == null || value == '') { + stats.completed = stats.completed - 1; + } + + values[fieldName] = value; + pageValues[fieldName] = value; + return { + values: values, + pageValues: pageValues, + completionStats: stats + } + }); + } + + submit() { + $.ajax({ + url : this.state.api_url, + type : 'POST', + contentType : 'application/json' + }); + } + + render() { + if(!this.state.InstrumentJSON.Elements){ + // Since the Instrument data is set when the component is + // mounted we want to display nothing until it has been set + return ( +
+ ); + } + let DirectEntryFormElements; + let buttons; + if (this.state.page === this.state.InstrumentJSON.Elements.length) { + DirectEntryFormElements = ( + + ); + } else if (this.state.page >= 0) { + DirectEntryFormElements = ( + + ); + } else { + DirectEntryFormElements = ( + + ); + } + + if (this.state.page === this.state.InstrumentJSON.Elements.length) { + buttons = ( +
+ + +
+ ); + } else if (this.state.page === -1 || (this.state.page === 0 && this.state.InstrumentJSON.Elements.length === 1)) { + buttons = ( + + ); + } else if (this.state.page === 0) { + buttons = ( + + ); + } else if (this.state.page === this.state.InstrumentJSON.Elements.length - 1) { + buttons = ( +
+ + +
+ ); + } else { + buttons = ( +
+ + +
+ ); + } + const style = { + width: this.state.completionStats.completed / this.state.completionStats.total * 100 + '%' + } + return ( +
+ +
+ {DirectEntryFormElements} +
+ {buttons} +
+
+
+
+ {this.state.completionStats.completed} of {this.state.completionStats.total} Answered +
+
+
+
+   +
+
+
+
+
+ ); + } +} + +class ReviewPage extends React.Component { + constructor(props) { + super(props); + } + + render() { + + let questions = this.props.reviewData.questions.map((element) => { + console.log(element); + return ( + + {element.question} + {element.response} + + ); + }); + + return ( +
+

Review You Submission

+ + + + {questions} + +
+
+ ) + } +} +/*eslint-enable */ + +window.DirectEntry = DirectEntry; + +export default DirectEntry; diff --git a/modules/survey_module/jsx/DirectEntryForm.js b/modules/survey_module/jsx/DirectEntryForm.js new file mode 100644 index 00000000000..520f7492f3b --- /dev/null +++ b/modules/survey_module/jsx/DirectEntryForm.js @@ -0,0 +1,410 @@ +/** + * This file contains React form components for Direct Data Entry + * + * @author Jordan Stirling (StiringApps ltd.) + * @version 0.0.1 + * + */ + +import GroupElement from './GroupElement.js'; +// import Markdown from './Markdown.js'; + +/*eslint-disable */ +/** + * THIS ELEMENT IS FOR DEVELOPMENT PURPOSES ONLY + */ +class NotImplement extends React.Component { + + constructor(props) { + super(props); + } + + render() { + return ( +
+ {this.props.element.Type} is not yet implemented +
+ ); + } +} + +class DirectEntryFormElement extends React.Component { + + constructor(props) { + super(props); + } + + render() { + let element; + let errorMessage; + let questionClass = 'question-container col-xs-12 col-sm-10 col-sm-offset-1'; + + switch(this.props.element.Type) { + case "select": + element = ( + + ); + break; + case "text": + element = ( + + ); + break; + case "date": + element = ( + + ); + break; + case "label": + element = ( + + ); + break; + case "header": + element = ( + + ); + break; + case "ElementGroup": + element = ( + + ); + break; + default: + element = ( + + ); + }; + + if (this.props.errors[this.props.element.Name]) { + questionClass += " questionError"; + errorMessage = ( +

+ * {this.props.errors[this.props.element.Name]} +

+ ); + } + + return ( +
+ {element} + {errorMessage} +
+ ); + } +} + +class Page extends React.Component { + constructor(props) { + super(props); + + } + + render() { + + const DirectEntryFormElements = this.props.elements.map((element) => { + return ( + + ); + }); + + return ( +
+ {DirectEntryFormElements} +
+ ); + + } +} + +class SelectElement extends React.Component { + + constructor(props) { + super(props); + + this.state = { + value: '' + }; + } + + onSelect(value) { + if (this.props.value !== value) { + this.props.updateAnswer(this.props.element.Name, value); + } else { + this.props.updateAnswer(this.props.element.Name, null); + } + } + + render() { + let options = []; + let optionLabel; + + for (var key in this.props.element.Options.Values) { + let checked; + if(key === '') { + continue; + } else if(key === this.props.value) { + checked = ( + + ); + } + optionLabel = String(this.props.element.Options.Values[key]); + options.push( +
+
+ +
+
+ +
+
+ ); + } + + const element = ( +
+ {options} +
+ ); + + let classInfo = 'col-xs-12 field_question'; + + if(this.props.error) { + classInfo += ' has-error'; + } + + let description = ''; + if (!!this.props.element.Description) { + description = ( +

+ +

+ ); + } + + return ( +
+ {description} + {element} +
+ ); + } +} + +class TextElement extends React.Component { + constructor(props) { + super(props); + + this.updateText = this.updateText.bind(this); + } + + updateText(e) { + this.props.updateAnswer(this.props.element.Name, e.target.value); + } + + render() { + let type; + let value = ''; + if(this.props.value) { + value = this.props.value; + } + if (this.props.element.Options.Type === 'small') { + type = ( + + ); + } else { + type = ( +