Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/controllers/poll_text_responses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,19 @@ class PollTextResponsesController < ApplicationController
before_action :set_poll

def create
session_start = params[:poll_text_response][:session_start]&.to_i || 0

# Check if this poll belongs to a survey and if resubmission is allowed
if @poll.survey.present? && !@poll.survey.can_user_submit?(current_user)
render json: { error: "Survey does not allow resubmission" }, status: :forbidden
return
end

# Create a new text response with the session_start
@text_response = @poll.poll_text_responses.build(
user: current_user,
text_content: params[:poll_text_response][:text_content],
session_start: session_start,
)

if @text_response.save
Expand Down
40 changes: 30 additions & 10 deletions app/controllers/poll_votes_controller.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
class PollVotesController < ApplicationController
before_action :authenticate_user!, only: %i[create]

POLL_VOTES_PERMITTED_PARMS = %i[poll_option_id].freeze
POLL_VOTES_PERMITTED_PARMS = %i[poll_option_id session_start].freeze

def show
@poll = Poll.find(params[:id]) # Querying the poll instead of the poll vote
Expand All @@ -16,14 +16,34 @@ def show
def create
poll_option = PollOption.find(poll_vote_params[:poll_option_id])
poll = poll_option.poll

poll_vote = PollVote.find_or_initialize_by(
user_id: current_user.id,
poll_id: poll.id,
)

poll_vote.poll_option_id = poll_option.id
poll_vote.save!
session_start = poll_vote_params[:session_start]&.to_i || 0

# Check if this poll belongs to a survey and if resubmission is allowed
if poll.survey.present? && !poll.survey.can_user_submit?(current_user)
render json: { error: "Survey does not allow resubmission" }, status: :forbidden
return
end

# For survey polls, always create new votes with session_start
# For regular polls, use the old behavior of updating existing votes
if poll.survey.present?
# Survey poll - create new vote with session
poll_vote = PollVote.new(
user_id: current_user.id,
poll_id: poll.id,
poll_option_id: poll_option.id,
session_start: session_start,
)
poll_vote.save!
else
# Regular poll - update existing vote or create new one
poll_vote = PollVote.find_or_initialize_by(
user_id: current_user.id,
poll_id: poll.id,
)
poll_vote.poll_option_id = poll_option.id
poll_vote.save!
end

render json: { voting_data: poll.voting_data,
poll_id: poll.id,
Expand All @@ -36,4 +56,4 @@ def create
def poll_vote_params
params.require(:poll_vote).permit(POLL_VOTES_PERMITTED_PARMS)
end
end
end
7 changes: 5 additions & 2 deletions app/controllers/subforems_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ def index
end

def new
# Let's just not show the survey for now — still WIP
# @survey = Survey.find(ENV["SUBFOREM_SURVEY_ID"].to_i) if ENV["SUBFOREM_SURVEY_ID"].present?
# For testing purposes, use the first survey if available
return unless Rails.env.test?

# In test environment, use the first survey that allows resubmission
@survey = Survey.where(allow_resubmission: true).first || Survey.first
end

def edit
Expand Down
39 changes: 34 additions & 5 deletions app/controllers/surveys_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,51 @@ class SurveysController < ApplicationController
def votes
survey = Survey.find(params[:id])

# Find all of the current user's votes for the polls in this survey
# Get the latest session for this user and survey
latest_session = survey.get_latest_session(current_user)

# Check if user can submit this survey
can_submit = survey.can_user_submit?(current_user)
completed = survey.completed_by_user?(current_user)

# Generate new session number if resubmission is allowed and survey is completed
new_session = nil
if completed && survey.allow_resubmission?
new_session = survey.generate_new_session(current_user)
end

# Determine which session to show votes from
# If resubmission is allowed and survey is completed, show empty votes (fresh start)
# Otherwise, show votes from the latest session
session_to_show = if completed && survey.allow_resubmission?
new_session # Use new session (which will have no votes yet)
else
latest_session # Use latest session (which has existing votes)
end

# Find all of the current user's votes for the polls in this survey in the session to show
# and format them into a Hash of { poll_id => poll_option_id }
user_votes = current_user.poll_votes
.where(poll_id: survey.poll_ids)
.where(poll_id: survey.poll_ids, session_start: session_to_show)
.pluck(:poll_id, :poll_option_id)
.to_h

# Find all of the current user's text responses for the polls in this survey
# Find all of the current user's text responses for the polls in this survey in the session to show
user_text_responses = current_user.poll_text_responses
.where(poll_id: survey.poll_ids)
.where(poll_id: survey.poll_ids, session_start: session_to_show)
.pluck(:poll_id, :text_content)
.to_h

# Merge votes and text responses
all_responses = user_votes.merge(user_text_responses)

render json: { votes: all_responses }
render json: {
votes: all_responses,
can_submit: can_submit,
completed: completed,
allow_resubmission: survey.allow_resubmission,
current_session: latest_session,
new_session: new_session
}
end
end
8 changes: 4 additions & 4 deletions app/liquid_tags/poll_tag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ class PollTag < LiquidTagBase
return
}
var csrfToken = tokenMeta.getAttribute('content')
var optionId = e.target.dataset.optionId

