From 3a25e806c66c8ad79476fa405055b6f71e14b37b Mon Sep 17 00:00:00 2001 From: Patrick Donelan Date: Sat, 23 May 2009 09:54:03 +0000 Subject: [PATCH] Survey bug fixes Fixed bugs in the handling of logical sections, creating of responses and counting of responses. Added in a bunch of new tests. Jslinting of all survye js files --- lib/WebGUI/Asset/Wobject/Survey.pm | 299 ++++++++----- .../Asset/Wobject/Survey/ResponseJSON.pm | 401 +++++++++--------- lib/WebGUI/Asset/Wobject/Survey/SurveyJSON.pm | 6 +- lib/WebGUI/Asset/Wobject/Survey/Test.pm | 50 ++- t/Asset/Wobject/Survey/ResponseJSON.t | 214 +++++++--- t/Asset/Wobject/Survey/SurveyJSON.t | 2 +- t/Asset/Wobject/Survey/Test.t | 32 +- www/extras/wobject/Survey/administersurvey.js | 103 +++-- .../wobject/Survey/administersurvey/comm.js | 14 +- www/extras/wobject/Survey/editsurvey.js | 22 +- www/extras/wobject/Survey/editsurvey/comm.js | 27 +- .../wobject/Survey/editsurvey/object.js | 9 +- 12 files changed, 703 insertions(+), 476 deletions(-) diff --git a/lib/WebGUI/Asset/Wobject/Survey.pm b/lib/WebGUI/Asset/Wobject/Survey.pm index cbbe6b944..2e61cf788 100644 --- a/lib/WebGUI/Asset/Wobject/Survey.pm +++ b/lib/WebGUI/Asset/Wobject/Survey.pm @@ -662,12 +662,13 @@ sub getAdminConsole { my $self = shift; my $ac = $self->SUPER::getAdminConsole; my $i18n = WebGUI::International->new($self->session, "Asset_Survey"); - my $edit = WebGUI::International->new($self->session, "WebGUI")->get(575); - $ac->addSubmenuItem($self->session->url->page("func=edit"), $edit); - $ac->addSubmenuItem($self->session->url->page("func=editSurvey"), "$edit Survey"); - $ac->addSubmenuItem($self->session->url->page("func=graph"), $i18n->get('survey visualization')); + $ac->addSubmenuItem($self->session->url->page("func=edit"), WebGUI::International->new($self->session, "WebGUI")->get(575)); + $ac->addSubmenuItem($self->session->url->page("func=editSurvey"), $i18n->get('edit survey')); + $ac->addSubmenuItem($self->session->url->page("func=takeSurvey"), $i18n->get('take survey')); + $ac->addSubmenuItem($self->session->url->page("func=graph"), $i18n->get('visualize')); $ac->addSubmenuItem($self->session->url->page("func=editTestSuite"), $i18n->get("test suite")); $ac->addSubmenuItem($self->session->url->page("func=runTests"), $i18n->get("run all tests")); + $ac->addSubmenuItem($self->session->url->page("func=runTests;format=tap"), $i18n->get("run all tests") . " (TAP)"); return $ac; } @@ -1297,13 +1298,18 @@ A template to use. Defaults to this Survey's feedbackTemplateId sub getResponseDetails { my $self = shift; - my %opts = validate(@_, { userId => 0, responseId => 0, templateId => 0 } ); + my %opts = validate(@_, { userId => 0, responseId => 0, templateId => 0, isComplete => 0} ); my $responseId = $opts{responseId}; my $userId = $opts{userId} || $self->session->user->userId; my $templateId = $opts{templateId} || $self->get('feedbackTemplateId') || 'nWNVoMLrMo059mDRmfOp9g'; + my $isComplete = $opts{isComplete}; + + # By default, get most recent completed response with any complete code (e.g. isComplete > 0) + # This includes abnormal finishes such as timeouts and restarts + my $isCompleteClause = defined $isComplete ? "isComplete = $isComplete" : 'isComplete > 0'; $responseId - ||= $self->session->db->quickScalar("select Survey_responseId from Survey_response where userId = ? and assetId = ? and isComplete > 0", [ $userId, $self->getId ]); + ||= $self->session->db->quickScalar("select Survey_responseId from Survey_response where userId = ? and assetId = ? and $isCompleteClause order by endDate desc limit 1", [ $userId, $self->getId ]); if (!$responseId) { $self->session->log->debug("ResponseId not found"); @@ -1460,15 +1466,17 @@ sub submitQuestions { my $result = $self->recordResponses( $responses ); # check for special actions - if ( my $url = $result->{terminal} ) { - $self->session->log->debug('Terminal, surveyEnd'); - return $self->surveyEnd( { exitUrl => $url } ); - } elsif ( exists $result->{exitUrl} ) { - $self->session->log->debug('exitUrl triggered, surveyEnd'); - return $self->surveyEnd( { exitUrl => $result->{exitUrl} }); - } elsif ( my $restart = $result->{restart} ) { - $self->session->log->debug('restart triggered'); - return $self->surveyEnd( { restart => $restart } ); + if ($result && ref $result eq 'HASH') { + if ( my $url = $result->{terminal} ) { + $self->session->log->debug('Terminal, surveyEnd'); + return $self->surveyEnd( { exitUrl => $url } ); + } elsif ( exists $result->{exitUrl} ) { + $self->session->log->debug('exitUrl triggered, surveyEnd'); + return $self->surveyEnd( { exitUrl => $result->{exitUrl} }); + } elsif ( my $restart = $result->{restart} ) { + $self->session->log->debug('restart triggered'); + return $self->surveyEnd( { restart => $restart } ); + } } return $self->www_loadQuestions(); @@ -1595,21 +1603,21 @@ sub www_loadQuestions { my @questions; eval { @questions = $self->responseJSON->nextQuestions(); }; - # Logical sections cause nextResponse to move when nextQuestions is called, so - # persist and changes, and repeat the surveyEnd check in case we are now at the end - $self->persistResponseJSON(); - if ( $self->responseJSON->surveyEnd() ) { - $self->session->log->debug('surveyEnd, probably as a result of a Logical Section'); - if ( $self->get('quizModeSummary') ) { - if(! $self->session->form->param('shownsummary')){ - my ($summary,$html) = $self->getSummary(); - my $json = to_json( { type => 'summary', summary => $summary, html => $html }); - $self->session->http->setMimeType('application/json'); - return $json; - } - } - return $self->surveyEnd(); - } +# # Logical sections cause nextResponse to move when nextQuestions is called, so +# # persist and changes, and repeat the surveyEnd check in case we are now at the end +# $self->persistResponseJSON(); +# if ( $self->responseJSON->surveyEnd() ) { +# $self->session->log->debug('surveyEnd, probably as a result of a Logical Section'); +# if ( $self->get('quizModeSummary') ) { +# if(! $self->session->form->param('shownsummary')){ +# my ($summary,$html) = $self->getSummary(); +# my $json = to_json( { type => 'summary', summary => $summary, html => $html }); +# $self->session->http->setMimeType('application/json'); +# return $json; +# } +# } +# return $self->surveyEnd(); +# } my $section = $self->responseJSON->nextResponseSection(); @@ -1887,79 +1895,49 @@ sub responseId { my $self = shift; my ($userId) = validate_pos(@_, {type => SCALAR, optional => 1}); + $userId ||= $self->session->user->userId; my $user = WebGUI::User->new($self->session, $userId); - - if (!defined $self->{responseId}) { - my $ip = $self->session->env->getIp; - my $id = $userId || $self->session->user->userId; - my $anonId = $self->session->form->process('userid'); - if ($self->responseIdCookies) { - $anonId ||= $self->session->http->getCookies->{Survey2AnonId}; ## no critic - } - $anonId ||= undef; - - if ($self->responseIdCookies) { - $anonId && $self->session->http->setCookie( Survey2AnonId => $anonId ); - } + my $ip = $self->session->env->getIp; - my ($responseId, $string); - - # if there is an anonid or id is for a WG user - if ( $anonId or $id != 1 ) { - $string = 'userId'; - if ($anonId) { - $string = 'anonId'; - $id = $anonId; - } - $responseId - = $self->session->db->quickScalar( - "select Survey_responseId from Survey_response where $string = ? and assetId = ? and isComplete = 0", - [ $id, $self->getId() ] ); + my $responseId = $self->{responseId}; - } - elsif ( $id == 1 ) { - $responseId = $self->session->db->quickScalar( - 'select Survey_responseId from Survey_response where userId = ? and ipAddress = ? and assetId = ? and isComplete = 0', - [ $id, $ip, $self->getId() ] + # If a cached responseId doesn't exist, get the current in-progress response from the db + $responseId ||= $self->session->db->quickScalar( + "select Survey_responseId from Survey_response where userId = ? and assetId = ? and isComplete = 0", + [ $userId, $self->getId ] ); + + # If no current in-progress response exists, create one (as long as we're allowed to) + if ( !$responseId ) { + my $maxResponsesPerUser = $self->get('maxResponsesPerUser'); + my $takenCount = $self->takenCount( { userId => $userId } ); + if ( $maxResponsesPerUser == 0 || $takenCount < $maxResponsesPerUser ) { + # Create a new response + $responseId = $self->session->db->setRow( + 'Survey_response', + 'Survey_responseId', { + Survey_responseId => 'new', + userId => $userId, + ipAddress => $ip, + username => $user->username, + startDate => scalar time, + endDate => 0, + assetId => $self->getId, + anonId => undef + } ); - } - if ( !$responseId ) { - my $maxResponsesPerUser = $self->get('maxResponsesPerUser'); - my $takenCount; - - if ( $id == 1 ) { - $takenCount = $self->takenCount( { userId => $id, ipAddress => $ip } ); - } - else { - $takenCount = $self->takenCount( { $string => $id } ); - } - if ( $maxResponsesPerUser == 0 || $takenCount < $maxResponsesPerUser ) { - $responseId = $self->session->db->setRow( - 'Survey_response', - 'Survey_responseId', { - Survey_responseId => 'new', - userId => $id, - ipAddress => $ip, - username => $user ? $user->username : $self->session->user->username, - startDate => scalar time, #WebGUI::DateTime->now->toDatabase, - endDate => 0, #WebGUI::DateTime->now->toDatabase, - assetId => $self->getId(), - anonId => $anonId - } - ); - # Store the newly created responseId - $self->{responseId} = $responseId; - - # Manually persist ResponseJSON since we have changed $self->responseId - $self->persistResponseJSON(); - } - else { - $self->session->log->debug("takenCount ($takenCount) >= maxResponsesPerUser ($maxResponsesPerUser)"); - } + # Store the newly created responseId + $self->{responseId} = $responseId; + + # Manually persist ResponseJSON since we have changed $self->responseId + $self->persistResponseJSON(); + } + else { + $self->session->log->debug("Refusing to create new response, takenCount ($takenCount) >= maxResponsesPerUser ($maxResponsesPerUser)"); } - $self->{responseId} = $responseId; } + $self->{responseId} = $responseId; + return $self->{responseId}; } @@ -1974,16 +1952,18 @@ and thus should not count towards tally) sub takenCount { my $self = shift; - my %opts = validate(@_, { userId => 0, anonId => 0, ipAddress => 0 }); + my %opts = validate(@_, { userId => 0, anonId => 0, ipAddress => 0, isComplete => 0 }); + my $isComplete = defined $opts{isComplete} ? $opts{isComplete} : 1; my $sql = 'select count(*) from Survey_response where'; $sql .= ' assetId = ' . $self->session->db->quote($self->getId); - $sql .= ' and isComplete = 1'; + $sql .= ' and isComplete = ' . $self->session->db->quote($isComplete); for my $o qw(userId anonId ipAddress) { if (my $o_value = $opts{o}) { $sql .= " and $o = " . $self->session->db->quote($o_value); } } + $self->session->log->debug($sql); my $count = $self->session->db->quickScalar($sql); return $count; @@ -2217,6 +2197,81 @@ sub www_exportTransposedResults { return $self->export( $filename, $content ); } + +#------------------------------------------------------------------- + +sub www_exportStructure { + my $self = shift; + + return $self->session->privilege->insufficient() + unless ( $self->session->user->isInGroup( $self->get('groupToEditSurvey') ) ); + + if ($self->session->form->param('format') eq 'html') { + my $output = <N.B. Items are formatted as: + +

