From 2ce5b50c9702b0b2a3a6b366a8ebf5fc47a88c2d Mon Sep 17 00:00:00 2001 From: Qaisyousuf Date: Mon, 11 Aug 2025 11:28:32 +0200 Subject: [PATCH] Add frontend condition logic for questionnaire --- .../Controllers/QuestionnaireController.cs | 182 ++++++++ .../Admin/Views/Questionnaire/SetLogic.cshtml | 412 +++++++++++++---- .../QuestionnaireResponseController.cs | 26 +- .../ResponseAnswerViewModel.cs | 1 + .../DisplayQuestionnaire.cshtml | 437 ++++++++---------- 5 files changed, 719 insertions(+), 339 deletions(-) diff --git a/Web/Areas/Admin/Controllers/QuestionnaireController.cs b/Web/Areas/Admin/Controllers/QuestionnaireController.cs index 6cf121f..0d8bd5c 100644 --- a/Web/Areas/Admin/Controllers/QuestionnaireController.cs +++ b/Web/Areas/Admin/Controllers/QuestionnaireController.cs @@ -773,6 +773,188 @@ namespace Web.Areas.Admin.Controllers return RedirectToAction(nameof(SetLogic), new { id = model.QuestionnaireId }); } } + // Add these methods to your existing controller (the one with SetLogic and SaveLogic) + + [HttpPost] + public async Task SaveAnswerCondition([FromBody] SaveAnswerConditionRequest request) + { + try + { + // Validate the request + if (request == null || request.AnswerId <= 0) + { + return Json(new { success = false, message = "Invalid answer ID provided." }); + } + + // Get the questionnaire with all related data + var questionnaire = _questionnaire.GetQuestionnaireWithQuestionAndAnswer(request.QuestionnaireId); + + if (questionnaire == null) + { + return Json(new { success = false, message = "Questionnaire not found." }); + } + + // Find the specific answer + var answer = questionnaire.Questions + .SelectMany(q => q.Answers) + .FirstOrDefault(a => a.Id == request.AnswerId); + + if (answer == null) + { + return Json(new { success = false, message = "Answer not found." }); + } + + // Validate and store the condition JSON + if (string.IsNullOrEmpty(request.ConditionJson)) + { + // Clear the condition (set to continue) + answer.ConditionJson = null; + } + else + { + // Validate JSON format + try + { + var testParse = System.Text.Json.JsonSerializer.Deserialize(request.ConditionJson); + answer.ConditionJson = request.ConditionJson; + } + catch (System.Text.Json.JsonException) + { + return Json(new { success = false, message = "Invalid condition data format." }); + } + } + + // Save changes using your repository pattern + await _questionnaire.Update(questionnaire); + await _questionnaire.commitAsync(); + + // Generate summary for response + string summary = GetConditionSummaryFromJson(answer.ConditionJson); + + return Json(new + { + success = true, + message = "Answer condition saved successfully!", + summary = summary + }); + } + catch (Exception ex) + { + // Log error if you have logging configured + // _logger?.LogError(ex, "Error saving answer condition for AnswerId: {AnswerId}", request.AnswerId); + return Json(new + { + success = false, + message = "An error occurred while saving the condition. Please try again." + }); + } + } + + [HttpPost] + public async Task ResetAnswerCondition([FromBody] ResetAnswerConditionRequest request) + { + try + { + if (request == null || request.AnswerId <= 0) + { + return Json(new { success = false, message = "Invalid answer ID provided." }); + } + + var questionnaire = _questionnaire.GetQuestionnaireWithQuestionAndAnswer(request.QuestionnaireId); + + if (questionnaire == null) + { + return Json(new { success = false, message = "Questionnaire not found." }); + } + + var answer = questionnaire.Questions + .SelectMany(q => q.Answers) + .FirstOrDefault(a => a.Id == request.AnswerId); + + if (answer == null) + { + return Json(new { success = false, message = "Answer not found." }); + } + + // Reset to default (Continue) + answer.ConditionJson = null; + + await _questionnaire.Update(questionnaire); + await _questionnaire.commitAsync(); + + return Json(new + { + success = true, + message = "Answer condition reset successfully!" + }); + } + catch (Exception ex) + { + // _logger?.LogError(ex, "Error resetting answer condition for AnswerId: {AnswerId}", request.AnswerId); + return Json(new + { + success = false, + message = "An error occurred while resetting the condition. Please try again." + }); + } + } + + // Helper method to generate summary from JSON + private string GetConditionSummaryFromJson(string conditionJson) + { + if (string.IsNullOrEmpty(conditionJson)) + { + return "Continue to the next question normally"; + } + + try + { + var condition = System.Text.Json.JsonSerializer.Deserialize(conditionJson); + if (condition == null) return "Continue to the next question normally"; + + switch (condition.ActionType) + { + case 0: // Continue + return "Continue to the next question normally"; + case 1: // SkipToQuestion + return condition.TargetQuestionNumber.HasValue + ? $"Jump to Question {condition.TargetQuestionNumber}" + : "Jump to specific question"; + case 2: // SkipCount + return $"Skip {condition.SkipCount ?? 1} question(s)"; + case 3: // EndSurvey + return "End the survey immediately"; + default: + return "Continue to the next question normally"; + } + } + catch + { + return "Continue to the next question normally"; + } + } + + // Request models - Add these classes to your project + public class SaveAnswerConditionRequest + { + public int AnswerId { get; set; } + public string ConditionJson { get; set; } = string.Empty; + public int QuestionnaireId { get; set; } + } + + public class ResetAnswerConditionRequest + { + public int AnswerId { get; set; } + public int QuestionnaireId { get; set; } + } + + public class ConditionData + { + public int ActionType { get; set; } + public int? TargetQuestionNumber { get; set; } + public int? SkipCount { get; set; } + public string? EndMessage { get; set; } + } } diff --git a/Web/Areas/Admin/Views/Questionnaire/SetLogic.cshtml b/Web/Areas/Admin/Views/Questionnaire/SetLogic.cshtml index 4169da4..feca336 100644 --- a/Web/Areas/Admin/Views/Questionnaire/SetLogic.cshtml +++ b/Web/Areas/Admin/Views/Questionnaire/SetLogic.cshtml @@ -305,32 +305,88 @@ } .answer-header { - background: #e9ecef; - padding: 15px 20px; + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + padding: 18px 20px; border-bottom: 1px solid #dee2e6; - display: flex; - justify-content: space-between; - align-items: center; + position: relative; } .answer-card.has-condition .answer-header { - background: #ff6b6b; + background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%); color: white; } + .answer-header-content { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 15px; + } + + .answer-title-section { + flex: 1; + min-width: 0; + } + + .answer-number { + font-size: 0.8rem; + font-weight: 600; + color: #6c5ce7; + margin-bottom: 5px; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .answer-card.has-condition .answer-number { + color: rgba(255, 255, 255, 0.9); + } + .answer-title { + font-size: 1.1rem; font-weight: 600; margin: 0; + line-height: 1.4; + color: #2c3e50; + word-wrap: break-word; + } + + .answer-card.has-condition .answer-title { + color: white; + } + + .answer-badges { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-end; } .active-badge { - background: white; + background: rgba(255, 255, 255, 0.95); color: #ff6b6b; - padding: 4px 10px; - border-radius: 12px; + padding: 6px 12px; + border-radius: 15px; font-size: 0.75rem; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.5px; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + } + + .condition-type-badge { + background: rgba(108, 92, 231, 0.1); + color: #6c5ce7; + padding: 4px 10px; + border-radius: 12px; + font-size: 0.7rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .answer-card.has-condition .condition-type-badge { + background: rgba(255, 255, 255, 0.2); + color: white; } .answer-content { @@ -348,7 +404,7 @@ } .btn-save-answer { - background: #28a745; + background: linear-gradient(135deg, #28a745 0%, #20c997 100%); border: none; color: white; padding: 8px 16px; @@ -359,15 +415,24 @@ transition: all 0.2s ease; text-transform: uppercase; letter-spacing: 0.5px; + box-shadow: 0 2px 8px rgba(40, 167, 69, 0.3); } .btn-save-answer:hover { - background: #218838; + background: linear-gradient(135deg, #218838 0%, #1ea472 100%); transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4); + } + + .btn-save-answer:disabled { + background: #6c757d; + cursor: not-allowed; + transform: none; + box-shadow: none; } .btn-reset-answer { - background: #dc3545; + background: linear-gradient(135deg, #dc3545 0%, #c82333 100%); border: none; color: white; padding: 8px 16px; @@ -378,41 +443,55 @@ transition: all 0.2s ease; text-transform: uppercase; letter-spacing: 0.5px; + box-shadow: 0 2px 8px rgba(220, 53, 69, 0.3); } .btn-reset-answer:hover { - background: #c82333; + background: linear-gradient(135deg, #c82333 0%, #a71e2a 100%); transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4); + } + + .btn-reset-answer:disabled { + background: #6c757d; + cursor: not-allowed; + transform: none; + box-shadow: none; } .answer-status { font-size: 0.8rem; - padding: 4px 8px; - border-radius: 4px; + padding: 6px 12px; + border-radius: 15px; font-weight: 500; margin-right: auto; + text-transform: uppercase; + letter-spacing: 0.5px; } .answer-status.saved { - background: #d4edda; + background: linear-gradient(135deg, #d4edda 0%, #c3e6cb 100%); color: #155724; + border: 1px solid #b8dabd; } .answer-status.modified { - background: #fff3cd; + background: linear-gradient(135deg, #fff3cd 0%, #ffeaa7 100%); color: #856404; + border: 1px solid #ffeaa7; } - .answer-text { - font-weight: 600; - color: #495057; - font-size: 1rem; - margin: 0 0 20px 0; - padding: 12px; - background: white; - border-radius: 6px; - border-left: 4px solid #6c5ce7; - } + .answer-status.saving { + background: linear-gradient(135deg, #cce7ff 0%, #b3d9ff 100%); + color: #0066cc; + border: 1px solid #99ccff; + } + + .answer-status.error { + background: linear-gradient(135deg, #f8d7da 0%, #f5c6cb 100%); + color: #721c24; + border: 1px solid #f5c6cb; + } .form-group { margin-bottom: 20px; @@ -552,6 +631,17 @@ grid-template-columns: 1fr; } + .answer-header-content { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + + .answer-badges { + align-items: flex-start; + flex-direction: row; + } + } @@ -612,6 +702,7 @@
+ @Html.AntiForgeryToken()
@@ -653,19 +744,27 @@ var answer = question.Answers[answerIndex]; var hasCondition = answer.ActionType != ConditionActionType.Continue; -
+
-

