diff --git a/LEAF_Request_Portal/ajaxJSON.php b/LEAF_Request_Portal/ajaxJSON.php index 00ded8753..16454291a 100644 --- a/LEAF_Request_Portal/ajaxJSON.php +++ b/LEAF_Request_Portal/ajaxJSON.php @@ -32,7 +32,7 @@ // this method does not exist in Form class // echo $form->getProgressJSON($_GET['recordID']); // but this one does - echo $form->getProgress($_GET['recordID']); + echo $form->getProgress((int)$_GET['recordID']); break; case 'getrecentactions': diff --git a/LEAF_Request_Portal/api/controllers/FormController.php b/LEAF_Request_Portal/api/controllers/FormController.php index 36c35ef50..4c862b198 100644 --- a/LEAF_Request_Portal/api/controllers/FormController.php +++ b/LEAF_Request_Portal/api/controllers/FormController.php @@ -121,7 +121,7 @@ public function get($act) }); $this->index['GET']->register('form/[digit]/progress', function ($args) use ($form) { - $return = $form->getProgress($args[0]); + $return = $form->getProgress((int)$args[0]); return $return; }); diff --git a/LEAF_Request_Portal/js/form.js b/LEAF_Request_Portal/js/form.js index 2da387910..5d37d4604 100644 --- a/LEAF_Request_Portal/js/form.js +++ b/LEAF_Request_Portal/js/form.js @@ -227,20 +227,28 @@ var LeafForm = function (containerID) { /** cross walk end */ let childRequiredValidators = {}; + //store required validators for the controlled question and any subchildren on main entry and modals const handleChildValidators = (childID) => { - if (!childRequiredValidators[childID]) { - childRequiredValidators[childID] = { - validator: formRequired[`id${childID}`]?.setRequired, - }; - } - //reset the validator, if there is one, from the stored value - if ( - childRequiredValidators[childID].validator !== undefined && - dialog !== null - ) { - dialog.requirements[childID] = - childRequiredValidators[childID].validator; - } + let arrValidatorIDs = [ childID ]; + const arrSubchildren = Array.from( + document.querySelectorAll(`.response.blockIndicator_${childID} div.response[class*="blockIndicator_"]`) + ); + arrSubchildren.forEach(element => { + const id = +element.className.match(/(\d+)$/)?.[0]; + if(id > 0) { + arrValidatorIDs.push(id); + } + }); + arrValidatorIDs.forEach(id => { + if (!childRequiredValidators[id]) { + childRequiredValidators[id] = { + validator: formRequired[`id${id}`]?.setRequired, + }; + } + if (childRequiredValidators[id].validator !== undefined && dialog !== null) { + dialog.requirements[id] = childRequiredValidators[id].validator; + } + }); }; //validator ref for required question in a hidden state const hideShowValidator = function () { @@ -361,32 +369,61 @@ var LeafForm = function (containerID) { return sanitize(val).trim(); }; - /* clear out potential entries and set validator for hidden questions */ - const clearValues = (childFormat = "", childIndID = 0) => { - $("#" + childIndID).val(""); //clears most formats - $(`input[id^="${childIndID}_"]`).prop("checked", false); //radio and checkbox(es) formats - $(`input[id^="${childIndID}_radio0"]`).prop("checked", true); - - $(`#grid_${childIndID}_1_input tbody td`) //grid table data - .each(function () { - if ($("textarea", this).length) { - $("textarea", this).val(''); - } else if ($("select", this).length) { - $("select", this).val(''); - } else if ($("input", this).length) { - $("input", this).val(''); + /*hide the question and any subquestions. clear out potential entries and set validator for hidden questions */ + const clearValues = (childIndID = 0) => { + const arrSubchildren = Array.from( + document.querySelectorAll(`.response.blockIndicator_${childIndID} div.response[class*="blockIndicator_"]`) + ); + + //parse the IDs of any additional subquestions + let arrChildAndSubquestionIDs = [ childIndID ]; + arrSubchildren.forEach(element => { + const id = +element.className.match(/(\d+)$/)?.[0]; + if(id > 0) { + arrChildAndSubquestionIDs.push(id); } }); - if (childFormat === "multiselect") { - clearMultiSelectChild($("#" + childIndID), childIndID); - } - if ( - childRequiredValidators[childIndID].validator !== undefined && - dialog !== null - ) { - dialog.requirements[childIndID] = hideShowValidator; - } + arrChildAndSubquestionIDs.forEach(id => { + //clear values for questions in a hidden state. + $("#" + id).val(""); //most formats + $(`input[id^="${id}_"]`).prop("checked", false); //radio and checkbox(es) formats + + $(`#grid_${id}_1_input tbody td`) //grid table data + .each(function () { + if ($("textarea", this).length) { + $("textarea", this).val(''); + } else if ($("select", this).length) { + $("select", this).val(''); + } else if ($("input", this).length) { + $("input", this).val(''); + } + }); + + const isMultiselectQuestion = document.querySelector(`select[id="${id}"][multiple]`) !== null; + if (isMultiselectQuestion) { + clearMultiSelectChild($("#" + id), id); + } + + const isRadioQuestion = document.querySelector(`input[id^="${id}_radio"]`) !== null; + if(isRadioQuestion) { + const radioEmpty = $(`input[id^="${id}_radio0"]`); //need to add hidden empty input to clear radio + if (radioEmpty.length === 0) { + $(`div.response.blockIndicator_${id}`).prepend( + `` + ); + } + $(`input[id^="${id}_radio0"]`).prop("checked", true); + } + + //if the question is required, use the alternate validator + if ( + childRequiredValidators[id].validator !== undefined && + dialog !== null + ) { + dialog.requirements[id] = hideShowValidator; + } + }); }; /** @@ -526,7 +563,7 @@ var LeafForm = function (containerID) { switch (co) { case "hide": if (hideShowConditionMet === true) { - clearValues(childFormat, childID); + clearValues(childID); elChildResponse.classList.add('response-hidden'); elsChild.hide(); elsChild.attr('aria-hidden', true); @@ -542,7 +579,7 @@ var LeafForm = function (containerID) { elsChild.removeAttr('aria-hidden'); elsChild.show(); } else { - clearValues(childFormat, childID); + clearValues(childID); elChildResponse.classList.add('response-hidden'); elsChild.hide(); elsChild.attr('aria-hidden', true); @@ -611,7 +648,7 @@ var LeafForm = function (containerID) { setTimeout(() => { const closestHidden = elChildResponse.closest('.response-hidden'); if (closestHidden !== null) { - clearValues(childFormat, childID); + clearValues(childID); } elChildInput.trigger("change"); diff --git a/LEAF_Request_Portal/sources/Form.php b/LEAF_Request_Portal/sources/Form.php index 4945a1f5d..d92116efd 100644 --- a/LEAF_Request_Portal/sources/Form.php +++ b/LEAF_Request_Portal/sources/Form.php @@ -1509,207 +1509,260 @@ public function doSubmit(int $recordID): array return $return_value; } -/** - * Get the progress percentage (as integer), accounting for conditinally hidden questions - * @param int $recordID - * @return int Percent completed + /** + * Keep track of number of required questions, number visible, and form branch visibility state + * + * @param array dataTable (ref) lookup table of form data. Used to check if question is answered and for conditional logic assessment + * @param array formNode form question with potential substructure + * @param bool parentOrSelfHidden is question and children in hidden state + * @param int required_visible (ref) number of required questions that are visible + * @param int required_answered (ref) number of required questions that are answered + * @param int required_total (ref) total number of required questions found during checking + * @param int res_max_required total number of required questions on the form */ - public function getProgress($recordID) + private function count_required( + array &$dataTable, + array $formNode, + bool $parentOrSelfHidden = false, + int &$required_visible, + int &$required_answered, + int &$required_total, + int $res_max_required): void { - $returnValue = 0; - - $vars = array(':recordID' => (int)$recordID); - //get all the catIDs associated with this record and whether the forms are enabled or submitted - $resRecordInfoEachForm = $this->db->prepared_query('SELECT recordID, categoryID, `count`, submitted FROM records - LEFT JOIN category_count USING (recordID) - WHERE recordID=:recordID', $vars); - $isSubmitted = false; - foreach ($resRecordInfoEachForm as $request) { - if ($request['submitted'] > 0) { - $isSubmitted = true; - break; - } - } - if ($isSubmitted) { - $returnValue = 100; + if((int)$formNode['required'] === 1) { + $required_total += 1; //keep track to skip calls once all required questions are found. + } + //don't care about any of this if the question is in a hidden state + if($parentOrSelfHidden === false) { + //Check for conditions and if the state is hidden. + $format = trim(strtolower($formNode['format'])); + if(!empty($formNode['conditions']) && $formNode['conditions'] !== 'null') { + $conditions = json_decode(strip_tags($formNode['conditions'])); + $multiChoiceParentFormats = array('multiselect', 'checkboxes'); + $singleChoiceParentFormats = array('radio', 'dropdown', 'number', 'currency'); + $conditionMet = false; + foreach ($conditions as $c) { + if ($c->childFormat === $format && + (strtolower($c->selectedOutcome) === 'hide' || strtolower($c->selectedOutcome) === 'show')) { + + $parentFormat = $c->parentFormat; + $conditionParentValue = preg_split('/\R/', $c->selectedParentValue) ?? []; + $currentParentDataValue = preg_replace('/'/', ''', $dataTable[$c->parentIndID] ?? ''); + /* if ($parentFormat === 'checkbox') { //single checkbox ifthen is either checked or not checked (pending parent checkbox) + $currentParentDataValue = !empty($currentParentDataValue) && $currentParentDataValue !== 'no' ? '1' : '0'; + } */ + if (in_array($parentFormat, $multiChoiceParentFormats)) { + $currentParentDataValue = @unserialize($currentParentDataValue) === false ? + array($currentParentDataValue) : unserialize($currentParentDataValue); + } else { + $currentParentDataValue = array($currentParentDataValue); + } - } else { - //use recordInfo about forms associated with the recordID to get the total number of required questions on the request - $allRequiredIndicators = $this->db->prepared_query("SELECT categoryID, indicatorID, `format`, conditions FROM indicators WHERE required=1 AND disabled = 0", array()); - $categories = array(); - foreach($resRecordInfoEachForm as $form) { - if((int)$form['count'] === 1) { - $categories[] = $form['categoryID']; - } - } - $resRequestRequired = array(); - foreach ($allRequiredIndicators as $indicator) { - if(in_array($indicator['categoryID'], $categories)) { - $resRequestRequired[] = $indicator; + $operator = $c->selectedOp; + switch ($operator) { + case '==': + case '!=': + if (in_array($parentFormat, $multiChoiceParentFormats)) { + //true if the current data value includes any of the condition values + foreach ($currentParentDataValue as $v) { + if (in_array($v, $conditionParentValue)) { + $conditionMet = true; + break; + } + } + } else if (in_array($parentFormat, $singleChoiceParentFormats) && $currentParentDataValue[0] === $conditionParentValue[0]) { + $conditionMet = true; + } + if($operator === "!=") { + $conditionMet = !$conditionMet; + } + break; + case 'gt': + case 'gte': + case 'lt': + case 'lte': + $arrNumVals = array(); + $arrNumComp = array(); + foreach($currentParentDataValue as $v) { + if(is_numeric($v)) { + $arrNumVals[] = (float) $v; + } + } + foreach($conditionParentValue as $cval) { + if(is_numeric($cval)) { + $arrNumComp[] = (float) $cval; + } + } + $useOrEqual = str_contains($operator, 'e'); + $useGreaterThan = str_contains($operator, 'g'); + $lenValues = count(array_values($arrNumVals)); + $lenCompare = count(array_values($arrNumComp)); + if($lenCompare > 0) { + for ($i = 0; $i < $lenValues; $i++) { + $currVal = $arrNumVals[$i]; + if($useGreaterThan === true) { + $conditionMet = $useOrEqual === true ? $currVal >= max($arrNumComp) : $currVal > max($arrNumComp); + } else { + $conditionMet = $useOrEqual === true ? $currVal <= min($arrNumComp) : $currVal < min($arrNumComp); + } + if($conditionMet === true) { + break; + } + } + } + break; + default: + break; + } + } + //if in hidden state, set parenthidden to true and break out of condition checking. + if (($conditionMet === false && strtolower($c->selectedOutcome) === 'show') || + ($conditionMet === true && strtolower($c->selectedOutcome) === 'hide')) { + $parentOrSelfHidden = true; + break; + } } + unset($conditions); } - $countRequestRequired = count($resRequestRequired); - //if there are no required questions we are done - if($countRequestRequired === 0) { - $returnValue = 100; - } else { - //get indicatorID, format, data, and required for all completed questions for non-disabled indicators - $resCompleted = $this->db->prepared_query('SELECT indicatorID, `format`, `data`, `required` FROM `data` LEFT JOIN indicators - USING (indicatorID) - WHERE recordID=:recordID - AND indicators.disabled = 0 - AND data != "" - AND data IS NOT NULL', $vars); - - //used to count the number of required completions and organize completed data for possible condition checks - $resCountCompletedRequired = 0; - $resCompletedIndIDs = array(); - - foreach ($resCompleted as $entry) { - $arrData = @unserialize($entry['data']) === false ? array($entry['data']) : unserialize($entry['data']); - $entryFormat = trim(explode(PHP_EOL, $entry['format'])[0]); - - /*Edgecases: Checkbox and checkboxes format entries save the value 'no' if their corresponding input checkbox is unchecked. - Verifying that entries for these formats do not consist solely of 'no' values prevents a miscount that can occur if - they are later updated to be required, since not having empty values is no longer confirmation that they are answered.*/ - $checkBoxesAllNo = strtolower($entryFormat) === 'checkbox' || strtolower($entryFormat) === 'checkboxes'; - if ($checkBoxesAllNo === true) { - foreach ($arrData as $ele) { - if ($ele !== 'no') { - $checkBoxesAllNo = false; - break; - } - } + //if not in hidden state and required: increment required total. Check for answer and increment answered total if answered. + if(!$parentOrSelfHidden && (int)$formNode['required'] === 1) { + $required_visible += 1; + $answered = false; + $val = $formNode['value']; + if(isset($val) && $val !== '') { + /*value property is already processed based on format and could be string or array. + Convert to array for more consistent comparison and to simplify checkbox(es) and grid formats.*/ + $valType = gettype($val); + + $arrVal = array(); + if($valType === 'array') { + $arrVal = $val; + } elseif ($valType === 'string') { + $val = trim($val); + $arrVal = @unserialize($val) === false ? array($val) : unserialize($val); } - /*Grid formats are likewise not necessarily answered if they are not empty strings. Unanswered grid cells are empty */ - $gridContainsEmptyValue = false; - if (strtolower($entryFormat) === 'grid' && isset($arrData['cells'])) { - $arrRowEntries = $arrData['cells']; - foreach ($arrRowEntries as $row) { - if (in_array("", $row)) { - $gridContainsEmptyValue = true; - break; + //these formats can have values despite being unanswered ('no', or serialized data about the entry) + $specialFormat = $format === 'checkbox' || $format === 'checkboxes' || $format === 'grid'; + if ($specialFormat === true) { + if($format === 'grid') { + $hasInput = isset($arrVal['cells']); + $gridContainsEmptyValue = !$hasInput; + if($hasInput) { + $arrRowEntries = $arrVal['cells']; + foreach ($arrRowEntries as $row) { + if (in_array("", $row)) { + $gridContainsEmptyValue = true; + break; + } + } } - } - } - if ($checkBoxesAllNo === false && $gridContainsEmptyValue === false) { - if ((int) $entry['required'] === 1) { - $resCountCompletedRequired++; - if ($resCountCompletedRequired === $countRequestRequired) { - break; + $answered = !$gridContainsEmptyValue; + + } else { + //checkbox(es) - only one needed + foreach($arrVal as $ele) { + if ($ele !== '' && $ele !== 'no') { + $answered = true; + break; + } } } - //save the data entry (whether required or not) in case it is needed for condition checking - $resCompletedIndIDs[(int) $entry['indicatorID']] = $entry['data']; + + //any other format, confirm the element is not an empty string + } else { + $answered = $arrVal[0] !== ''; } } - if ($resCountCompletedRequired === $countRequestRequired) { - $returnValue = 100; + if($answered === true) { + $required_answered += 1; + } + } + } + //progress tree depth if required total is not at max. + if(isset($formNode['child'])) { + foreach($formNode['child'] as $child) { + if($required_total < $res_max_required) { + $this->count_required( + $dataTable, + $child, + $parentOrSelfHidden, + $required_visible, + $required_answered, + $required_total, + $res_max_required + ); + } + } + } + } - } else { - //finally, check if there are conditions that result in required questions being in a hidden state, and adjust count and percentage - $multiChoiceParentFormats = array('multiselect', 'checkboxes'); - $singleChoiceParentFormats = array('radio', 'dropdown', 'number', 'currency'); + /** + * Get the progress percentage (as integer), accounting for conditinally hidden questions + * @param int $recordID + * @return int Percent completed + */ + public function getProgress(int $recordID): int + { + $subSQL = 'SELECT submitted, categoryID FROM records + LEFT JOIN category_count USING (recordID) + WHERE recordID=:recordID'; + $subVars = array(':recordID' => (int)$recordID); - foreach ($resRequestRequired as $ind) { - //if question is not complete, and there are conditions (conditions could potentially have the string null due to a past import issue) ... - if (!in_array((int) $ind['indicatorID'], array_keys($resCompletedIndIDs)) && !empty($ind['conditions']) && $ind['conditions'] !== 'null') { + $resRecordInfoEachForm = $this->db->prepared_query($subSQL, $subVars); - $conditions = json_decode(strip_tags($ind['conditions'])); - $currFormat = preg_split('/\R/', $ind['format'])[0] ?? ''; + //Check if submitted, return 100 if it is. Get catIDs for otherwise. + $categoryIDs = array(); + foreach ($resRecordInfoEachForm as $request) { + $categoryIDs[] = $request['categoryID']; + if ($request['submitted'] > 0) { + return 100; + } + } - $conditionMet = false; - foreach ($conditions as $c) { - if ($c->childFormat === $currFormat //if current format and condition format match - && (strtolower($c->selectedOutcome) === 'hide' || strtolower($c->selectedOutcome) === 'show')) { //and outcome is hide or show + $maxRequiredSQL = "SELECT `indicatorID` FROM `indicators` + WHERE `required`=1 AND `disabled`=0 AND FIND_IN_SET(categoryID, :categoryIDs)"; + $maxRequiredVars = array( + ":categoryIDs" => implode(",", $categoryIDs) + ); + $resMaxRequired = count($this->db->prepared_query($maxRequiredSQL, $maxRequiredVars)); - $parentFormat = $c->parentFormat; - $conditionParentValue = preg_split('/\R/', $c->selectedParentValue) ?? []; - //if parent data does not exist, use an empty string, since potential 'not' comparisons would need this. - $currentParentDataValue = preg_replace('/'/', ''', $resCompletedIndIDs[(int) $c->parentIndID] ?? ''); + //Check max count. Return 100 if there are none. Otherwise use this count for total checking. + if ($resMaxRequired === 0) { + return 100; + } - if (in_array($parentFormat, $multiChoiceParentFormats)) { - $currentParentDataValue = @unserialize($currentParentDataValue) === false ? array($currentParentDataValue) : unserialize($currentParentDataValue); - } else { - $currentParentDataValue = array($currentParentDataValue); - } + $dataSQL = "SELECT `indicatorID`, `format`, TRIM(`data`) as `data` FROM `data` + LEFT JOIN indicators USING (indicatorID) + WHERE recordID=:recordID + AND indicators.disabled = 0 + AND TRIM(`data`) != ''"; + $dataVars = array(':recordID' => (int)$recordID); + $resData = $this->db->prepared_query($dataSQL, $dataVars); - $operator = $c->selectedOp; - switch ($operator) { - case '==': - case '!=': - if (in_array($parentFormat, $multiChoiceParentFormats)) { - //true if the current data value includes any of the condition values - foreach ($currentParentDataValue as $v) { - if (in_array($v, $conditionParentValue)) { - $conditionMet = true; - break; - } - } - } else if (in_array($parentFormat, $singleChoiceParentFormats) && $currentParentDataValue[0] === $conditionParentValue[0]) { - $conditionMet = true; - } - if($operator === "!=") { - $conditionMet = !$conditionMet; - } - break; - case 'gt': - case 'gte': - case 'lt': - case 'lte': - $arrNumVals = array(); - $arrNumComp = array(); - foreach($currentParentDataValue as $v) { - if(is_numeric($v)) { - $arrNumVals[] = (float) $v; - } - } - foreach($conditionParentValue as $cval) { - if(is_numeric($cval)) { - $arrNumComp[] = (float) $cval; - } - } - $useOrEqual = str_contains($operator, 'e'); - $useGreaterThan = str_contains($operator, 'g'); - $lenValues = count(array_values($arrNumVals)); - $lenCompare = count(array_values($arrNumComp)); - if($lenCompare > 0) { - for ($i = 0; $i < $lenValues; $i++) { - $currVal = $arrNumVals[$i]; - if($useGreaterThan === true) { - $conditionMet = $useOrEqual === true ? $currVal >= max($arrNumComp) : $currVal > max($arrNumComp); - } else { - $conditionMet = $useOrEqual === true ? $currVal <= min($arrNumComp) : $currVal < min($arrNumComp); - } - if($conditionMet === true) { - break; - } - } - } - break; - default: - break; - } - } - //if the question is not being shown due to its conditions, do not count it as a required question - if (($conditionMet === false && strtolower($c->selectedOutcome) === 'show') || - ($conditionMet === true && strtolower($c->selectedOutcome) === 'hide')) { - $countRequestRequired--; - break; - } - } - } - } - if ($countRequestRequired === 0) { - $returnValue = 100; - } else { - $returnValue = round(100 * ($resCountCompletedRequired / $countRequestRequired)); - } - } + $dataTable = array(); + foreach($resData as $d) { + $dataTable[$d['indicatorID']] = $d['data']; + } + + $requiredVisible = 0; + $requiredAnswered = 0; + $requiredTotal = 0; + + $fullForm = $this->getFullForm($recordID); + foreach($fullForm as $page) { + if($requiredTotal < $resMaxRequired) { + $this->count_required($dataTable, $page, false, $requiredVisible, $requiredAnswered, $requiredTotal, $resMaxRequired); } } + + $returnValue = 0; + if ($requiredVisible === 0) { + $returnValue = 100; + } else { + $returnValue = round(100 * ($requiredAnswered / $requiredVisible)); + } return $returnValue; }