+
+END_HTML + my $sNum = 1; + for my $s (@{$self->surveyJSON->sections}) { + $output .= "S$sNum: ($s->{variable}) “$s->{title}”"; + $output .= '
    '; + my $qNum = 0; + for my $q (@{$s->{questions}}) { + $qNum++; + $output .= '
  • '; + $output .= "Q$qNum: ($q->{variable}) “$q->{text}”"; + $output .= '
      '; + my $aNum = 0; + for my $a (@{$q->{answers}}) { + $aNum++; + $output .= '
    • '; + $output .= "A$aNum: ($a->{recordedAnswer},$a->{value}) “$a->{text}”"; + $output .= '
    • '; + } + $output .= '
    '; + $output .= '
  • '; + } + $output .= '
'; + } + $output .= '
'; + + return $self->session->style->userStyle($output); + } else { + my @rows = ([qw( numbering type variable recordedValue score text goto gotoExpression)]); + my $sNum = 0; + for my $s (@{$self->surveyJSON->sections}) { + $sNum++; + push @rows, ["S$sNum", 'Section', $s->{variable}, '', '', $s->{text}, $s->{goto}, $s->{gotoExpression}]; + my $qNum = 0; + for my $q (@{$s->{questions}}) { + $qNum++; + push @rows, ["S$sNum-Q$qNum", 'Question', $q->{variable}, '', '', $q->{text}, $q->{goto}, $q->{gotoExpression}]; + my $aNum = 0; + for my $a (@{$q->{answers}}) { + $aNum++; + push @rows, ["S$sNum-Q$qNum-A$aNum", 'Answer', '', $a->{recordedAnswer}, $a->{value}, $a->{text}, $a->{goto}, $a->{gotoExpression}]; + } + } + } + + use Text::CSV_XS; + my $csv = Text::CSV_XS->new( { binary => 1 } ); + my @lines = map {$csv->combine(@$_); $csv->string} @rows; + my $output = join "\n", @lines; + + my $filename = $self->session->url->escape( $self->get("title") . "_structure.csv" ); + $self->session->http->setFilename($filename,"text/csv"); + + return $output; + } +} + #------------------------------------------------------------------- =head2 export($filename,$content) @@ -2450,14 +2505,14 @@ sub www_editTestSuite { my $icon = $session->icon; while (my $test = $getATest->()) { $testsFound++; - my $id = $test->getId; + my $testId = $test->getId; my $name = $test->get('name'); $tests .= '' - . $icon->delete( 'func=deleteTest;testId='.$id, undef, $i18n->get('confirm delete test')) - . $icon->edit( 'func=editTest;testId='.$id) - . $icon->moveDown('func=demoteTest;testId='.$id) - . $icon->moveUp( 'func=promoteTest;testId='.$id) - . qq{Run Test} + . $icon->delete( 'func=deleteTest;testId='.$testId, undef, $i18n->get('confirm delete test')) + . $icon->edit( 'func=editTest;testId='.$testId) + . $icon->moveDown('func=demoteTest;testId='.$testId) + . $icon->moveUp( 'func=promoteTest;testId='.$testId) + . qq{Run Test} . ''.$name.''; } $tests .= '
'; @@ -2508,13 +2563,14 @@ sub www_editTest { $form->hidden( name=>"assetId", value=>$self->getId); $form->dynamicForm([WebGUI::Asset::Wobject::Survey::Test->crud_definition($session)], 'properties', $test); $form->submit; - - my $i18n = WebGUI::International->new($session, 'Asset_Survey'); - my $ac = $self->getAdminConsole; if ($testId eq 'new') { $test->delete; } + my $ac = $self->getAdminConsole; + my $i18n = WebGUI::International->new($session, 'Asset_Survey'); + $ac->addSubmenuItem($self->session->url->page("func=editTest;testId=$testId"), $i18n->get('edit test')); + $ac->addSubmenuItem($self->session->url->page("func=runTest;testId=$testId"), $i18n->get('run test')); return $ac->render($error.$form->print, $i18n->get('edit test')); } @@ -2600,9 +2656,9 @@ sub www_runTest { return $self->session->privilege->insufficient() unless $self->session->user->isInGroup( $self->get('groupToEditSurvey') ); - my $id = $session->form->get("testId"); + my $testId = $session->form->get("testId"); - my $test = WebGUI::Asset::Wobject::Survey::Test->new($session, $id) + my $test = WebGUI::Asset::Wobject::Survey::Test->new($session, $testId) or return $self->www_editTestSuite('Unable to find test'); my $result = $test->run or return $self->www_editTestSuite('Unable to run test'); @@ -2611,10 +2667,10 @@ sub www_runTest { my $parsed = $self->parseTap($tap) or return $self->www_editTestSuite('Unable to parse test output'); - my $ac = $self->getAdminConsole; - my $edit = WebGUI::International->new($self->session, "WebGUI")->get(575); - $ac->addSubmenuItem($self->session->url->page("func=editTest;testId=$id"), "$edit Test"); - $ac->addSubmenuItem($self->session->url->page("func=runTests"), "Run All Tests"); + my $ac = $self->getAdminConsole; + my $i18n = WebGUI::International->new($session, 'Asset_Survey'); + $ac->addSubmenuItem($self->session->url->page("func=editTest;testId=$testId"), $i18n->get('edit test')); + $ac->addSubmenuItem($self->session->url->page("func=runTest;testId=$testId"), $i18n->get('run test')); return $ac->render($parsed->{templateText}, 'Test Results'); } @@ -2693,6 +2749,7 @@ Runs all tests sub www_runTests { my $self = shift; + my $session = $self->session; my $i18n = WebGUI::International->new($self->session, "Asset_Survey"); return $self->session->privilege->insufficient() @@ -2705,6 +2762,8 @@ sub www_runTests { aggregate => 1, results => [], }; + my $format = $self->session->form->param('format'); + local $| = 1 if $format eq 'tap'; my @parsers; use TAP::Parser::Aggregator; @@ -2723,6 +2782,7 @@ sub www_runTests { testId => $test->getId, text => $parsed->{templateText}, }; + $self->session->output->print("$name\n$tap\n\n") if $format eq 'tap'; } $aggregate->stop; @@ -2750,7 +2810,18 @@ sub www_runTests { my $out = $self->processTemplate($var, $self->get('testResultsTemplateId') || 'S3zpVitAmhy58CAioH359Q'); my $ac = $self->getAdminConsole; - return $ac->render($out, $i18n->get('test results')); + if ($format eq 'tap') { + my $summary = <<'END_SUMMARY'; +SUMMARY +------- +Passed: %s +Failed: %s +END_SUMMARY + $self->session->output->print(sprintf $summary, scalar $aggregate->passed, scalar $aggregate->failed); + return 'chunked'; + } else { + return $ac->render($out, $i18n->get('test results')); + } } 1; diff --git a/lib/WebGUI/Asset/Wobject/Survey/ResponseJSON.pm b/lib/WebGUI/Asset/Wobject/Survey/ResponseJSON.pm index 63f76f9fc..3783b1eaa 100644 --- a/lib/WebGUI/Asset/Wobject/Survey/ResponseJSON.pm +++ b/lib/WebGUI/Asset/Wobject/Survey/ResponseJSON.pm @@ -106,6 +106,9 @@ sub reset { # And then data overrides %{$data}, }; + + # If first section is logical, process it immediately + $self->checkForLogicalSection; return $self; } @@ -214,6 +217,9 @@ Mutator. The lastResponse property represents the surveyOrder index of the most This method returns (and optionally sets) the value of lastResponse. +You may want to call L after modifying this so that +any logical section you land in gets immediately processed. + =head3 $responseIndex (optional) If defined, lastResponse is set to $responseIndex. @@ -397,6 +403,9 @@ Mutator. The index of the next item that should be shown to the user, that is, the index of the next item in the L<"surveyOrder"> array, e.g. L<"lastResponse"> + 1. +You may want to call L after modifying this so that +any logical section you land in gets immediately processed. + =head3 $responseIndex (optional) If defined, nextResponse is set to $responseIndex. @@ -480,7 +489,7 @@ Processes and records submitted survey responses in the L<"responses"> data stru Does terminal handling, and branch processing, and advances the L<"lastResponse"> index if all required questions have been answered. -=head3 $submittedResponses +=head3 $responses A hash ref of submitted form param data. Each element should look like: @@ -492,219 +501,226 @@ A hash ref of submitted form param data. Each element should look like: See L<"questionId"> and L<"answerId">. -=head3 Terminal processing +=head3 Terminal, goto and gotoExpression processing -Terminal processing for a section and its questions and answers are handled in -order. The terminalUrl setting in a question overrides the terminalUrl setting -for its section. Similarly, with questions and answers, the last terminalUrl -setting of the set of questions is what is returned for the page, with the questions -and answers being answered in L<"surveyOrder">. +Gotos are processed first, followed by gotoExpressions, and finally terminals. +On a page with the following items: + Section 1 + Question 1.1 + Answer 1.1.1 + Answer 1.1.2 + Question 1.2 + Answer 1.2.1 + .. -=head3 Branch processing +the precedence order is inside-out, in order of questions displayed, e.g. -Jump targets (gotos) and jump expressions (gotoExpressions) are attempted in the following -order: + Answer 1.1.1 + Answer 1.1.2 + Question 1.1 + Answer 1.2.1 + Question 1.2 + Section 1 -=over 3 - -=item * answer goto - -=item * answer gotoExpression - -=item * question goto - -=item * question gotoExpression - -=item * question goto - -=item * question gotoExpression - -=back - -The first to trigger a jump short-circuits the process, and subsequent items are not attempted. +The first to trigger a jump short-circuits the process, meaning that subsequent items are not attempted. =cut sub recordResponses { my $self = shift; - my ($submittedResponses) = validate_pos( @_, { type => HASHREF } ); + my ($responses) = validate_pos( @_, { type => HASHREF } ); # Build a lookup table of non-multiple choice question types - my %knownTypes = map {$_ => 1} @{$self->survey->specialQuestionTypes}; - + my %specialQTypes = map { $_ => 1 } @{ $self->survey->specialQuestionTypes }; + # We want to record responses against the "next" response section and questions, since these are # the items that have just been displayed to the user. - my $section = $self->nextResponseSection(); + my $section = $self->nextResponseSection(); - my ($sectionGoto, $questionGoto, $answerGoto, $sectionExpression, $questionExpression, $answerExpression); + # Process responses by looping over expected questions in survey order + my @questions = $self->nextQuestions(); + my %newResponse; + my $allQsValid = 1; + my %validAnswers; + for my $question (@questions) { + my $aValid = 0; # TODO: this is flawed because we can have multi-answer quesions + my $qId = $question->{id}; - # Handle terminal Section.. - my $terminalUrl; - my $sTerminal = 0; - if ( $section->{terminal} ) { - $sTerminal = 1; - $terminalUrl = $section->{terminalUrl}; - } - # ..and also gotos.. - elsif ( $section->{goto} =~ /\w/ ) { - $sectionGoto = $section->{goto}; - } - # .. and also gotoExpressions.. - elsif ( $section->{gotoExpression} =~ /\w/ ) { - $sectionExpression = $section->{gotoExpression}; + $newResponse{ $qId }->{comment} = $responses->{ "${qId}comment" }; + + for my $answer ( @{ $question->{answers} } ) { + my $aId = $answer->{id}; + my $recordedAnswer = $responses->{ $aId }; + my $questionType = $question->{questionType}; + + # Server-side Validation and storing of extra data for special q types goes here + # Any answer that fails validation should be skipped with 'next' + + + if ( $questionType eq 'Number' ) { + if ( $answer->{max} =~ /\d/ and $recordedAnswer > $answer->{max} ) { + next; + } + elsif ( $answer->{min} =~ /\d/ and $recordedAnswer < $answer->{min} ) { + next; + } + elsif ( $answer->{step} =~ /\d/ and $recordedAnswer % $answer->{step} != 0 ) { + next; + } + } + elsif ( $questionType eq 'Year Month' ) { + # store year and month as "YYYY Month" + $recordedAnswer = $responses->{ "$aId-year" } . " " . $responses->{ "$aId-month" }; + } + else { + # In the case of a mc question, only selected answers will have a defined recordedAnswer + # Thus we skip any answers where recordedAnswer is not defined + next if !defined $recordedAnswer || $recordedAnswer !~ /\S/; + } + + # If we reach here, answer validated ok + $aValid = 1; + $validAnswers{$aId} = 1; + + # Now, decide what to record. For multi-choice questions, use recordedAnswer. + # Otherwise, we use the (raw) submitted response (e.g. text input, date input etc..) + $newResponse{ $aId } = { + value => $specialQTypes{ $questionType } ? $recordedAnswer : $answer->{recordedAnswer}, + verbatim => $answer->{verbatim} ? $responses->{ "${aId}verbatim" } : undef, + time => time, + comment => $responses->{ "${aId}comment" }, + }; + } + + # Check if a required Question was skipped + $allQsValid = 0 if $question->{required} && !$aValid; + + # If question was answered, increment the questionsAnswered count.. + $self->questionsAnswered(+1) if $aValid; } - my $logicalSection = $section->{logical}; - - # Process Questions in Section.. - my $terminal = 0; - my $allRequiredQsAnswered = 1; - my @questions; + # Stop here on validation errors + if ( !$allQsValid ) { + $self->session->log->debug("One or more questions failed validation"); + return; + } - if (!$logicalSection) { + # Add newResponse to the overall response (via a hash slice) + @{$self->responses}{keys %newResponse} = values %newResponse; + + # Now that the response has been recorded, increment nextResponse + # N.B. This can be overwritten by goto and gotoExpressions, below. + # (we give them a chance to run before processing logical sections) + # Normally we move forward by the number of questions answered, but if + # the section has no questions we still move forward by 1 + $self->nextResponse( $self->nextResponse + ( @questions || 1 ) ); + + # Now that the response has been added, loop over the questions a second time + # to process goto, gotoExpression, and terminalUrls. + # + # We are only dealing with a single section. On a page with: + # + # Section 1 + # Question 1.1 + # Answer 1.1.1 + # Answer 1.1.2 + # Question 1.2 + # Answer 1.2.1 + # .. + # + # the precedence order is inside-out, in order of questions displayed, e.g. + # + # Answer 1.1.1 + # Answer 1.1.2 + # Question 1.1 + # Answer 1.2.1 + # Question 1.2 + # Section 1 + # .. + for my $question (@questions) { - # N.B. Important that nextQuestions is not called for logicalSetions, since - # logical sections cause this sub to be called from nextQuestions() in the first place! - @questions = $self->nextQuestions(); + # First Answers.. - for my $question (@questions) { - my $aAnswered = 0; - - # Handle terminal Questions.. - if ( $question->{terminal} ) { - $terminal = 1; - $terminalUrl = $question->{terminalUrl}; + for my $answer ( @{ $question->{answers} } ) { + # Only process the chosen answer.. + my $aId = $answer->{id}; + next if !$validAnswers{$aId}; + + # Answer goto + if (my $action = $self->processGoto($answer->{goto})) { + $self->session->log->debug("Branching on Answer goto: $answer->{goto}"); + return $action; } - # ..and also gotos.. - elsif ( $question->{goto} =~ /\w/ ) { - $questionGoto = $question->{goto}; + # Then answer gotoExpression + if (my $action = $self->processExpression($answer->{gotoExpression})) { + $self->session->log->debug("Branching on Answer gotoExpression: $answer->{gotoExpression}"); + return $action; } - # .. and also gotoExpressions.. - elsif ( $question->{gotoExpression} =~ /\w/ ) { - $questionExpression = $question->{gotoExpression}; - } - - # Record Question comment - $self->responses->{ $question->{id} }->{comment} = $submittedResponses->{ $question->{id} . 'comment' }; - - # Process Answers in Question.. - for my $answer ( @{ $question->{answers} } ) { - - # Pluck the values out of the responses hash that we want to record.. - my $submittedAnswerResponse = $submittedResponses->{ $answer->{id} }; - my $submittedAnswerComment = $submittedResponses->{ $answer->{id} . 'comment' }; - my $submittedAnswerVerbatim = $submittedResponses->{ $answer->{id} . 'verbatim' }; - - # Server-side Validation and storing of extra data for special q types goes here - - if($question->{questionType} eq 'Number'){ - if($answer->{max} =~ /\d/ and $submittedAnswerResponse > $answer->{max}){ - next; - }elsif($answer->{min} =~ /\d/ and $submittedAnswerResponse < $answer->{min}){ - next; - }elsif($answer->{step} =~ /\d/ and $submittedAnswerResponse % $answer->{step} != 0){ - next; - } - } elsif ($question->{questionType} eq 'Year Month'){ - # store year and month as "YYYY Month" - $submittedAnswerResponse = $submittedResponses->{ $answer->{id} . '-year' } . " " . $submittedResponses->{ $answer->{id} . '-month' }; - } else { - if ( !defined $submittedAnswerResponse || $submittedAnswerResponse !~ /\S/ ) { - $self->session->log->debug("Skipping invalid submitted answer response: $submittedAnswerResponse") if $submittedAnswerResponse; - next; - } - } - - # If we reach here, answer validated ok - $aAnswered = 1; - - # Now, decide what to record. For multi-choice questions, use recordedAnswer. - # Otherwise, we use the (raw) submitted response (e.g. text input, date input etc..) - $self->responses->{ $answer->{id} }->{value} - = $knownTypes{ $question->{questionType} } - ? $submittedAnswerResponse - : $answer->{recordedAnswer}; - - $self->responses->{ $answer->{id} }->{verbatim} = $answer->{verbatim} ? $submittedAnswerVerbatim : undef; - $self->responses->{ $answer->{id} }->{time} = time; - $self->responses->{ $answer->{id} }->{comment} = $submittedAnswerComment; - - # Handle terminal Answers.. - if ( $answer->{terminal} ) { - $terminal = 1; - $terminalUrl = $answer->{terminalUrl}; - } - - # ..and also gotos.. - elsif ( $answer->{goto} =~ /\w/ ) { - $answerGoto = $answer->{goto}; - } - - # .. and also gotoExpressions.. - elsif ( $answer->{gotoExpression} =~ /\w/ ) { - $answerExpression = $answer->{gotoExpression}; - } - } - - # Check if a required Question was skipped - if ( $question->{required} && !$aAnswered ) { - $allRequiredQsAnswered = 0; - } - - # If question was answered, increment the questionsAnswered count.. - if ($aAnswered) { - $self->questionsAnswered(+1); + # Then answer terminal + if ($answer->{terminal}) { + $self->session->log->debug("Answer terminal: $answer->{terminalUrl}"); + return { terminal => $answer->{terminalUrl} }; } } - } - - # If all required responses were given, proceed onwards! - if ($allRequiredQsAnswered && !$logicalSection) { - # Move the lastResponse index to the last question answered - $self->lastResponse( $self->lastResponse + @questions ); - } - - if ($allRequiredQsAnswered || $logicalSection) { - # Process jumps and jump expressions in precedence order of: - # answer goto, answer expression, question goto, question expression, section.. + # Then Questions.. - # The joined logical OR here carries out the short-circuting for us - # e.g. processGoto returns 1 on its first match - # and processExpression returns hashref on its first match - my $action = $self->processGoto($answerGoto) || - $self->processExpression($answerExpression) || - $self->processGoto($questionGoto) || - $self->processExpression($questionExpression) || - $self->processGoto($sectionGoto) || - $self->processExpression($sectionExpression); - - # Special actions (such as exitUrl and restart) happen straight away - if ($action && ref $action eq 'HASH') { + # Question goto + if (my $action = $self->processGoto($question->{goto})) { + $self->session->log->debug("Branching on Question goto: $question->{goto}"); return $action; } + # Then question gotoExpression + if (my $action = $self->processExpression($question->{gotoExpression})) { + $self->session->log->debug("Branching on Question gotoExpression: $question->{gotoExpression}"); + return $action; + } + # N.B. Questions don't have terminalUrls } - if (!$allRequiredQsAnswered) { - # Required responses were missing, so we don't let the Survey terminate - $terminal = 0; + # Then Sections.. + + # Section goto + if (my $action = $self->processGoto($section->{goto})) { + $self->session->log->debug("Branching on Section goto: $section->{goto}"); + return $action; } + # Then section gotoExpression + if (my $action = $self->processExpression($section->{gotoExpression})) { + $self->session->log->debug("Branching on Section gotoExpression: $section->{gotoExpression}"); + return $action; + } + # Then section terminal + if ($section->{terminal} && $self->nextResponseSectionIndex != $self->lastResponseSectionIndex) { + $self->session->log->debug("Section terminal: $section->{terminalUrl}"); + return { terminal => $section->{terminalUrl} }; + } + + # The above goto and gotoExpression checks will have already called $self->checkForLogicalSection after + # moving nextResponse, however we need to call it again here for the case where the survey fell + # through naturally to a logical section + $self->checkForLogicalSection; + + $self->session->log->debug("Falling through.."); + return; +} - # Handle special cases down here, after we've given sections a chance for their jump [expressions] to run - if ( !@questions || $logicalSection ) { - # No questions to be (or should be) displayed, so increment lastResponse and return - $self->lastResponse( $self->nextResponse ); - return $sTerminal ? { terminal => $terminalUrl } : {}; - } +=head2 checkForLogicalSection - if ( $sTerminal && $self->nextResponseSectionIndex != $self->lastResponseSectionIndex ) { - $terminal = 1; - } +Check if the next response section is marked as logical, and if so, immediately processed it. +Normally, this sub should be called every time lastResponse or nextResponse is modified, so +that logical sections "automatically" trigger. - if ($terminal) { - return { terminal => $terminalUrl }; +=cut + +sub checkForLogicalSection { + my $self = shift; + my $section = $self->nextResponseSection(); + if ($section && $section->{logical}) { + $self->session->log->debug("Processing logical section $section->{variable}"); + $self->recordResponses({}); } - return {}; + return; } #------------------------------------------------------------------- @@ -735,12 +751,14 @@ sub processGoto { while ($self->nextResponseSectionIndex == $lastResponseSectionIndex) { $self->lastResponse( $self->lastResponse + 1); } + $self->checkForLogicalSection; return 1; } if ($goto eq 'END_SURVEY') { $self->session->log->debug("END_SURVEY jump target encountered"); $self->lastResponse( scalar( @{ $self->surveyOrder} ) - 1 ); + $self->checkForLogicalSection; return 1; } @@ -757,6 +775,7 @@ sub processGoto { # Fudge lastResponse so that the next response item will be our matching item $self->lastResponse( $itemIndex - 1 ); + $self->checkForLogicalSection; return 1; } @@ -765,6 +784,7 @@ sub processGoto { # Fudge lastResponse so that the next response item will be our matching item $self->lastResponse( $itemIndex - 1 ); + $self->checkForLogicalSection; return 1; } @@ -1038,7 +1058,8 @@ A hash reference. Each matching key in the string will be replaced with its asso sub getTemplatedText { my $self = shift; - my ($text, $params) = validate_pos(@_, { type => SCALAR }, { type => HASHREF }); + my ($text, $params) = validate_pos(@_, { type => SCALAR|UNDEF }, { type => HASHREF }); + $text = q{} if not defined $text; # Turn multi-valued answers into comma-separated text for my $value (values %$params) { @@ -1089,26 +1110,6 @@ sub nextQuestions { # Get some information about the Section that the next response belongs to.. my $section = $self->nextResponseSection(); - - # Logical sections get processed immediately rather than displayed - if ($section->{logical}) { - my $nextResponse = $self->nextResponse; - - $self->session->log->debug("Processing logical section"); - - # Pass off to recordResponses, which will process expressions and increment nextResponse - $self->recordResponses({}); - - # Explicitly check that nextResponse was incremented, lest we end up with an infinite loop - if ($nextResponse == $self->nextResponse) { - $self->session->log->error("Something bad happened in Survey logic, bailing out to avoid infinite loop"); - } else { - $self->session->log->debug("nextResponse has been updated to " . $self->nextResponse); - # ..and then start over - return $self->nextQuestions; - } - } - my $sectionIndex = $self->nextResponseSectionIndex; my $questionsPerPage = $self->survey->section( [ $self->nextResponseSectionIndex ] )->{questionsPerPage}; diff --git a/lib/WebGUI/Asset/Wobject/Survey/SurveyJSON.pm b/lib/WebGUI/Asset/Wobject/Survey/SurveyJSON.pm index 8f60deb15..101ef99d3 100644 --- a/lib/WebGUI/Asset/Wobject/Survey/SurveyJSON.pm +++ b/lib/WebGUI/Asset/Wobject/Survey/SurveyJSON.pm @@ -960,11 +960,6 @@ sub newAnswer { Remove all existing answers and add a default set of answers to a question, based on question type. -N.B. You probably don't want to call this method directly to update a question's questionType, as it -doesn't actually change the stored value of questionType. Instead, call: - - $surveyJSON->update( $address, { questionType => "some question type" } ); - =head3 $address See L<"Address Parameter">. Determines question to add answers to. @@ -986,6 +981,7 @@ sub updateQuestionAnswers { # Get the indexed question, and remove all of its existing answers my $question = $self->question($address); $question->{answers} = []; + $question->{questionType} = $type; # Add the default set of answers. The question type determines both the number # of answers added and the answer text to use. When updating answer text diff --git a/lib/WebGUI/Asset/Wobject/Survey/Test.pm b/lib/WebGUI/Asset/Wobject/Survey/Test.pm index c41ec9bb5..7f376cb0f 100644 --- a/lib/WebGUI/Asset/Wobject/Survey/Test.pm +++ b/lib/WebGUI/Asset/Wobject/Survey/Test.pm @@ -281,8 +281,10 @@ sub _test { while ( my ( $variable, $spec ) = each %$args ) { my $index = $surveyOrderIndexByVariableName->{$variable}; + return fail($testCount, "Invalid question variable: $variable") if !defined $index; my $address = $surveyOrder->[$index]; my $question = $rJSON->survey->question($address); + return fail($testCount, "Invalid question variable: $variable") if !defined $question; my $questionType = $question->{questionType}; # Keep track of lowest index (to work out what survey page we should test on) @@ -325,6 +327,7 @@ sub _test { my ($pageSection, $pageQuestion); if (defined $lowestIndex) { + my $address = $surveyOrder->[$lowestIndex] or return fail($testCount, "Unable to determine address from lowest index: $lowestIndex"); $rJSON->nextResponse($lowestIndex); $pageSection = $rJSON->survey->section($surveyOrder->[$lowestIndex]); $pageQuestion = $rJSON->survey->question($surveyOrder->[$lowestIndex]); @@ -427,8 +430,10 @@ sub _test_mc { my @specs = @$args; my $index = $surveyOrderIndexByVariableName->{$variable}; + return fail(-1, "Invalid question variable: $variable") if !defined $index; my $address = $surveyOrder->[$index]; my $question = $rJSON->survey->question($address); + return fail(-1, "Invalid question variable: $variable") if !defined $question; my $answers = $question->{answers}; # Each spec is a sub-test, one per answer in the question @@ -517,8 +522,10 @@ sub _sequence { while ( my ( $variable, $spec ) = each %$args ) { my $index = $surveyOrderIndexByVariableName->{$variable}; + return fail($testCount, "Invalid question variable: $variable") if !defined $index; my $address = $surveyOrder->[$index]; my $question = $rJSON->survey->question($address); + return fail($testCount, "Invalid question variable: $variable") if !defined $question; my $questionType = $question->{questionType}; # Iterate over all answers @@ -648,27 +655,38 @@ sub _recordResponses { if ($next) { my $nextResponse = $rJSON->nextResponse; my $nextAddress = $surveyOrder->[$nextResponse]; - my $nextSection = $rJSON->survey->section($nextAddress); - my $nextQuestion = $rJSON->survey->question($nextAddress); - # Get the lowest section surveyOrderIndex from lookup - my $got; - my $svar = $nextSection->{variable}; - my $qvar = $nextQuestion->{variable}; - if ($surveyOrderIndexByVariableName->{$svar} == $nextResponse) { - $got = "'$svar' (<-- a section)"; - $got .= " and '$qvar' (<-- a question)" if $qvar; - } elsif ($qvar) { - $got = "'$qvar' (<-- a question)"; - } else { - $got = 'Unknown!'; - } - my $expectedNextResponse = $surveyOrderIndexByVariableName->{$next}; - if ($nextResponse != $expectedNextResponse) { + if ($next ne 'SURVEY_END' && !defined $nextAddress) { return fail($testCount, $name, <session->log->debug("SURVEY_END matched correctly"); + } else { + my $nextSection = $rJSON->survey->section($nextAddress); + my $nextQuestion = $rJSON->survey->question($nextAddress); + # Get the lowest section surveyOrderIndex from lookup + my $got; + my $svar = $nextSection->{variable}; + my $qvar = $nextQuestion->{variable}; + if ($surveyOrderIndexByVariableName->{$svar} == $nextResponse) { + $got = "'$svar' (<-- a section)"; + $got .= " and '$qvar' (<-- a question)" if $qvar; + } elsif ($qvar) { + $got = "'$qvar' (<-- a question)"; + } else { + $got = 'Unknown!'; + } + my $expectedNextResponse = $surveyOrderIndexByVariableName->{$next}; + if ($nextResponse != $expectedNextResponse) { + return fail($testCount, $name, <session; #---------------------------------------------------------------------------- # Tests -my $tests = 97; +my $tests = 104; plan tests => $tests + 1; #---------------------------------------------------------------------------- @@ -384,7 +384,6 @@ $rJSON->survey->question([3,0])->{variable} = 's3q0'; # surveyOrder index = 6 $rJSON->survey->question([3,1])->{variable} = 's3q1'; # surveyOrder index = 7 $rJSON->survey->question([3,2])->{variable} = 's3q2'; # surveyOrder index = 8 -$rJSON->survey->answer([0,0,0])->{recordedAnswer} = 3; # value recorded in responses hash for multi-choice answer $rJSON->survey->answer([0,0,0])->{value} = 100; # set answer score $rJSON->survey->answer([0,1,0])->{value} = 200; # set answer score $rJSON->survey->answer([0,1,0])->{verbatim} = 1; # make this answer verbatim @@ -392,8 +391,8 @@ $rJSON->survey->answer([0,1,0])->{verbatim} = 1; # make this answer verbatim # Reset responses and record first answer $rJSON->lastResponse(-1); $rJSON->recordResponses({ - '0-0-0' => 'I chose the first answer to s0q0', - '0-1-0' => 'I chose the first answer to s0q1', + '0-0-0' => 3, # it's a funny email address I know... + '0-1-0' => '13 11 66', '0-1-0verbatim' => 'So you want to know more', }); @@ -521,10 +520,10 @@ $rJSON->questionsAnswered(-1 * $rJSON->questionsAnswered); $rJSON->survey->question([1,0])->{questionType} = 'Multiple Choice'; $rJSON->lastResponse(4); my $terminals; -cmp_deeply( +is( $rJSON->recordResponses({}), - {}, - 'recordResponses, if section has no questions, returns terminal info in the section. With no terminal info, returns {}', + undef, + 'recordResponses, with no terminal info, returns undef', ); is($rJSON->lastResponse(), 5, 'recordResponses, increments lastResponse if there are no questions in the section'); @@ -544,38 +543,6 @@ $rJSON->survey->question([1,0])->{terminalUrl} = 'question 1-0 terminal'; $rJSON->lastResponse(2); $rJSON->survey->answer([1,0,0])->{recordedAnswer} = 1; # Set recordedAnswer -cmp_deeply( - $rJSON->recordResponses({ - '1-0comment' => 'Section 1, question 0 comment', - '1-0-0' => 'First answer', - '1-0-0verbatim' => 'First answer verbatim', # ignored - '1-0-0comment' => 'Section 1, question 0, answer 0 comment', - }), - { terminal => 'question 1-0 terminal' }, - 'recordResponses: question terminal overrides section terminal', -); - -is($rJSON->lastResponse(), 4, 'lastResponse advanced to next page of questions'); -is($rJSON->questionsAnswered, 1, 'questionsAnswered=1, answered one question'); - -cmp_deeply( - $rJSON->responses, - { - '1-0' => { - comment => 'Section 1, question 0 comment', - }, - '1-0-0' => { - comment => 'Section 1, question 0, answer 0 comment', - 'time' => num(time(), 3), - value => 1, # 'recordedAnswer' value used because question is multi-choice - verbatim => undef, - }, - '1-1' => { - comment => undef, - } - }, - 'recordResponses: recorded responses correctly, two questions, one answer, comments, values and time' -); # Check that raw input is recorded for verbatim mc answers $rJSON->survey->answer([1,0,0])->{verbatim} = 1; @@ -583,10 +550,10 @@ $rJSON->lastResponse(2); $rJSON->responses({}); $rJSON->questionsAnswered(-1 * $rJSON->questionsAnswered); $rJSON->recordResponses({ - '1-0comment' => 'Section 1, question 0 comment', - '1-0-0' => 'First answer', - '1-0-0verbatim' => 'First answer verbatim', - '1-0-0comment' => 'Section 1, question 0, answer 0 comment', + '1-0comment' => 'Section 1, question 0 comment', + '1-0-0' => 'First answer', + '1-0-0verbatim' => 'First answer verbatim', + '1-0-0comment' => 'Section 1, question 0, answer 0 comment', }); cmp_deeply( $rJSON->responses, @@ -639,8 +606,8 @@ cmp_deeply( ); $rJSON->survey->question([1,0])->{questionType} = 'Multiple Choice'; # revert change -$rJSON->survey->question([1,0,0])->{terminal} = 1; -$rJSON->survey->question([1,0,0])->{terminalUrl} = 'answer 1-0-0 terminal'; +$rJSON->survey->answer([1,0,0])->{terminal} = 1; +$rJSON->survey->answer([1,0,0])->{terminalUrl} = 'answer 1-0-0 terminal'; $rJSON->responses({}); $rJSON->lastResponse(2); $rJSON->questionsAnswered(-1 * $rJSON->questionsAnswered); @@ -648,22 +615,25 @@ $rJSON->questionsAnswered(-1 * $rJSON->questionsAnswered); cmp_deeply( $rJSON->recordResponses({ '1-0comment' => 'Section 1, question 0 comment', - '1-0-0' => "\t\t\t\n\n\n\t\t\t", #SOS in whitespace + '1-0-0' => 1, '1-0-0comment' => 'Section 1, question 0, answer 0 comment', }), { terminal => 'answer 1-0-0 terminal'}, - 'recordResponses: answer terminal overrides question and section terminals', + 'recordResponses: answer terminal overrides section terminals', ); +$rJSON->responses({}); +$rJSON->lastResponse(2); +$rJSON->questionsAnswered(-1 * $rJSON->questionsAnswered); cmp_deeply( $rJSON->responses, { - '1-0' => { - comment => 'Section 1, question 0 comment', - }, - '1-1' => { - comment => undef, - } +# '1-0' => { +# comment => 'Section 1, question 0 comment', +# }, +# '1-1' => { +# comment => undef, +# } }, 'recordResponses: if the answer is all whitespace, it is skipped over' ); @@ -771,6 +741,142 @@ cmp_deeply($rJSON->responses, {}, '..and now responses hash empty again'); is($rJSON->pop, undef, 'additional pop has no effect'); +#################################################### +# +# Question Types +# +#################################################### +$rJSON = WebGUI::Asset::Wobject::Survey::ResponseJSON->new(buildSurveyJSON($session)); + +# Use Section 1 (containing 2 questions) for testing. This allows us to test 2 different responses at once. + +######## +# Yes/No +$rJSON->survey->updateQuestionAnswers([1,0], 'Yes/No'); +$rJSON->survey->updateQuestionAnswers([1,1], 'Yes/No'); +for my $q (0,1) { + $rJSON->survey->answer([1,$q,0])->{recordedAnswer} = 'Yes'; + $rJSON->survey->answer([1,$q,1])->{recordedAnswer} = 'No'; +} +$rJSON->reset; +$rJSON->lastResponse(2); +$rJSON->recordResponses( { + '1-0-0' => 1, # Multi-choice answers are submitted like this, + '1-1-1' => 1, # with the selected answer set to 1 +}); +cmp_deeply( + $rJSON->responses->{'1-0-0'}, + { + 'verbatim' => undef, + 'comment' => undef, + 'time' => num(time(), 3), + 'value' => 'Yes' + }, + 'Yes recorded correctly' +); +cmp_deeply( + $rJSON->responses->{'1-1-1'}, + { + 'verbatim' => undef, + 'comment' => undef, + 'time' => num(time(), 3), + 'value' => 'No' + }, + 'No recorded correctly' +); + +#################################################### +# +# logical sections +# +#################################################### +$rJSON = WebGUI::Asset::Wobject::Survey::ResponseJSON->new(buildSurveyJSON($session)); +cmp_deeply( + $rJSON->surveyOrder, + [ [ 0, 0, [0] ], # S0Q0 (surveyOrder: 0) + [ 0, 1, [0] ], # S0Q1 (surveyOrder: 1) + [ 0, 2, [ 0, 1 ] ], # S0Q2 (surveyOrder: 2) + [ 1, 0, [ 0, 1 ] ], # S1Q0 (surveyOrder: 3) + [ 1, 1, [ 0, 1 ] ], # S1Q1 (surveyOrder: 4) + [2], # S2 (surveyOrder: 5) + [ 3, 0, [ 0, 1 ] ], # S3Q0 (surveyOrder: 6) + [ 3, 1, [ 0, 1, 2, 3, 4, 5, 6 ] ], #S3Q1 (surveyOrder: 7) + [ 3, 2, [0] ], #S3Q2 (surveyOrder: 8) + ], + 'surveyOrder', +); + +$rJSON->survey->section([$_])->{gotoExpression} = qq{tag('tagged at s$_')} for (0..3); +$rJSON->survey->section([$_])->{variable} = "S$_" for (0..3); +$rJSON->survey->answer([0,2,1])->{goto} = 'S2'; + +# Submit section 0, should fall through to section 2 because section 1 is logical +# If we submit S0 normally, nextResponse will be 3 (S1 / S1Q0) +$rJSON->recordResponses( { + '0-0-0' => 'me@email.com', + '0-1-0' => 'my phone', + '0-2-0' => 1, +}); +is($rJSON->nextResponse, 3, 'Natural progression'); + +# However if S1 is logical, nextResponse will be 5 (S2) +$rJSON->reset; +$rJSON->survey->section([1])->{logical} = 1; + +$rJSON->recordResponses( { + '0-0-0' => 'me@email.com', + '0-1-0' => 'my phone', + '0-2-0' => 1, +}); +is($rJSON->nextResponse, 5, 'Logical section processed automatically'); +cmp_deeply($rJSON->tags, { 'tagged at s0' => 1, 'tagged at s1' => 1, }, 'Logical section gotoExpression can still tag data'); +$rJSON->survey->section([1])->{logical} = 0; + +# Check behaviour when first section is logical +$rJSON->reset; +cmp_deeply( [ $rJSON->nextQuestions ], + [ + superhashof( { id => '0-0' } ), + superhashof( { id => '0-1' } ), + superhashof( { id => '0-2' } ), + ], + 'Normally nextQuestions returns all questions in first section' +); +$rJSON->survey->section([0])->{logical} = 1; +$rJSON->reset; +cmp_deeply( [ $rJSON->nextQuestions ], + [ + superhashof( { id => '1-0' } ), + superhashof( { id => '1-1' } ), + ], + '..but when first section logical, second section questions returned instead' +); +cmp_deeply($rJSON->tags, { 'tagged at s0' => 1 }, '..and s0 gotoExpression was run'); + +# Check behaviour when all sections logical +$rJSON->survey->section([$_])->{logical} = 1 for (0..3); +$rJSON->reset; +cmp_deeply($rJSON->tags, + { + 'tagged at s0' => 1, + 'tagged at s1' => 1, + 'tagged at s2' => 1, + 'tagged at s3' => 1, + }, + '..all gotoExpressions run' +); +$rJSON->survey->section([$_])->{logical} = 0 for (0..3); + +# Check that we can jump to a logical section +$rJSON->survey->section([2])->{logical} = 1; +$rJSON->reset; +$rJSON->recordResponses( { + '0-0-0' => 'me@email.com', + '0-1-0' => 'my phone', + '0-2-1' => 1, # goto -> S2 +}); +is($rJSON->nextResponse, 6, 'S2 processed automatically and we land as S3'); + } #################################################### @@ -798,7 +904,7 @@ sub buildSurveyJSON { $sjson->newObject([3]); ##Add questions $sjson->updateQuestionAnswers([0,0], 'Email'); - $sjson->updateQuestionAnswers([0,1], 'Phone number'); + $sjson->updateQuestionAnswers([0,1], 'Phone Number'); $sjson->updateQuestionAnswers([0,2], 'Yes/No'); $sjson->updateQuestionAnswers([1,0], 'True/False'); $sjson->updateQuestionAnswers([1,1], 'Gender'); diff --git a/t/Asset/Wobject/Survey/SurveyJSON.t b/t/Asset/Wobject/Survey/SurveyJSON.t index 2ddb60a80..1da1b816b 100644 --- a/t/Asset/Wobject/Survey/SurveyJSON.t +++ b/t/Asset/Wobject/Survey/SurveyJSON.t @@ -1412,7 +1412,7 @@ cmp_deeply( }), ], }), - 'addAnswersToQuestion: Yes/No bundle created' + 'addAnswersToQuestion: Yes/No bundle created' # N.B. This test is dependent on the default values of the Yes/No bundle ); #################################################### diff --git a/t/Asset/Wobject/Survey/Test.t b/t/Asset/Wobject/Survey/Test.t index d31d9c65a..545fd9360 100644 --- a/t/Asset/Wobject/Survey/Test.t +++ b/t/Asset/Wobject/Survey/Test.t @@ -98,22 +98,24 @@ $s->surveyJSON_update( [ 5, 0 ], { questionType => 'Slider', required => 1 } ); $s->surveyJSON_update( [ 5, 1 ], { questionType => 'Text', required => 1 } ); $s->surveyJSON_update( [ 5, 2 ], { questionType => 'Number', required => 1 } ); +$s->surveyJSON_update( [ 6 ], { logical => 1, gotoExpression => q{tag('tagged at S6');} } ); + # And finally, persist the changes.. $s->persistSurveyJSON; cmp_deeply( $s->responseJSON->surveyOrder, - [ [ 0, 0, [ 0, 1 ] ], # S0Q0 - [ 1, 0, [ 0, 1 ] ], # S1Q0 - [ 2, 0, [] ], # S2Q0 - [ 3, 0, [ 0, 1 ] ], # S3Q0 - [ 3, 1, [ 0, 1 ] ], # S3Q1 - [ 3, 2, [ 0, 1 ] ], # S3Q2 - [ 4, 0, [ 0 .. 10 ] ], # S4Q0 - [ 5, 0, [0] ], # S5Q0 - [ 5, 1, [0] ], # S5Q0 - [ 5, 2, [0] ], # S5Q0 - [6], # S6 + [ [ 0, 0, [ 0, 1 ] ], # S0Q0 (surveyOrderIndex: 0) + [ 1, 0, [ 0, 1 ] ], # S1Q0 (surveyOrderIndex: 1) + [ 2, 0, [] ], # S2Q0 (surveyOrderIndex: 2) + [ 3, 0, [ 0, 1 ] ], # S3Q0 (surveyOrderIndex: 3) + [ 3, 1, [ 0, 1 ] ], # S3Q1 (surveyOrderIndex: 4) + [ 3, 2, [ 0, 1 ] ], # S3Q2 (surveyOrderIndex: 5) + [ 4, 0, [ 0 .. 10 ] ], # S4Q0 (surveyOrderIndex: 6) + [ 5, 0, [0] ], # S5Q0 (surveyOrderIndex: 7) + [ 5, 1, [0] ], # S5Q0 (surveyOrderIndex: 8) + [ 5, 2, [0] ], # S5Q0 (surveyOrderIndex: 9) + [6], # S6 (surveyOrderIndex: 10) ], 'surveyOrder is correct' ); @@ -472,6 +474,7 @@ not ok 4 - Checking tagged on page containing Section S1 Question S1Q0 END_TAP # Slider, Number & Text question types +# And also test the fact that S6 is logical $spec = < < 3000) { answered = 0; } - if(answered == 1){ document.getElementById(z1).value = m + "-" + y; } + for (var z2 in toValidate[i].answers) { + if (YAHOO.lang.hasOwnProperty(toValidate[i].answers, z2)) { + var month = document.getElementById(z2+'-month').value; + var year = document.getElementById(z2+'-year').value; + if (month == ''){ + answered = 0; + } + var yInt = parseInt(year, 10); + if(!yInt) { + answered = 0; + } + if(yInt < 1000 || yInt > 3000) { + answered = 0; + } + if (answered == 1){ + document.getElementById(z2).value = month + "-" + year; + } } } } else { - for (var z1 in toValidate[i].answers) { - if (YAHOO.lang.hasOwnProperty(toValidate[i].answers, z1)) { - var v = document.getElementById(z1).value; + for (var z3 in toValidate[i].answers) { + if (YAHOO.lang.hasOwnProperty(toValidate[i].answers, z3)) { + var v = document.getElementById(z3).value; if (YAHOO.lang.isValue(v) && v !== '') { answered = 1; break; @@ -140,15 +148,21 @@ if (typeof Survey === "undefined") { var submitDiv = submitButton && YAHOO.util.Dom.getAncestorByTagName(submitButton, 'div'); if (submit) { - submitDiv && YAHOO.util.Dom.removeClass(submitDiv, INVALID_SUBMIT_CLASS); + if (submitDiv) { + YAHOO.util.Dom.removeClass(submitDiv, INVALID_SUBMIT_CLASS); + } YAHOO.log("Submitting"); Survey.Comm.callServer('', 'submitQuestions', 'surveyForm', hasFile); } else { - submitDiv && YAHOO.util.Dom.addClass(submitDiv, INVALID_SUBMIT_CLASS); + if (submitDiv) { + YAHOO.util.Dom.addClass(submitDiv, INVALID_SUBMIT_CLASS); + } // Scroll page to the y-coord of the lowest invalid question - lowestInvalidY && scrollPage(lowestInvalidY, 1.5, YAHOO.util.Easing.easeOut); + if (lowestInvalidY) { + scrollPage(lowestInvalidY, 1.5, YAHOO.util.Easing.easeOut); + } } } @@ -203,13 +217,18 @@ if (typeof Survey === "undefined") { } }else if(+keycode == 38){//key up if(objs.max == ''){ - this.value = +value + +step; + this.value = value + step; } - if(+value + +step <= +objs.max){ - this.value = +value + +step; + if(value + step <= objs.max){ + this.value = value + step; } } } + + function sliderTextSet(event, objs){ + this.value = this.value * 1; + this.value = YAHOO.lang.isValue(this.value) ? this.value : 0; + } function sliderManager(q){ //total number of pixels in the slider. @@ -237,9 +256,9 @@ if (typeof Survey === "undefined") { var type = 'slider'; //find the maximum difference between an answers max and min - for (var s in q.answers) { - if (YAHOO.lang.hasOwnProperty(q.answers, s)) { - var a1 = q.answers[s]; + for (var _s in q.answers) { + if (YAHOO.lang.hasOwnProperty(q.answers, _s)) { + var a1 = q.answers[_s]; YAHOO.util.Event.addListener(a1.id, "blur", sliderTextSet); if (a1.max - a1.min > max) { max = a1.max - a1.min; @@ -309,7 +328,7 @@ if (typeof Survey === "undefined") { Event.on(document.getElementById(s.input), "blur", manualEntry); Event.on(document.getElementById(s.input), "keypress", manualEntry); var getRealValue = function(){ - return parseInt((this.getValue() / bugSteps * step) + +min, 10); + return parseInt((this.getValue() / bugSteps * step) +min, 10); }; s.getRealValue = getRealValue; document.getElementById(s.input).value = s.getRealValue(); @@ -317,11 +336,6 @@ if (typeof Survey === "undefined") { } } - - function sliderTextSet(event, objs){ - this.value = this.value * 1; - this.value = YAHOO.lang.isValue(this.value) ? this.value : 0; - } function handleDualSliders(q){ var a1 = q.answers[0]; @@ -453,7 +467,6 @@ if (typeof Survey === "undefined") { return toolTipText; }, showSummary: function(summary,html){ - var html = html; document.getElementById('survey').innerHTML = html; @@ -467,7 +480,7 @@ if (typeof Survey === "undefined") { totalSummaryDS.responseType = YAHOO.util.DataSource.TYPE_JSARRAY; totalSummaryDS.responseSchema = { fields: [ "correct", "count" ] }; - new YAHOO.widget.PieChart( "chart", totalSummaryDS, { + var _pie = new YAHOO.widget.PieChart( "chart", totalSummaryDS, { dataField: "count", categoryField: "correct", style: @@ -511,7 +524,7 @@ if (typeof Survey === "undefined") { fields: ["Question ID","Question Text","Answer ID","Correct","Answer Text","Score","Value"] }; var tempText = "section" + (i+1) + "datatable"; - new YAHOO.widget.DataTable(tempText, myColumnDefs, myDataSource, { caption: "Section " + (i+1) } ); + var _dt = new YAHOO.widget.DataTable(tempText, myColumnDefs, myDataSource, { caption: "Section " + (i+1) } ); } //Now create section summary bar charts @@ -656,35 +669,35 @@ if (typeof Survey === "undefined") { } if (COUNTRY[q.questionType]) { - for (var k = 0; k < q.answers.length; k++) { - var ans = q.answers[k]; + for (var k2 = 0; k2 < q.answers.length; k2++) { + var ans2 = q.answers[k2]; if (toValidate[q.id]) { toValidate[q.id].type = q.questionType; - toValidate[q.id].answers[ans.id] = 1; + toValidate[q.id].answers[ans2.id] = 1; } } continue; } if (DATE_TYPES[q.questionType]) { - for (var k = 0; k < q.answers.length; k++) { - var ans = q.answers[k]; + for (var k3 = 0; k3 < q.answers.length; k3++) { + var ans3 = q.answers[k3]; if (toValidate[q.id]) { - toValidate[q.id].answers[ans.id] = 1; + toValidate[q.id].answers[ans3.id] = 1; } - var calid = ans.id + 'container'; + var calid = ans3.id + 'container'; var c = new YAHOO.widget.Calendar(calid, { title: 'Choose a date:', close: true, navigator: true }); - c.selectEvent.subscribe(selectCalendar, [c, ans.id], true); + c.selectEvent.subscribe(selectCalendar, [c, ans3.id], true); c.render(); c.hide(); var btn = new YAHOO.widget.Button({ label: "Select Date", - id: "pushbutton" + ans.id, - container: ans.id + 'button' + id: "pushbutton" + ans3.id, + container: ans3.id + 'button' }); btn.on("click", showCalendar, [c]); } @@ -715,12 +728,12 @@ if (typeof Survey === "undefined") { continue; } if (NUMBER_TYPES[q.questionType]) { - for (var x in q.answers) { - if (YAHOO.lang.hasOwnProperty(q.answers, x)) { + for (var x1 in q.answers) { + if (YAHOO.lang.hasOwnProperty(q.answers, x1)) { if (toValidate[q.id]) { - toValidate[q.id].answers[q.answers[x].id] = {'min':q.answers[x].min,'max':q.answers[x].max,'step':q.answers[x].step}; + toValidate[q.id].answers[q.answers[x1].id] = {'min':q.answers[x1].min,'max':q.answers[x1].max,'step':q.answers[x1].step}; } - YAHOO.util.Event.addListener(q.answers[x].id, "keyup", numberHandler, q.answers[x]); + YAHOO.util.Event.addListener(q.answers[x1].id, "keyup", numberHandler, q.answers[x1]); } } continue; diff --git a/www/extras/wobject/Survey/administersurvey/comm.js b/www/extras/wobject/Survey/administersurvey/comm.js index 39e0fdb11..eb32d6a66 100644 --- a/www/extras/wobject/Survey/administersurvey/comm.js +++ b/www/extras/wobject/Survey/administersurvey/comm.js @@ -1,5 +1,4 @@ - -/*global Survey, YAHOO */ +/*global Survey, YAHOO, alert, window */ if (typeof Survey === "undefined") { var Survey = {}; } @@ -20,7 +19,7 @@ if (typeof Survey === "undefined") { //YAHOO.log('setForm was true'); } if (callMade) { - alert("Waiting on previous request"); + alert("Your previous action is still being processed. Please try again."); } else { callMade = 1; @@ -40,7 +39,14 @@ if (typeof Survey === "undefined") { window.scrollTo(0, 0); callMade = 0; var response = ''; - response = YAHOO.lang.JSON.parse(o.responseText); + try { + response = YAHOO.lang.JSON.parse(o.responseText); + } + catch (err) { + YAHOO.log(err); + alert("Oops.. A problem was encountered. Please try again."); + return; + } if (response.type === 'displayquestions') { Survey.Form.displayQuestions(response); } diff --git a/www/extras/wobject/Survey/editsurvey.js b/www/extras/wobject/Survey/editsurvey.js index 848c8779c..78be46651 100644 --- a/www/extras/wobject/Survey/editsurvey.js +++ b/www/extras/wobject/Survey/editsurvey.js @@ -1,4 +1,4 @@ -/*global Survey, YAHOO */ +/*global Survey, YAHOO, alert */ if (typeof Survey === "undefined") { var Survey = {}; } @@ -55,7 +55,9 @@ Survey.Data = (function(){ focus = d.address;//What is the current highlighted item. var warnings = ""; for(var w in d.warnings){ - warnings += "
" + d.warnings[w] + "
"; + if (YAHOO.lang.hasOwnProperty(d.warnings, w)) { + warnings += "
" + d.warnings[w] + "
"; + } } if (document.getElementById('warnings')) { if (warnings !== "") { @@ -111,7 +113,9 @@ Survey.Data = (function(){ } // (re)Add resize handler - Survey.Data.ddContainerResize && Survey.Data.ddContainerResize.destroy(); + if (Survey.Data.ddContainerResize) { + Survey.Data.ddContainerResize.destroy(); + } Survey.Data.ddContainerResize = new YAHOO.util.Resize('sections-panel', { proxy: true, minWidth: 300, @@ -137,7 +141,9 @@ Survey.Data = (function(){ YAHOO.util.Dom.addClass(selectedId, 'selected'); } - sButton && sButton.destroy(); + if (sButton) { + sButton.destroy(); + } sButton = new YAHOO.widget.Button({ label: "Add Section", id: "addSection", @@ -145,7 +151,9 @@ Survey.Data = (function(){ }); sButton.on("click", this.addSection); - qButton && qButton.destroy(); + if (qButton) { + qButton.destroy(); + } qButton = new YAHOO.widget.Button({ label: "Add Question", id: "addQuestion", @@ -154,7 +162,9 @@ Survey.Data = (function(){ qButton.on("click", this.addQuestion, d.buttons.question); if (d.buttons.answer) { - aButton && aButton.destroy(); + if (aButton) { + aButton.destroy(); + } aButton = new YAHOO.widget.Button({ label: "Add Answer", id: "addAnswer", diff --git a/www/extras/wobject/Survey/editsurvey/comm.js b/www/extras/wobject/Survey/editsurvey/comm.js index 159ac8121..80551b8b8 100644 --- a/www/extras/wobject/Survey/editsurvey/comm.js +++ b/www/extras/wobject/Survey/editsurvey/comm.js @@ -1,3 +1,4 @@ +/*global Survey, YAHOO, alert */ if (typeof Survey == "undefined") { var Survey = {}; } @@ -13,7 +14,7 @@ Survey.Comm = new function(){ callMade = 1; YAHOO.util.Connect.asyncRequest('POST', sUrl, callback, postData); } - } + }; this.callback = { success:function(o){ YAHOO.util.Dom.setStyle('mask-all','display','none'); @@ -21,7 +22,7 @@ Survey.Comm = new function(){ Survey.Data.loadData(YAHOO.lang.JSON.parse(o.responseText)); }, failure: function(o){ - YAHOO.util.Dom.setStyle('mask-all','display','none') + YAHOO.util.Dom.setStyle('mask-all','display','none'); callMade = 0; alert("Last request failed"); Survey.Data.loadLast(); @@ -32,47 +33,47 @@ Survey.Comm = new function(){ var postData = "data="+p; var sUrl = "?func=loadSurvey"; request(sUrl,this.callback,postData); - } + }; this.dragDrop = function(target,before){ var p = {}; - p['target'] = target; - p['before'] = before; + p.target = target; + p.before = before; var postData = "data="+YAHOO.lang.JSON.stringify(p); var sUrl = "?func=dragDrop"; request(sUrl,this.callback,postData); - } + }; this.submitEdit = function(p){ var postData = "data="+YAHOO.lang.JSON.stringify(p); var sUrl = "?func=submitEdit"; request(sUrl,this.callback,postData); - } + }; this.newSection = function(){ var sUrl = "?func=newObject"; request(sUrl,this.callback); - } + }; this.newQuestion = function(id){ var postData = "data="+id; var sUrl = "?func=newObject"; request(sUrl,this.callback,postData); - } + }; this.newAnswer = function(id){ var postData = "data="+id; var sUrl = "?func=newObject"; request(sUrl,this.callback,postData); - } + }; this.deleteAnswer = function(id){ var postData = "data="+id; var sUrl = "?func=deleteAnswer"; request(sUrl,this.callback,postData); - } + }; this.deleteQuestion = function(id){ var postData = "data="+id; var sUrl = "?func=deleteQuestion"; request(sUrl,this.callback,postData); - } + }; this.deleteSection = function(id){ var postData = "data="+id; var sUrl = "?func=deleteSection"; request(sUrl,this.callback,postData); - } + }; }(); diff --git a/www/extras/wobject/Survey/editsurvey/object.js b/www/extras/wobject/Survey/editsurvey/object.js index de9a1ba2c..ea6bb2b0a 100644 --- a/www/extras/wobject/Survey/editsurvey/object.js +++ b/www/extras/wobject/Survey/editsurvey/object.js @@ -1,5 +1,4 @@ - -/*global Survey, YAHOO */ +/*global Survey, YAHOO, alert, initHoverHelp, window */ if (typeof Survey === "undefined") { var Survey = {}; } @@ -55,7 +54,7 @@ Survey.ObjectTemplate = (function(){ // Remove all hover-help var hovers = document.getElementsByClassName('wg-hoverhelp'); - for (i = 0; i < hovers.length; i++) { + for (var i = 0; i < hovers.length; i++) { var hover = hovers[i]; if (!hover) { continue; @@ -128,14 +127,14 @@ Survey.ObjectTemplate = (function(){ this.submit(); } } - } + }; btns[btns.length] = { text: "Remove Default Type", handler: function(){ document.getElementById('removetype').value = 1; this.submit(); } - } + }; } dialog = new YAHOO.widget.Dialog(type, {