📝 Answer @(answerIndex + 1)

- @if (hasCondition) - { - - Active - - } +
+
+
Answer @(answerIndex + 1)
+

@answer.AnswerText

+
+
+ @if (hasCondition) + { + + Active + + + @GetConditionTypeBadge(answer.ActionType) + + } +
+
-
@answer.AnswerText
-
@@ -829,14 +928,12 @@
Back to Questionnaires - + @@ -895,11 +992,11 @@ updateConditionSummary(selectElement); updateQuestionHeaderStatus(); + updateAnswerCardBadges(answerId, selectedValue); markAnswerAsModified(answerId); } function updateConditionSummary(selectElement) { - const answerId = selectElement.getAttribute('data-answer-id'); const selectedValue = selectElement.value; const summaryElement = selectElement.closest('.answer-content').querySelector('.summary-text'); @@ -933,6 +1030,36 @@ } } + function updateAnswerCardBadges(answerId, actionValue) { + const answerCard = document.querySelector(`[data-answer-id="${answerId}"]`); + const badgesContainer = answerCard.querySelector('.answer-badges'); + + // Remove existing badges + badgesContainer.innerHTML = ''; + + if (actionValue !== '0') { + // Add active badge + const activeBadge = document.createElement('span'); + activeBadge.className = 'active-badge'; + activeBadge.innerHTML = ' Active'; + badgesContainer.appendChild(activeBadge); + + // Add condition type badge + const typeBadge = document.createElement('span'); + typeBadge.className = 'condition-type-badge'; + switch(actionValue) { + case '1': typeBadge.textContent = 'Skip To'; break; + case '2': typeBadge.textContent = 'Skip Count'; break; + case '3': typeBadge.textContent = 'End Survey'; break; + } + badgesContainer.appendChild(typeBadge); + + answerCard.classList.add('has-condition'); + } else { + answerCard.classList.remove('has-condition'); + } + } + function updateQuestionHeaderStatus() { document.querySelectorAll('.question-card').forEach(card => { const actionSelects = card.querySelectorAll('.action-type-select'); @@ -951,36 +1078,33 @@ } else { header.classList.remove('has-logic'); } - - // Update answer cards - actionSelects.forEach(select => { - const answerCard = select.closest('.answer-card'); - if (select.value !== '0') { - answerCard.classList.add('has-condition'); - } else { - answerCard.classList.remove('has-condition'); - } - }); }); } function markAnswerAsModified(answerId) { - const answerCard = document.querySelector(`[data-answer-id="${answerId}"]`).closest('.answer-card'); + const answerCard = document.querySelector(`[data-answer-id="${answerId}"]`); const statusElement = answerCard.querySelector('.answer-status'); - if (statusElement && !statusElement.classList.contains('modified')) { + if (statusElement && !statusElement.classList.contains('modified') && !statusElement.classList.contains('saving')) { statusElement.textContent = '● Modified'; statusElement.className = 'answer-status modified'; } } function saveAnswer(answerId) { - const answerCard = document.querySelector(`[data-answer-id="${answerId}"]`).closest('.answer-card'); + const answerCard = document.querySelector(`[data-answer-id="${answerId}"]`); const actionSelect = answerCard.querySelector('.action-type-select'); const statusElement = answerCard.querySelector('.answer-status'); + const saveButton = answerCard.querySelector('.btn-save-answer'); if (!actionSelect) return; + // Show saving status + statusElement.textContent = '⏳ Saving...'; + statusElement.className = 'answer-status saving'; + saveButton.disabled = true; + saveButton.innerHTML = ' Saving'; + const actionType = actionSelect.value; let condition = { ActionType: parseInt(actionType) @@ -1008,42 +1132,129 @@ break; } - // Here you could make an AJAX call to save individual answer - // For now, we'll just show success message and update status - statusElement.textContent = '✓ Saved'; - statusElement.className = 'answer-status saved'; - updateQuestionHeaderStatus(); - showToast('Answer condition saved successfully!', 'success'); + // Get anti-forgery token + const token = document.querySelector('input[name="__RequestVerificationToken"]'); - // Optional: Make AJAX call to server - // fetch('/YourController/SaveAnswerCondition', { - // method: 'POST', - // headers: { - // 'Content-Type': 'application/json' - // }, - // body: JSON.stringify({ - // AnswerId: answerId, - // ConditionJson: JSON.stringify(condition) - // }) - // }); + const requestData = { + AnswerId: parseInt(answerId), + ConditionJson: JSON.stringify(condition), + QuestionnaireId: @Model.QuestionnaireId + }; + + // Create headers with anti-forgery token + const headers = { + 'Content-Type': 'application/json' + }; + + if (token) { + headers['RequestVerificationToken'] = token.value; + } + + // Make AJAX call to save individual answer + fetch('@Url.Action("SaveAnswerCondition")', { + method: 'POST', + headers: headers, + body: JSON.stringify(requestData) + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .then(data => { + if (data.success) { + statusElement.textContent = '✓ Saved'; + statusElement.className = 'answer-status saved'; + + // Update the summary if provided + if (data.summary) { + const summaryElement = answerCard.querySelector('.summary-text'); + if (summaryElement) { + summaryElement.textContent = data.summary; + } + } + + showToast('Answer condition saved successfully!', 'success'); + } else { + statusElement.textContent = '✗ Error'; + statusElement.className = 'answer-status error'; + showToast(data.message || 'Failed to save answer condition.', 'error'); + } + }) + .catch(error => { + console.error('Error:', error); + statusElement.textContent = '✗ Error'; + statusElement.className = 'answer-status error'; + showToast('An error occurred while saving the answer condition.', 'error'); + }) + .finally(() => { + // Re-enable save button + saveButton.disabled = false; + saveButton.innerHTML = ' Save'; + updateQuestionHeaderStatus(); + }); } function resetAnswer(answerId) { if (confirm('Are you sure you want to reset this answer to "Continue to next question"?')) { - const answerCard = document.querySelector(`[data-answer-id="${answerId}"]`).closest('.answer-card'); - const actionSelect = answerCard.querySelector('.action-type-select'); + const answerCard = document.querySelector(`[data-answer-id="${answerId}"]`); const statusElement = answerCard.querySelector('.answer-status'); + const resetButton = answerCard.querySelector('.btn-reset-answer'); - if (actionSelect) { - actionSelect.value = '0'; - toggleConditionOptions(actionSelect); + // Show loading state + statusElement.textContent = '⏳ Resetting...'; + statusElement.className = 'answer-status saving'; + resetButton.disabled = true; + resetButton.innerHTML = ' Resetting'; - statusElement.textContent = '↻ Reset'; - statusElement.className = 'answer-status modified'; + const token = document.querySelector('input[name="__RequestVerificationToken"]'); + const headers = { + 'Content-Type': 'application/json' + }; - updateQuestionHeaderStatus(); - showToast('Answer condition reset successfully!', 'success'); + if (token) { + headers['RequestVerificationToken'] = token.value; } + + fetch('@Url.Action("ResetAnswerCondition")', { + method: 'POST', + headers: headers, + body: JSON.stringify({ + AnswerId: parseInt(answerId), + QuestionnaireId: @Model.QuestionnaireId + }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + // Reset the form controls + const actionSelect = answerCard.querySelector('.action-type-select'); + if (actionSelect) { + actionSelect.value = '0'; + toggleConditionOptions(actionSelect); + } + + statusElement.textContent = '↻ Reset'; + statusElement.className = 'answer-status modified'; + showToast('Answer condition reset successfully!', 'success'); + } else { + statusElement.textContent = '✗ Error'; + statusElement.className = 'answer-status error'; + showToast(data.message || 'Failed to reset answer condition.', 'error'); + } + }) + .catch(error => { + console.error('Error:', error); + statusElement.textContent = '✗ Error'; + statusElement.className = 'answer-status error'; + showToast('An error occurred while resetting the answer condition.', 'error'); + }) + .finally(() => { + resetButton.disabled = false; + resetButton.innerHTML = ' Reset'; + updateQuestionHeaderStatus(); + }); } } @@ -1055,13 +1266,16 @@ position: fixed; top: 20px; right: 20px; - background: ${type === 'success' ? '#28a745' : '#dc3545'}; + background: ${type === 'success' ? 'linear-gradient(135deg, #28a745, #20c997)' : 'linear-gradient(135deg, #dc3545, #c82333)'}; color: white; - padding: 12px 20px; - border-radius: 6px; + padding: 15px 25px; + border-radius: 8px; z-index: 10000; opacity: 0; - transition: opacity 0.3s ease; + transition: all 0.3s ease; + box-shadow: 0 4px 20px ${type === 'success' ? 'rgba(40, 167, 69, 0.3)' : 'rgba(220, 53, 69, 0.3)'}; + font-weight: 500; + max-width: 300px; `; document.body.appendChild(toast); @@ -1069,17 +1283,19 @@ // Fade in setTimeout(() => { toast.style.opacity = '1'; + toast.style.transform = 'translateY(0)'; }, 100); - // Remove after 3 seconds + // Remove after 4 seconds setTimeout(() => { toast.style.opacity = '0'; + toast.style.transform = 'translateY(-20px)'; setTimeout(() => { if (document.body.contains(toast)) { document.body.removeChild(toast); } }, 300); - }, 3000); + }, 4000); } function clearAllConditions() { @@ -1096,7 +1312,7 @@ }); updateQuestionHeaderStatus(); - alert('✅ All conditions have been cleared!\n\nDon\'t forget to save your changes.'); + showToast('All conditions have been cleared! Don\'t forget to save your changes.', 'success'); } } @@ -1108,8 +1324,9 @@ const actionSelect = container.querySelector('.action-type-select'); if (actionSelect && actionSelect.value !== '0') { const summaryText = container.querySelector('.summary-text').textContent; - const answerText = container.querySelector('.answer-text').textContent.trim(); - conditions.push(`• ${answerText} → ${summaryText}`); + const answerCard = actionSelect.closest('.answer-card'); + const answerTitle = answerCard.querySelector('.answer-title').textContent.trim(); + conditions.push(`• ${answerTitle} → ${summaryText}`); } }); @@ -1278,4 +1495,19 @@ return "No condition set"; } } + + string GetConditionTypeBadge(ConditionActionType actionType) + { + switch (actionType) + { + case ConditionActionType.SkipToQuestion: + return "Skip To"; + case ConditionActionType.SkipCount: + return "Skip Count"; + case ConditionActionType.EndSurvey: + return "End Survey"; + default: + return ""; + } + } } \ No newline at end of file diff --git a/Web/Controllers/QuestionnaireResponseController.cs b/Web/Controllers/QuestionnaireResponseController.cs index b34883d..9bba2c2 100644 --- a/Web/Controllers/QuestionnaireResponseController.cs +++ b/Web/Controllers/QuestionnaireResponseController.cs @@ -287,6 +287,28 @@ namespace Web.Controllers } + //private ResponseQuestionnaireViewModel MapToViewModel(Questionnaire questionnaire) + //{ + // var viewModel = new ResponseQuestionnaireViewModel + // { + // Id = questionnaire.Id, + // Title = questionnaire.Title, + // Description = questionnaire.Description, + // Questions = questionnaire.Questions.Select(q => new ResponseQuestionViewModel + // { + // Id = q.Id, + // Text = q.Text, + // Type = q.Type, + // Answers = q.Answers.Select(a => new ResponseAnswerViewModel + // { + // Id = a.Id, + // Text = a.Text + // }).ToList() + // }).ToList() + // }; + + // return viewModel; + //} private ResponseQuestionnaireViewModel MapToViewModel(Questionnaire questionnaire) { var viewModel = new ResponseQuestionnaireViewModel @@ -302,7 +324,8 @@ namespace Web.Controllers Answers = q.Answers.Select(a => new ResponseAnswerViewModel { Id = a.Id, - Text = a.Text + Text = a.Text, + ConditionJson = a.ConditionJson // Add this line }).ToList() }).ToList() }; @@ -312,7 +335,6 @@ namespace Web.Controllers - } } diff --git a/Web/ViewModel/QuestionnaireVM/ResponseAnswerViewModel.cs b/Web/ViewModel/QuestionnaireVM/ResponseAnswerViewModel.cs index 1055cc1..b47c69f 100644 --- a/Web/ViewModel/QuestionnaireVM/ResponseAnswerViewModel.cs +++ b/Web/ViewModel/QuestionnaireVM/ResponseAnswerViewModel.cs @@ -6,5 +6,6 @@ public string? Text { get; set; } // Answer text public int? Count { get; set; } + public string? ConditionJson { get; set; } // Add this line for conditional logic } } diff --git a/Web/Views/QuestionnaireResponse/DisplayQuestionnaire.cshtml b/Web/Views/QuestionnaireResponse/DisplayQuestionnaire.cshtml index 483da39..3be82b2 100644 --- a/Web/Views/QuestionnaireResponse/DisplayQuestionnaire.cshtml +++ b/Web/Views/QuestionnaireResponse/DisplayQuestionnaire.cshtml @@ -310,7 +310,6 @@ -
@@ -369,20 +368,17 @@ } -
+

@(i + 1). @question.Text

- - @switch (question.Type) { case QuestionType.Text: @foreach (var answer in question.Answers) { - - + } break; case QuestionType.CheckBox: @@ -394,7 +390,7 @@ @foreach (var answer in question.Answers) {
- + @@ -410,7 +406,7 @@ @foreach (var answer in question.Answers) {
- +