var optionId = e.target.closest('.ltag-polloption').dataset.optionId
#{' '}
// Handle different poll types
if (pollType === 'multiple_choice') {
// For multiple choice, toggle the checkbox and submit all selected options
Expand Down Expand Up @@ -106,9 +106,9 @@ class PollTag < LiquidTagBase
var poll = document.getElementById('poll_'+pollId)
var selectedOptions = poll.querySelectorAll('input[type="checkbox"]:checked')
var optionIds = Array.from(selectedOptions).map(function(checkbox) {
return checkbox.dataset.optionId
return checkbox.closest('.ltag-polloption').dataset.optionId
})

#{' '}
// Submit all selected options
optionIds.forEach(function(optionId) {
window.fetch('/poll_votes', {
Expand Down
109 changes: 83 additions & 26 deletions app/liquid_tags/survey_tag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ def validate_contexts
const totalPolls = polls.length;
let currentPollIndex = 0; // Default to the first poll
let pendingVotes = {}; // Store pending votes for submission
let currentSession = Math.floor(Math.random() * 1000000); // Generate random session number (0-999999)

// --- Define UI update function (used by everyone) ---
function updateUI() {
Expand Down Expand Up @@ -165,7 +166,7 @@ def validate_contexts
}
#{' '}
const selectedOptions = pollElement.querySelectorAll('input[type="checkbox"]:checked');
pendingVotes[pollId] = Array.from(selectedOptions).map(opt => opt.dataset.optionId);
pendingVotes[pollId] = Array.from(selectedOptions).map(opt => opt.closest('.survey-poll-option').dataset.optionId);
#{' '}
// Check if any option is selected to enable next button
const hasSelection = selectedOptions.length > 0;
Expand Down Expand Up @@ -219,7 +220,7 @@ def validate_contexts
window.fetch('/poll_votes', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ poll_vote: { poll_option_id: optionId } }),
body: JSON.stringify({ poll_vote: { poll_option_id: optionId, session_start: currentSession } }),
credentials: 'same-origin',
})
);
Expand All @@ -232,7 +233,7 @@ def validate_contexts
headers: { 'X-CSRF-Token': csrfToken, 'Content-Type': 'application/json' },
body: JSON.stringify({#{' '}
poll_text_response: {#{' '}
text_content: voteData.content#{' '}
text_content: voteData.content, session_start: currentSession#{' '}
}#{' '}
}),
credentials: 'same-origin',
Expand All @@ -244,7 +245,7 @@ def validate_contexts
window.fetch('/poll_votes', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ poll_vote: { poll_option_id: voteData } }),
body: JSON.stringify({ poll_vote: { poll_option_id: voteData, session_start: currentSession } }),
credentials: 'same-origin',
})
);
Expand Down Expand Up @@ -306,47 +307,103 @@ def validate_contexts
updateUI();#{' '}
}#{' '}
});
#{' '}
// Always attach event listeners first to ensure functionality
polls.forEach(poll => {
if (poll.dataset.pollType === 'text_input') {
const textarea = poll.querySelector('.survey-text-input');
if (textarea) {
textarea.addEventListener('input', () => handleTextInput(poll));
}
} else {
poll.querySelectorAll('.survey-poll-option').forEach(option => {
option.addEventListener('click', () => handleSelection(option));
});
}
});
#{' '}
// Fetch user's state and hydrate the UI
window.fetch(`/surveys/${surveyId}/votes`)
.then(response => response.ok ? response.json() : Promise.reject('Could not fetch survey votes.'))
.then(json => {
const userVotes = json.votes || {};
polls.forEach(poll => {
const votedOptionIds = userVotes[poll.dataset.pollId];
if (votedOptionIds) setAndLockAnsweredPoll(poll, votedOptionIds);
const canSubmit = json.can_submit !== false;
const completed = json.completed === true;
const allowResubmission = json.allow_resubmission === true;
#{' '}
console.log('Survey state:', {
completed,
allowResubmission,
canSubmit,
currentSession,
userVotes: Object.keys(userVotes).length
});
const correctStartingIndex = Array.from(polls).findIndex(p => !p.classList.contains('is-answered'));
if (correctStartingIndex === -1) {#{' '}
#{' '}
// For resubmission surveys, always start fresh
if (allowResubmission) {
console.log('Resubmission survey - starting fresh from first poll');
// Clear all previous answers and start from first poll
polls.forEach(poll => {
poll.classList.remove('is-answered');
poll.querySelectorAll('.survey-poll-option').forEach(option => {
option.classList.remove('user-selected');
option.classList.remove('disabled');
const input = option.querySelector('input');
if (input) input.checked = false;
});
const textarea = poll.querySelector('.survey-text-input');
if (textarea) {
textarea.value = '';
textarea.disabled = false;
const feedback = poll.querySelector('.survey-text-input-feedback');
if (feedback) feedback.style.display = 'none';
}
});
currentPollIndex = 0;
#{' '}
updateUI();
return; // Exit early - resubmission surveys always start fresh
}
#{' '}
// For non-resubmission surveys, check if completed
if (completed) {
console.log('Non-resubmission survey completed - showing completion message');
if (pollsContainer) pollsContainer.style.display = 'none';
if (navigation) navigation.style.display = 'none';
if (finalMessage) finalMessage.style.display = 'block';
return;
}
if (correctStartingIndex !== currentPollIndex) {
#{' '}
// Normal flow for non-resubmission surveys - show existing answers and jump to first unanswered
console.log('Normal survey flow - showing existing answers and jumping to first unanswered');
polls.forEach(poll => {
const votedOptionIds = userVotes[poll.dataset.pollId];
if (votedOptionIds) setAndLockAnsweredPoll(poll, votedOptionIds);
});
const correctStartingIndex = Array.from(polls).findIndex(p => !p.classList.contains('is-answered'));
console.log('Correct starting index:', correctStartingIndex, 'Current poll index:', currentPollIndex);
if (correctStartingIndex !== -1 && correctStartingIndex !== currentPollIndex) {
console.log('Adjusting poll index from', currentPollIndex, 'to', correctStartingIndex);
currentPollIndex = correctStartingIndex;
updateUI();
}

})
.catch(error => {
console.error("Survey Error:", error);
// If survey state fetch fails, still attach event listeners for fresh start
polls.forEach(poll => {
if (!poll.classList.contains('is-answered')) {
if (poll.dataset.pollType === 'text_input') {
// Handle text input polls
const textarea = poll.querySelector('.survey-text-input');
if (textarea) {
textarea.addEventListener('input', () => handleTextInput(poll));
}
} else {
// Handle regular poll options
poll.querySelectorAll('.survey-poll-option').forEach(option => {
option.addEventListener('click', () => handleSelection(option));
});
if (poll.dataset.pollType === 'text_input') {
const textarea = poll.querySelector('.survey-text-input');
if (textarea) {
textarea.addEventListener('input', () => handleTextInput(poll));
}
} else {
poll.querySelectorAll('.survey-poll-option').forEach(option => {
option.addEventListener('click', () => handleSelection(option));
});
}
});
})
.catch(error => {
console.error("Survey Error:", error);
surveyElement.innerHTML = "<p>Sorry, this survey could not be loaded.</p>";
});

} else {
Expand Down
30 changes: 23 additions & 7 deletions app/models/poll.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,22 @@ def vote_previously_recorded_for?(user_id:)
false
end

# We only want a user to be able to vote (or abstain) once per poll per session.
# This query helps validate that constraint for session-based voting.
#
# @param user_id [Integer]
# @param session_start [Integer]
#
# @return [TrueClass] if the given user has a registered vote or skip in this session
# @return [FalseClass] if the given user does not have a poll vote
# nor poll skip in this session.
def vote_previously_recorded_for_in_session?(user_id:, session_start:)
return true if poll_votes.where(user_id: user_id, session_start: session_start).any?
return true if poll_skips.where(user_id: user_id, session_start: session_start).any?

false
end

def voting_data
{ votes_count: poll_votes_count, votes_distribution: poll_options.pluck(:id, :poll_votes_count) }
end
Expand Down Expand Up @@ -80,12 +96,12 @@ def move_to_position(new_position)
# Shift other polls in the same survey
if new_position < position
# Moving up: increment positions of polls between new_position and current position
survey.polls.where('position >= ? AND position < ?', new_position, position)
.update_all('position = position + 1')
survey.polls.where("position >= ? AND position < ?", new_position, position)
.update_all("position = position + 1")
elsif new_position > position
# Moving down: decrement positions of polls between current position and new_position
survey.polls.where('position > ? AND position <= ?', position, new_position)
.update_all('position = position - 1')
survey.polls.where("position > ? AND position <= ?", position, new_position)
.update_all("position = position - 1")
end

# Update this poll's position using update_column to skip validations
Expand All @@ -101,10 +117,10 @@ def create_poll_options
poll_options_input_array.each_with_index do |input, index|
supplementary_text = poll_options_supplementary_text_array&.dig(index)
PollOption.create!(
markdown: input,
poll_id: id,
markdown: input,
poll_id: id,
position: index,
supplementary_text: supplementary_text
supplementary_text: supplementary_text,
)
end
end
Expand Down
Loading
Loading