diff --git a/application/core/plugins/statFunctions/config.xml b/application/core/plugins/statFunctions/config.xml new file mode 100644 index 00000000000..847aa194350 --- /dev/null +++ b/application/core/plugins/statFunctions/config.xml @@ -0,0 +1,25 @@ + + + + statFunctions + plugin + 2019-09-19 + 2019-09-19 + Denis Chenu + https://www.limesurvey.org + 0.1.0 + GNU General Public License version 2 or later + +
  • statCountIf(QuestionCode.sgqa, value[, submitted = true])
  • +
  • statCount(QuestionCode.sgqa[, submitted = true])
  • + +]]>
    +
    + + + 4.0 + + + + +
    diff --git a/application/core/plugins/statFunctions/countFunctions.php b/application/core/plugins/statFunctions/countFunctions.php new file mode 100644 index 00000000000..df99fcd2d09 --- /dev/null +++ b/application/core/plugins/statFunctions/countFunctions.php @@ -0,0 +1,77 @@ + … see https://www.yiiframework.com/doc/api/1.1/CDbCriteria#compare-detail + * @param boolean $submitted (or not) response + * @return integer|string + */ + public static function statCountIf($qCode, $comparaison, $submitted = true) + { + $surveyId = LimeExpressionManager::getLEMsurveyId(); + if (!Survey::model()->findByPk($surveyId)->getIsActive()) { + return 0; + } + $questionCodeHelper = new \statFunctions\questionCodeHelper($surveyId); + $column = $questionCodeHelper->getColumnByQCode($qCode); + if (is_null($column)) { + if (Permission::model()->hasSurveyPermission($surveyId, 'surveycontent')) { // update ??? + return sprintf(gT("Invalid question code %s"), CHtml::encode($qCode)); + } + return ""; + } + $sQuotedColumn=Yii::app()->db->quoteColumnName($column); + $oCriteria = new CDbCriteria; + $oCriteria->condition= "$sQuotedColumn IS NOT NULL"; + if ($submitted) { + $oCriteria->addCondition("submitdate IS NOT NULL"); + } + $oCriteria->compare($sQuotedColumn, $comparaison); + return intval(SurveyDynamic::model($surveyId)->count($oCriteria)); + } + + /** + * Return the count of reponse on current Expression Manager survey equal to a specific value + * @param string $qCode : code of question, currently must be existing sgqa. Sample Q01.sgqa. + * @param boolean $submitted (or not) response + * @return integer|string + */ + public static function statCount($qCode, $submitted = true) + { + $surveyId = LimeExpressionManager::getLEMsurveyId(); + if (!Survey::model()->findByPk($surveyId)->getIsActive()) { + return 0; + } + $questionCodeHelper = new \statFunctions\questionCodeHelper($surveyId); + $column = $questionCodeHelper->getColumnByQCode($qCode); + if (is_null($column)) { + if (Permission::model()->hasSurveyPermission($surveyId, 'surveycontent')) { // update ??? + return sprintf(gT("Invalid question code %s"), CHtml::encode($qCode)); + } + return ""; + } + + $sQuotedColumn=Yii::app()->db->quoteColumnName($column); + $oCriteria = new CDbCriteria; + $oCriteria->condition= "$sQuotedColumn IS NOT NULL and $sQuotedColumn <> ''"; + if ($submitted) { + $oCriteria->addCondition("submitdate IS NOT NULL"); + } + return intval(SurveyDynamic::model($surveyId)->count($oCriteria)); + } +} diff --git a/application/core/plugins/statFunctions/questionCodeHelper.php b/application/core/plugins/statFunctions/questionCodeHelper.php new file mode 100644 index 00000000000..328d85db162 --- /dev/null +++ b/application/core/plugins/statFunctions/questionCodeHelper.php @@ -0,0 +1,46 @@ +surveyId = $surveyId; + /* Throw error if surveyid is invalid ? */ + } + + /** + * Check the survey + * @param string $qCode question SGQA + * @return string|null : the final column name, null if not found + */ + public function getColumnByQCode($qCode) + { + $availableColumns = SurveyDynamic::model($this->surveyId)->getAttributes(); + /* Sample : Q01.sgqa Q01_SQ01.sgqa */ + if (array_key_exists($qCode, $availableColumns)) { + return $qCode; + } + + /* @todo : allow "Q0" and "Q0_SQ0" … + * But without using LimeExpressionManager::ProcessString or LimeExpressionManager::getLEMqcode2sgqa + * Because break logic file + * Wait for OK to merge to start it … + */ + return null; + } +} diff --git a/application/core/plugins/statFunctions/statFunctions.php b/application/core/plugins/statFunctions/statFunctions.php new file mode 100644 index 00000000000..a4d500fdc00 --- /dev/null +++ b/application/core/plugins/statFunctions/statFunctions.php @@ -0,0 +1,56 @@ + + * @copyright 2019 Denis Chenu + * @license GPL version 3 + * @version 0.0.1 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +class statFunctions extends PluginBase +{ + protected static $description = 'Add some function in expression manager to get count from other responses'; + protected static $name = 'statCountFunctions'; + + public function init() + { + $this->subscribe('ExpressionManagerStart', 'newValidFunctions'); + } + + public function newValidFunctions() + { + Yii::setPathOfAlias(get_class($this), dirname(__FILE__)); + $newFunctions = array( + 'statCountIf' => array( + '\statFunctions\countFunctions::statCountIf', + null, // No javascript function : set as static function + $this->gT("Count the response done with value equal to a specific value"), // Description for admin + 'integer statCountIf(QuestionCode.sgqa, value[, submitted = true])', // Extra description + 'https://www.limesurvey.org', // Help url + 2, // Number of argument unsure it work here … , minimum 2, allow 3 + 3 + ), + 'statCount' => array( + '\statFunctions\countFunctions::statCount', + null, // No javascript function : set as static function + $this->gT("Count previous response done not empty"), // Description for admin + 'integer statCount(QuestionCode.sgqa[, submitted = true])', // Extra description + 'https://www.limesurvey.org', // Help url + 1, // Number of argument (time to make a good description of EM …) minimum 1, allow 2 + 2, + ), + ); + $this->getEvent()->append('functions', $newFunctions); + } +} diff --git a/application/helpers/expressions/em_core_helper.php b/application/helpers/expressions/em_core_helper.php index 3f68780445f..01cf8924224 100644 --- a/application/helpers/expressions/em_core_helper.php +++ b/application/helpers/expressions/em_core_helper.php @@ -1240,70 +1240,106 @@ public function GetJavaScriptEquivalentOfExpression() $tokens = $this->RDP_tokens; $stringParts = array(); $numTokens = count($tokens); + + /* Static function management */ + $bracket = 0; + $staticStringToParse = ""; for ($i = 0; $i < $numTokens; ++$i) { - $token = $tokens[$i]; - // When do these need to be quoted? + $token = $tokens[$i]; // When do these need to be quoted? + if(!empty($staticStringToParse)) { /* Currently inside a static function */ - switch ($token[2]) { - case 'DQ_STRING': - $stringParts[] = '"'.addcslashes($token[0], '\"').'"'; // htmlspecialchars($token[0],ENT_QUOTES,'UTF-8',false) . "'"; - break; - case 'SQ_STRING': - $stringParts[] = "'".addcslashes($token[0], "\'")."'"; // htmlspecialchars($token[0],ENT_QUOTES,'UTF-8',false) . "'"; - break; - case 'SGQA': - case 'WORD': - if ($i + 1 < $numTokens && $tokens[$i + 1][2] == 'LP') { - // then word is a function name - $funcInfo = $this->RDP_ValidFunctions[$token[0]]; - if ($funcInfo[1] == 'NA') { - return ''; // to indicate that this is trying to use a undefined function. Need more graceful solution - } - $stringParts[] = $funcInfo[1]; // the PHP function name - } elseif ($i + 1 < $numTokens && $tokens[$i + 1][2] == 'ASSIGN') { - $jsName = $this->GetVarAttribute($token[0], 'jsName', ''); - $stringParts[] = "document.getElementById('".$jsName."').value"; - if ($tokens[$i + 1][0] == '+=') { - // Javascript does concatenation unless both left and right side are numbers, so refactor the equation - $varName = $this->GetVarAttribute($token[0], 'varName', $token[0]); - $stringParts[] = " = LEMval('".$varName."') + "; - ++$i; - } - } else { - $jsName = $this->GetVarAttribute($token[0], 'jsName', ''); - $code = $this->GetVarAttribute($token[0], 'code', ''); - if ($jsName != '') { - $varName = $this->GetVarAttribute($token[0], 'varName', $token[0]); - $stringParts[] = "LEMval('".$varName."') "; + switch ($token[2]) { + case 'LP': + $staticStringToParse .= $token[0]; + $bracket ++; + break; + case 'RP': + $staticStringToParse .= $token[0]; + $bracket --; + break; + case 'DQ_STRING': + // A string inside double quote : add double quote again + $staticStringToParse .= '"'.$token[0].'"'; + break; + case 'SQ_STRING': + // A string inside single quote : add single quote again + $staticStringToParse .= "'".$token[0]."'"; + break; + default: + // This set whole string inside function as a static var : must document clearly. + $staticStringToParse .= $token[0]; + } + if($bracket == 0) { // Last close bracket : get the static final function and reset + //~ $staticString = LimeExpressionManager::ProcessStepString("{".$staticStringToParse."}",array(),3,true); + $staticString = $this->sProcessStringContainingExpressions("{".$staticStringToParse."}",0,3,1,-1,-1,true); // As static : no gseq,qseq etc … + $stringParts[] = $staticString; + $staticStringToParse = ""; + } + } else { + switch ($token[2]) { + case 'DQ_STRING': + $stringParts[] = '"'.addcslashes($token[0], '\"').'"'; // htmlspecialchars($token[0],ENT_QUOTES,'UTF-8',false) . "'"; + break; + case 'SQ_STRING': + $stringParts[] = "'".addcslashes($token[0], "\'")."'"; // htmlspecialchars($token[0],ENT_QUOTES,'UTF-8',false) . "'"; + break; + case 'SGQA': + case 'WORD': + if ($i + 1 < $numTokens && $tokens[$i + 1][2] == 'LP') { + // then word is a function name + $funcInfo = $this->RDP_ValidFunctions[$token[0]]; + if ($funcInfo[1] === null ) { + /* start a static function */ + $staticStringToParse = $token[0]; // The function name + $bracket = 0; // Reset bracket (again) + } else { + $stringParts[] = $funcInfo[1]; // the PHP function name + } + } elseif ($i + 1 < $numTokens && $tokens[$i + 1][2] == 'ASSIGN') { + $jsName = $this->GetVarAttribute($token[0], 'jsName', ''); + $stringParts[] = "document.getElementById('".$jsName."').value"; + if ($tokens[$i + 1][0] == '+=') { + // Javascript does concatenation unless both left and right side are numbers, so refactor the equation + $varName = $this->GetVarAttribute($token[0], 'varName', $token[0]); + $stringParts[] = " = LEMval('".$varName."') + "; + ++$i; + } } else { - $stringParts[] = "'".addcslashes($code, "'")."'"; + $jsName = $this->GetVarAttribute($token[0], 'jsName', ''); + $code = $this->GetVarAttribute($token[0], 'code', ''); + if ($jsName != '') { + $varName = $this->GetVarAttribute($token[0], 'varName', $token[0]); + $stringParts[] = "LEMval('".$varName."') "; + } else { + $stringParts[] = "'".addcslashes($code, "'")."'"; + } } - } - break; - case 'LP': - case 'RP': - $stringParts[] = $token[0]; - break; - case 'NUMBER': - $stringParts[] = is_numeric($token[0]) ? $token[0] : ("'".$token[0]."'"); - break; - case 'COMMA': - $stringParts[] = $token[0].' '; - break; - default: - // don't need to check type of $token[2] here since already handling SQ_STRING and DQ_STRING above - switch (strtolower($token[0])) { - case 'and': $stringParts[] = ' && '; break; - case 'or': $stringParts[] = ' || '; break; - case 'lt': $stringParts[] = ' < '; break; - case 'le': $stringParts[] = ' <= '; break; - case 'gt': $stringParts[] = ' > '; break; - case 'ge': $stringParts[] = ' >= '; break; - case 'eq': case '==': $stringParts[] = ' == '; break; - case 'ne': case '!=': $stringParts[] = ' != '; break; - default: $stringParts[] = ' '.$token[0].' '; break; - } - break; + break; + case 'LP': + case 'RP': + $stringParts[] = $token[0]; + break; + case 'NUMBER': + $stringParts[] = is_numeric($token[0]) ? $token[0] : ("'".$token[0]."'"); + break; + case 'COMMA': + $stringParts[] = $token[0].' '; + break; + default: + // don't need to check type of $token[2] here since already handling SQ_STRING and DQ_STRING above + switch (strtolower($token[0])) { + case 'and': $stringParts[] = ' && '; break; + case 'or': $stringParts[] = ' || '; break; + case 'lt': $stringParts[] = ' < '; break; + case 'le': $stringParts[] = ' <= '; break; + case 'gt': $stringParts[] = ' > '; break; + case 'ge': $stringParts[] = ' >= '; break; + case 'eq': case '==': $stringParts[] = ' == '; break; + case 'ne': case '!=': $stringParts[] = ' != '; break; + default: $stringParts[] = ' '.$token[0].' '; break; + } + break; + } } } // for each variable that does not have a default value, add clause to throw error if any of them are NA diff --git a/application/helpers/expressions/em_manager_helper.php b/application/helpers/expressions/em_manager_helper.php index 998527213c0..4ec4dc603b8 100644 --- a/application/helpers/expressions/em_manager_helper.php +++ b/application/helpers/expressions/em_manager_helper.php @@ -865,13 +865,13 @@ public static function RevertUpgradeConditionsToRelevance($surveyId=NULL, $qid=N * @return array **/ public static function getLEMqcode2sgqa($iSurveyId){ - $LEM =& LimeExpressionManager::singleton(); - $LEM->SetSurveyId($iSurveyId); // This update session only if needed - if( !in_array(Yii::app()->session['LEMlang'],Survey::model()->findByPk($iSurveyId)->getAllLanguages()) ) { - $LEM->SetEMLanguage(Survey::model()->findByPk($iSurveyId)->language);// Reset language only if needed - } - $LEM->setVariableAndTokenMappingsForExpressionManager($iSurveyId); - return $LEM->qcode2sgqa; + $LEM =& LimeExpressionManager::singleton(); + $LEM->SetSurveyId($iSurveyId); // This update session only if needed + if( !in_array(Yii::app()->session['LEMlang'],Survey::model()->findByPk($iSurveyId)->getAllLanguages()) ) { + $LEM->SetEMLanguage(Survey::model()->findByPk($iSurveyId)->language);// Reset language only if needed + } + $LEM->setVariableAndTokenMappingsForExpressionManager($iSurveyId); + return $LEM->qcode2sgqa; } /** @@ -8178,18 +8178,12 @@ public static function GetRelevanceAndTailoringJavaScript($bReturnArray=false) $neededCanonicalAttr[] = $LEM->varNameAttr[$nc]; } $neededAliases = array_unique($neededAliases); - if (count($neededAliases) > 0) - { - $jsParts[] = "var LEMalias2varName = {\n"; - $jsParts[] = implode(",\n",$neededAliases); - $jsParts[] = "};\n"; - } - if (count($neededCanonicalAttr) > 0) - { - $jsParts[] = "var LEMvarNameAttr = {\n"; - $jsParts[] = implode(",\n",$neededCanonicalAttr); - $jsParts[] = "};\n"; - } + $jsParts[] = "var LEMalias2varName = {\n"; + $jsParts[] = implode(",\n",$neededAliases); + $jsParts[] = "};\n"; + $jsParts[] = "var LEMvarNameAttr = {\n"; + $jsParts[] = implode(",\n",$neededCanonicalAttr); + $jsParts[] = "};\n"; } if (!$bReturnArray){ diff --git a/assets/packages/expressions/em_javascript.js b/assets/packages/expressions/em_javascript.js index 73581e3e290..c3af91a42f8 100644 --- a/assets/packages/expressions/em_javascript.js +++ b/assets/packages/expressions/em_javascript.js @@ -1386,6 +1386,8 @@ function LEManyNA() for (i=0;ifindByPk(self::$surveyId); - if(empty($survey)) { - throw new \Exception('getAllSurveyQuestions call with an invalid survey.'); - } + if(empty($survey)) { + throw new \Exception('getAllSurveyQuestions call with an invalid survey.'); + } $questions = []; foreach($survey->groups as $group) { $questionObjects = $group->questions; @@ -108,7 +108,7 @@ public function getAllSurveyQuestions() } } return $questions; - } + } /** * @return void @@ -133,4 +133,37 @@ public static function tearDownAfterClass() self::$testSurvey = null; } } + + /** + * Helper install and activate plugins by name + * @param string $pluginName + * @return void + */ + public static function installAndActivatePlugin($pluginName) + { + $plugin = \Plugin::model()->findByAttributes(array('name'=>$pluginName)); + if (!$plugin) { + $plugin = new \Plugin(); + $plugin->name = $pluginName; + $plugin->active = 1; + $plugin->save(); + } else { + $plugin->active = 1; + $plugin->save(); + } + } + + /** + * Helper dactivate plugins by name + * @param string $pluginName + * @return void + */ + public static function deActivatePlugin($pluginName) + { + $plugin = \Plugin::model()->findByAttributes(array('name'=>$pluginName)); + if ($plugin) { + $plugin->active = 0; + $plugin->save(); + } + } } diff --git a/tests/data/surveys/survey_archive_statCountFunctionsTest.lsa b/tests/data/surveys/survey_archive_statCountFunctionsTest.lsa new file mode 100644 index 00000000000..c80f7240a23 Binary files /dev/null and b/tests/data/surveys/survey_archive_statCountFunctionsTest.lsa differ diff --git a/tests/functional/acceptance/15246-fixed-em-function/FixedFunctionExpressionPluginTest.php b/tests/functional/acceptance/15246-fixed-em-function/FixedFunctionExpressionPluginTest.php new file mode 100644 index 00000000000..0f5c97810f4 --- /dev/null +++ b/tests/functional/acceptance/15246-fixed-em-function/FixedFunctionExpressionPluginTest.php @@ -0,0 +1,124 @@ +getAllSurveyQuestions(); + $urlMan = \Yii::app()->urlManager; + $urlMan->setBaseUrl('http://' . self::$domain . '/index.php'); + $url = $urlMan->createUrl( + 'survey/index', + [ + 'sid' => self::$surveyId, + 'token' => 'tokenTest', + 'newtest' => "Y", + ] + ); + try { + self::$webDriver->get($url); + /* 1st page */ + $submit = self::$webDriver->findElement(WebDriverBy::id('ls-button-submit')); + $submit->click(); + sleep(1); // Needed ? + /** Simple fixed value check **/ + $textToCompare = self::$webDriver->findElement(WebDriverBy::id('statCountQ00'))->getText(); + $this->assertEquals($textToCompare, "3", 'statCount(self.sgqa) usage broken : «' . $textToCompare ."» vs «3»"); + $textToCompare = self::$webDriver->findElement(WebDriverBy::id('statCountQ01'))->getText(); + $this->assertEquals($textToCompare, "3", 'statCount(Q01.sgqa) usage broken : «' . $textToCompare ."» vs «3»"); + /** Relevance (and update) check **/ + $this->assertFalse( + self::$webDriver->findElement(WebDriverBy::id('question'.$questions['Q01']->qid))->isDisplayed(), + "Q01 is not hidden by relevance" + ); + $sgqa = self::$surveyId."X".$questions['Q00']->gid."X".$questions['Q00']->qid; + $Input = self::$webDriver->findElement(WebDriverBy::id('answer' . $sgqa )); + $Input->sendKeys('10'); + $this->assertTrue( + self::$webDriver->findElement(WebDriverBy::id('question'.$questions['Q01']->qid))->isDisplayed(), + "Q01 is not shown by relevance after update Q00" + ); + /** Submitted VS not submitted **/ + $textToCompare = self::$webDriver->findElement(WebDriverBy::id('submitted'))->getText(); + $this->assertEquals( + $textToCompare, + "3", + 'statCount(Q01.sgqa) usage broken in Q01: «' . $textToCompare ."» vs «3»" + ); + $textToCompare = self::$webDriver->findElement(WebDriverBy::id('notSubmitted'))->getText(); + $this->assertEquals( + $textToCompare, + "6", + 'statCount(Q01.sgqa) usage broken in Q01: «' . $textToCompare ."» vs «6»" + ); + /* 2nd page */ + $submit = self::$webDriver->findElement(WebDriverBy::id('ls-button-submit')); + $submit->click(); + sleep(1); // Needed ? + /** Relevance on subquestion **/ + $sgqa = self::$surveyId."X".$questions['Q03']->gid."X".$questions['Q03']->qid; + // Line to be relevant + $lineRelevance = self::$webDriver->findElements( + WebDriverBy::cssSelector("#javatbd".$sgqa."SQ001.ls-irrelevant") + ); + $this->assertCount(0, $lineRelevance, 'Relevance is broken : SQ001 is irrelevant.'); + // Line to be irrelevant + $lineRelevance = self::$webDriver->findElements( + WebDriverBy::cssSelector("#javatbd".$sgqa."SQ003.ls-irrelevant") + ); + $this->assertCount(1, $lineRelevance, 'Relevance is broken : SQ003 is relevant.'); + /** Text of subquestion **/ + $textToCompare = self::$webDriver->findElement(WebDriverBy::id('answertext'.$sgqa.'SQ001'))->getText(); + $this->assertEquals( + $textToCompare, + "Event #1 (still 7 places)", + 'Text on sub-questions broken «' . $textToCompare ."» vs «Event #1 (still 7 places)»" + ); + + + } catch (\Exception $e) { + $filename = __CLASS__ ."_". __FUNCTION__; + self::$testHelper->takeScreenshot(self::$webDriver,$filename); + $this->assertFalse( + true, + 'Url: ' . $url . PHP_EOL . + 'Screenshot ' .$filename . PHP_EOL . $e->getMessage() + ); + } + } + + /** + * @inheritdoc + * @todo Deactivate and uninstall plugins ? + */ + public static function tearDownAfterClass() + { + self::deActivatePlugin('statFunctions'); + parent::tearDownAfterClass(); + } + +}