Merge pull request #13253 from ramonlsouza/PR-11359

feat: multiple choice poll
This commit is contained in:
Anton Georgiev 2021-10-26 14:42:29 -04:00 committed by GitHub
commit c60ef3c179
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 420 additions and 245 deletions

View File

@ -34,12 +34,12 @@ trait RespondToPollReqMsgHdlr {
bus.outGW.send(msgEvent)
}
def broadcastUserRespondedToPollRespMsg(msg: RespondToPollReqMsg, pollId: String, answerId: Int, sendToId: String): Unit = {
def broadcastUserRespondedToPollRespMsg(msg: RespondToPollReqMsg, pollId: String, answerIds: Seq[Int], sendToId: String): Unit = {
val routing = Routing.addMsgToClientRouting(MessageTypes.DIRECT, liveMeeting.props.meetingProp.intId, sendToId)
val envelope = BbbCoreEnvelope(UserRespondedToPollRespMsg.NAME, routing)
val header = BbbClientMsgHeader(UserRespondedToPollRespMsg.NAME, liveMeeting.props.meetingProp.intId, sendToId)
val body = UserRespondedToPollRespMsgBody(pollId, msg.header.userId, answerId)
val body = UserRespondedToPollRespMsgBody(pollId, msg.header.userId, answerIds)
val event = UserRespondedToPollRespMsg(header, body)
val msgEvent = BbbCommonEnvCoreMsg(envelope, event)
bus.outGW.send(msgEvent)
@ -47,20 +47,24 @@ trait RespondToPollReqMsgHdlr {
for {
(pollId: String, updatedPoll: SimplePollResultOutVO) <- Polls.handleRespondToPollReqMsg(msg.header.userId, msg.body.pollId,
msg.body.questionId, msg.body.answerId, liveMeeting)
msg.body.questionId, msg.body.answerIds, liveMeeting)
} yield {
broadcastPollUpdatedEvent(msg, pollId, updatedPoll)
for {
poll <- Polls.getPoll(pollId, liveMeeting.polls)
} yield {
val answerText = poll.questions(0).answers.get(msg.body.answerId).key
broadcastUserRespondedToPollRecordMsg(msg, pollId, msg.body.answerId, answerText, poll.isSecret)
for {
answerId <- msg.body.answerIds
} yield {
val answerText = poll.questions(0).answers.get(answerId).key
broadcastUserRespondedToPollRecordMsg(msg, pollId, answerId, answerText, poll.isSecret)
}
}
for {
presenter <- Users2x.findPresenter(liveMeeting.users2x)
} yield {
broadcastUserRespondedToPollRespMsg(msg, pollId, msg.body.answerId, presenter.intId)
broadcastUserRespondedToPollRespMsg(msg, pollId, msg.body.answerIds, presenter.intId)
}
}
}

View File

@ -29,7 +29,7 @@ trait StartCustomPollReqMsgHdlr extends RightsManagementTrait {
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting)
} else {
for {
pvo <- Polls.handleStartCustomPollReqMsg(state, msg.header.userId, msg.body.pollId, msg.body.pollType, msg.body.secretPoll, msg.body.answers, msg.body.question, liveMeeting)
pvo <- Polls.handleStartCustomPollReqMsg(state, msg.header.userId, msg.body.pollId, msg.body.pollType, msg.body.secretPoll, msg.body.isMultipleResponse, msg.body.answers, msg.body.question, liveMeeting)
} yield {
broadcastEvent(msg, pvo)
}

View File

@ -30,7 +30,7 @@ trait StartPollReqMsgHdlr extends RightsManagementTrait {
PermissionCheck.ejectUserForFailedPermission(meetingId, msg.header.userId, reason, bus.outGW, liveMeeting)
} else {
for {
pvo <- Polls.handleStartPollReqMsg(state, msg.header.userId, msg.body.pollId, msg.body.pollType, msg.body.secretPoll, msg.body.question, liveMeeting)
pvo <- Polls.handleStartPollReqMsg(state, msg.header.userId, msg.body.pollId, msg.body.pollType, msg.body.secretPoll, msg.body.question, msg.body.isMultipleResponse, liveMeeting)
} yield {
broadcastEvent(msg, pvo)
}

View File

@ -12,13 +12,13 @@ import org.bigbluebutton.core.running.LiveMeeting
object Polls {
def handleStartPollReqMsg(state: MeetingState2x, userId: String, pollId: String, pollType: String, secretPoll: Boolean, questionText: String,
lm: LiveMeeting): Option[SimplePollOutVO] = {
multiResponse: Boolean, lm: LiveMeeting): Option[SimplePollOutVO] = {
def createPoll(stampedPollId: String): Option[Poll] = {
val numRespondents: Int = Users2x.numUsers(lm.users2x) - 1 // subtract the presenter
for {
poll <- PollFactory.createPoll(stampedPollId, pollType, numRespondents, None, Some(questionText), secretPoll)
poll <- PollFactory.createPoll(stampedPollId, pollType, multiResponse, numRespondents, None, Some(questionText), secretPoll)
} yield {
lm.polls.save(poll)
poll
@ -146,12 +146,12 @@ object Polls {
}
}
def handleRespondToPollReqMsg(requesterId: String, pollId: String, questionId: Int, answerId: Int,
def handleRespondToPollReqMsg(requesterId: String, pollId: String, questionId: Int, answerIds: Seq[Int],
lm: LiveMeeting): Option[(String, SimplePollResultOutVO)] = {
for {
poll <- getSimplePollResult(pollId, lm.polls)
pvo <- handleRespondToPoll(poll, requesterId, pollId, questionId, answerId, lm)
pvo <- handleRespondToPoll(poll, requesterId, pollId, questionId, answerIds, lm)
} yield {
(pollId, pvo)
}
@ -169,12 +169,12 @@ object Polls {
}
def handleStartCustomPollReqMsg(state: MeetingState2x, requesterId: String, pollId: String, pollType: String, secretPoll: Boolean,
answers: Seq[String], questionText: String, lm: LiveMeeting): Option[SimplePollOutVO] = {
multiResponse: Boolean, answers: Seq[String], questionText: String, lm: LiveMeeting): Option[SimplePollOutVO] = {
def createPoll(stampedPollId: String): Option[Poll] = {
val numRespondents: Int = Users2x.numUsers(lm.users2x) - 1 // subtract the presenter
for {
poll <- PollFactory.createPoll(stampedPollId, pollType, numRespondents, Some(answers), Some(questionText), secretPoll)
poll <- PollFactory.createPoll(stampedPollId, pollType, multiResponse, numRespondents, Some(answers), Some(questionText), secretPoll)
} yield {
lm.polls.save(poll)
poll
@ -215,7 +215,7 @@ object Polls {
// Helper methods:
//
private def handleRespondToPoll(poll: SimplePollResultOutVO, requesterId: String, pollId: String, questionId: Int,
answerId: Int, lm: LiveMeeting): Option[SimplePollResultOutVO] = {
answerIds: Seq[Int], lm: LiveMeeting): Option[SimplePollResultOutVO] = {
/*
* Hardcode to zero as we are assuming the poll has only one question.
* Our data model supports multiple question polls but for this
@ -225,7 +225,7 @@ object Polls {
val questionId = 0
def storePollResult(responder: Responder): Option[SimplePollResultOutVO] = {
respondToQuestion(poll.id, questionId, answerId, responder, lm.polls)
respondToQuestion(poll.id, questionId, answerIds, responder, lm.polls)
for {
updatedPoll <- getSimplePollResult(poll.id, lm.polls)
} yield updatedPoll
@ -415,12 +415,12 @@ object Polls {
}
}
def respondToQuestion(pollId: String, questionID: Int, responseID: Int, responder: Responder, polls: Polls) {
def respondToQuestion(pollId: String, questionID: Int, responseIDs: Seq[Int], responder: Responder, polls: Polls) {
polls.polls.get(pollId) match {
case Some(p) => {
if (!p.getResponders().exists(_ == responder)) {
if (!p.getResponders().contains(responder)) {
p.addResponder(responder)
p.respondToQuestion(questionID, responseID, responder)
p.respondToQuestion(questionID, responseIDs, responder)
}
}
case None =>
@ -452,32 +452,32 @@ object PollFactory {
val LetterArray = Array("A", "B", "C", "D", "E", "F")
val NumberArray = Array("1", "2", "3", "4", "5", "6")
private def processYesNoPollType(qType: String, text: Option[String]): Question = {
private def processYesNoPollType(qType: String, multiResponse: Boolean, text: Option[String]): Question = {
val answers = new ArrayBuffer[Answer];
answers += new Answer(0, "Yes", Some("Yes"))
answers += new Answer(1, "No", Some("No"))
new Question(0, PollType.YesNoPollType, false, text, answers)
new Question(0, PollType.YesNoPollType, multiResponse, text, answers)
}
private def processYesNoAbstentionPollType(qType: String, text: Option[String]): Question = {
private def processYesNoAbstentionPollType(qType: String, multiResponse: Boolean, text: Option[String]): Question = {
val answers = new ArrayBuffer[Answer]
answers += new Answer(0, "Yes", Some("Yes"))
answers += new Answer(1, "No", Some("No"))
answers += new Answer(2, "Abstention", Some("Abstention"))
new Question(0, PollType.YesNoAbstentionPollType, false, text, answers)
new Question(0, PollType.YesNoAbstentionPollType, multiResponse, text, answers)
}
private def processTrueFalsePollType(qType: String, text: Option[String]): Question = {
private def processTrueFalsePollType(qType: String, multiResponse: Boolean, text: Option[String]): Question = {
val answers = new ArrayBuffer[Answer];
answers += new Answer(0, "True", Some("True"))
answers += new Answer(1, "False", Some("False"))
new Question(0, PollType.TrueFalsePollType, false, text, answers)
new Question(0, PollType.TrueFalsePollType, multiResponse, text, answers)
}
private def processLetterPollType(qType: String, multiResponse: Boolean, text: Option[String]): Option[Question] = {
@ -546,23 +546,23 @@ object PollFactory {
questionOption
}
private def createQuestion(qType: String, answers: Option[Seq[String]], text: Option[String]): Option[Question] = {
private def createQuestion(qType: String, multiResponse: Boolean, answers: Option[Seq[String]], text: Option[String]): Option[Question] = {
val qt = qType.toUpperCase()
var questionOption: Option[Question] = None
if (qt.matches(PollType.YesNoPollType)) {
questionOption = Some(processYesNoPollType(qt, text))
questionOption = Some(processYesNoPollType(qt, multiResponse, text))
} else if (qt.matches(PollType.YesNoAbstentionPollType)) {
questionOption = Some(processYesNoAbstentionPollType(qt, text))
questionOption = Some(processYesNoAbstentionPollType(qt, multiResponse, text))
} else if (qt.matches(PollType.TrueFalsePollType)) {
questionOption = Some(processTrueFalsePollType(qt, text))
questionOption = Some(processTrueFalsePollType(qt, multiResponse, text))
} else if (qt.matches(PollType.CustomPollType)) {
questionOption = processCustomPollType(qt, false, text, answers)
questionOption = processCustomPollType(qt, multiResponse, text, answers)
} else if (qt.startsWith(PollType.LetterPollType)) {
questionOption = processLetterPollType(qt, false, text)
questionOption = processLetterPollType(qt, multiResponse, text)
} else if (qt.startsWith(PollType.NumberPollType)) {
questionOption = processNumberPollType(qt, false, text)
questionOption = processNumberPollType(qt, multiResponse, text)
} else if (qt.startsWith(PollType.ResponsePollType)) {
questionOption = processResponsePollType(qt, text)
}
@ -570,10 +570,10 @@ object PollFactory {
questionOption
}
def createPoll(id: String, pollType: String, numRespondents: Int, answers: Option[Seq[String]], questionText: Option[String], isSecret: Boolean): Option[Poll] = {
def createPoll(id: String, pollType: String, multiResponse: Boolean, numRespondents: Int, answers: Option[Seq[String]], questionText: Option[String], isSecret: Boolean): Option[Poll] = {
var poll: Option[Poll] = None
createQuestion(pollType, answers, questionText) match {
createQuestion(pollType, multiResponse, answers, questionText) match {
case Some(question) => {
poll = Some(new Poll(id, Array(question), numRespondents, None, isSecret))
}
@ -622,10 +622,10 @@ class Poll(val id: String, val questions: Array[Question], val numRespondents: I
return false
}
def respondToQuestion(questionID: Int, responseID: Int, responder: Responder) {
def respondToQuestion(questionID: Int, responseIDs: Seq[Int], responder: Responder) {
questions.foreach(q => {
if (q.id == questionID) {
q.respondToQuestion(responseID, responder)
q.respondToQuestion(responseIDs, responder)
_numResponders += 1
}
})
@ -649,7 +649,7 @@ class Poll(val id: String, val questions: Array[Question], val numRespondents: I
}
def toSimplePollOutVO(): SimplePollOutVO = {
new SimplePollOutVO(id, questions(0).toSimpleAnswerOutVO())
new SimplePollOutVO(id, questions(0).multiResponse, questions(0).toSimpleAnswerOutVO())
}
def toSimplePollResultOutVO(): SimplePollResultOutVO = {
@ -674,9 +674,9 @@ class Question(val id: Int, val questionType: String, val multiResponse: Boolean
return false
}
def respondToQuestion(id: Int, responder: Responder) {
def respondToQuestion(ids: Seq[Int], responder: Responder) {
answers.foreach(r => {
if (r.id == id) r.addResponder(responder)
if (ids.contains(r.id)) r.addResponder(responder)
})
}

View File

@ -75,7 +75,7 @@ case class MeetingStatus(startEndTimeStatus: StartEndTimeStatus, recordingStatus
case class Meeting2x(defaultProps: DefaultProps, meetingStatus: MeetingStatus)
case class SimpleAnswerOutVO(id: Int, key: String)
case class SimplePollOutVO(id: String, answers: Array[SimpleAnswerOutVO])
case class SimplePollOutVO(id: String, isMultipleResponse: Boolean, answers: Array[SimpleAnswerOutVO])
case class SimpleVoteOutVO(id: Int, key: String, numVotes: Int)
case class SimplePollResultOutVO(id: String, questionType: String, questionText: Option[String], answers: Array[SimpleVoteOutVO], numRespondents: Int, numResponders: Int)
case class Responder(userId: String, name: String)

View File

@ -32,7 +32,7 @@ case class UserRespondedToPollRecordMsgBody(pollId: String, answerId: Int, answe
object RespondToPollReqMsg { val NAME = "RespondToPollReqMsg" }
case class RespondToPollReqMsg(header: BbbClientMsgHeader, body: RespondToPollReqMsgBody) extends StandardMsg
case class RespondToPollReqMsgBody(requesterId: String, pollId: String, questionId: Int, answerId: Int)
case class RespondToPollReqMsgBody(requesterId: String, pollId: String, questionId: Int, answerIds: Seq[Int])
object RespondToTypedPollReqMsg { val NAME = "RespondToTypedPollReqMsg" }
case class RespondToTypedPollReqMsg(header: BbbClientMsgHeader, body: RespondToTypedPollReqMsgBody) extends StandardMsg
@ -40,7 +40,7 @@ case class RespondToTypedPollReqMsgBody(requesterId: String, pollId: String, que
object UserRespondedToPollRespMsg { val NAME = "UserRespondedToPollRespMsg" }
case class UserRespondedToPollRespMsg(header: BbbClientMsgHeader, body: UserRespondedToPollRespMsgBody) extends BbbCoreMsg
case class UserRespondedToPollRespMsgBody(pollId: String, userId: String, answerId: Int)
case class UserRespondedToPollRespMsgBody(pollId: String, userId: String, answerIds: Seq[Int])
object UserRespondedToTypedPollRespMsg { val NAME = "UserRespondedToTypedPollRespMsg" }
case class UserRespondedToTypedPollRespMsg(header: BbbClientMsgHeader, body: UserRespondedToTypedPollRespMsgBody) extends BbbCoreMsg
@ -52,11 +52,11 @@ case class ShowPollResultReqMsgBody(requesterId: String, pollId: String)
object StartCustomPollReqMsg { val NAME = "StartCustomPollReqMsg" }
case class StartCustomPollReqMsg(header: BbbClientMsgHeader, body: StartCustomPollReqMsgBody) extends StandardMsg
case class StartCustomPollReqMsgBody(requesterId: String, pollId: String, pollType: String, secretPoll: Boolean, answers: Seq[String], question: String)
case class StartCustomPollReqMsgBody(requesterId: String, pollId: String, pollType: String, secretPoll: Boolean, isMultipleResponse: Boolean, answers: Seq[String], question: String)
object StartPollReqMsg { val NAME = "StartPollReqMsg" }
case class StartPollReqMsg(header: BbbClientMsgHeader, body: StartPollReqMsgBody) extends StandardMsg
case class StartPollReqMsgBody(requesterId: String, pollId: String, pollType: String, secretPoll: Boolean, question: String)
case class StartPollReqMsgBody(requesterId: String, pollId: String, pollType: String, secretPoll: Boolean, question: String, isMultipleResponse: Boolean)
object StopPollReqMsg { val NAME = "StopPollReqMsg" }
case class StopPollReqMsg(header: BbbClientMsgHeader, body: StopPollReqMsgBody) extends StandardMsg

View File

@ -3,11 +3,11 @@ import Polls from '/imports/api/polls';
import Logger from '/imports/startup/server/logger';
export default function userResponded({ body }) {
const { pollId, userId, answerId } = body;
const { pollId, userId, answerIds } = body;
check(pollId, String);
check(userId, String);
check(answerId, Number);
check(answerIds, Array);
const selector = {
id: pollId,
@ -18,7 +18,7 @@ export default function userResponded({ body }) {
users: userId,
},
$push: {
responses: { userId, answerId },
responses: { userId, answerIds },
},
};
@ -26,7 +26,7 @@ export default function userResponded({ body }) {
const numberAffected = Polls.update(selector, modifier);
if (numberAffected) {
Logger.info(`Updating Poll response (userId: ${userId}, response: ${answerId}, pollId: ${pollId})`);
Logger.info(`Updating Poll response (userId: ${userId}, response: ${JSON.stringify(answerIds)}, pollId: ${pollId})`);
}
} catch (err) {
Logger.error(`Updating Poll responses: ${err}`);

View File

@ -27,7 +27,7 @@ export default function userTypedResponse({ header, body }) {
requesterId: userId,
pollId,
questionId: 0,
answerId,
answerIds: [answerId],
};
return RedisPubSub.publishUserMessage(CHANNEL, EVENT_NAME, meetingId, userId, payload);

View File

@ -42,7 +42,7 @@ export default function publishTypedVote(id, pollAnswer) {
requesterId: requesterUserId,
pollId: id,
questionId: 0,
answerId: existingAnsId,
answerIds: [existingAnsId],
},
);
}

View File

@ -4,7 +4,7 @@ import Polls from '/imports/api/polls';
import Logger from '/imports/startup/server/logger';
import { extractCredentials } from '/imports/api/common/server/helpers';
export default function publishVote(pollId, pollAnswerId) {
export default function publishVote(pollId, pollAnswerIds) {
try {
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
@ -13,7 +13,7 @@ export default function publishVote(pollId, pollAnswerId) {
check(meetingId, String);
check(requesterUserId, String);
check(pollAnswerId, Number);
check(pollAnswerIds, Array);
check(pollId, String);
const allowedToVote = Polls.findOne({
@ -34,14 +34,14 @@ export default function publishVote(pollId, pollAnswerId) {
const selector = {
users: requesterUserId,
meetingId,
'answers.id': pollAnswerId,
'answers.id': { $all: pollAnswerIds },
};
const payload = {
requesterId: requesterUserId,
pollId,
questionId: 0,
answerId: pollAnswerId,
answerIds: pollAnswerIds,
};
/*

View File

@ -3,7 +3,7 @@ import { check } from 'meteor/check';
import { extractCredentials } from '/imports/api/common/server/helpers';
import Logger from '/imports/startup/server/logger';
export default function startPoll(pollTypes, pollType, pollId, secretPoll, question, answers) {
export default function startPoll(pollTypes, pollType, pollId, secretPoll, question, isMultipleResponse, answers) {
const REDIS_CONFIG = Meteor.settings.private.redis;
const CHANNEL = REDIS_CONFIG.channels.toAkkaApps;
let EVENT_NAME = 'StartPollReqMsg';
@ -23,6 +23,7 @@ export default function startPoll(pollTypes, pollType, pollId, secretPoll, quest
pollType,
secretPoll,
question,
isMultipleResponse,
};
if (pollType === pollTypes.Custom) {

View File

@ -15,6 +15,7 @@ export default function addPoll(meetingId, requesterId, poll, pollType, secretPo
key: String,
},
],
isMultipleResponse: Boolean,
});
const userSelector = {

View File

@ -6,6 +6,7 @@ import _ from 'lodash';
import { Session } from 'meteor/session';
import cx from 'classnames';
import Button from '/imports/ui/components/button/component';
import Checkbox from '/imports/ui/components/checkbox/component';
import Toggle from '/imports/ui/components/switch/component';
import LiveResult from './live-result/component';
import { styles } from './styles.scss';
@ -154,6 +155,10 @@ const intlMessages = defineMessages({
id: 'app.poll.abstention',
description: '',
},
enableMultipleResponseLabel: {
id: 'app.poll.enableMultipleResponseLabel',
description: 'label for checkbox to enable multiple choice',
},
startPollDesc: {
id: 'app.poll.startPollDesc',
description: '',
@ -210,6 +215,7 @@ class Poll extends Component {
question: '',
optList: [],
error: null,
isMultipleResponse: false,
secretPoll: false,
};
@ -218,6 +224,7 @@ class Poll extends Component {
this.handleRemoveOption = this.handleRemoveOption.bind(this);
this.handleTextareaChange = this.handleTextareaChange.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.toggleIsMultipleResponse = this.toggleIsMultipleResponse.bind(this);
this.displayToggleStatus = this.displayToggleStatus.bind(this);
}
@ -281,6 +288,11 @@ class Poll extends Component {
this.setState({ optList: list, error: clearError ? null : error });
}
toggleIsMultipleResponse() {
const { isMultipleResponse } = this.state;
return this.setState({ isMultipleResponse: !isMultipleResponse });
}
handleTextareaChange(e) {
const { type, error } = this.state;
const { pollTypes } = this.props;
@ -452,7 +464,7 @@ class Poll extends Component {
renderPollOptions() {
const {
type, secretPoll, optList, question, error,
type, secretPoll, optList, question, error, isMultipleResponse
} = this.state;
const {
startPoll,
@ -582,18 +594,32 @@ class Poll extends Component {
</div>
)
}
{
(defaultPoll || type === pollTypes.Response)
&& (
<div style={{
display: 'flex',
flexFlow: 'wrap',
flexDirection: 'column',
}}
>
{defaultPoll && this.renderInputs()}
{defaultPoll
&& (
{
(defaultPoll || type === pollTypes.Response)
&& (
<div style={{
display: 'flex',
flexFlow: 'wrap',
flexDirection: 'column',
}}
>
{defaultPoll
&& (
<div>
<Checkbox
onChange={this.toggleIsMultipleResponse}
checked={isMultipleResponse}
className={styles.checkbox}
ariaLabelledBy="multipleResponseCheckboxLabel"
/>
<label id="multipleResponseCheckboxLabel" className={styles.instructions}>
{intl.formatMessage(intlMessages.enableMultipleResponseLabel)}
</label>
</div>
)}
{defaultPoll && this.renderInputs()}
{defaultPoll
&& (
<Button
className={styles.addItemBtn}
data-test="addItem"
@ -670,10 +696,11 @@ class Poll extends Component {
verifiedPollType,
secretPoll,
question,
isMultipleResponse,
_.compact(verifiedOptions),
);
} else {
startPoll(verifiedPollType, secretPoll, question);
startPoll(verifiedPollType, secretPoll, question, isMultipleResponse);
}
});
}}

View File

@ -42,9 +42,9 @@ export default withTracker(() => {
const { pollTypes } = Service;
const startPoll = (type, secretPoll, question = '') => makeCall('startPoll', pollTypes, type, pollId, secretPoll, question);
const startPoll = (type, secretPoll, question = '', isMultipleResponse) => makeCall('startPoll', pollTypes, type, pollId, secretPoll, question, isMultipleResponse);
const startCustomPoll = (type, secretPoll, question = '', answers) => makeCall('startPoll', pollTypes, type, pollId, secretPoll, question, answers);
const startCustomPoll = (type, secretPoll, question = '', isMultipleResponse, answers) => makeCall('startPoll', pollTypes, type, pollId, secretPoll, question, isMultipleResponse, answers);
const stopPoll = () => makeCall('stopPoll');

View File

@ -73,7 +73,13 @@ class LiveResult extends PureComponent {
if (responses) {
const response = responses.find(r => r.userId === user.userId);
if (response) answer = answers[response.answerId].key;
if (response) {
const answerKeys = [];
response.answerIds.forEach((answerId) => {
answerKeys.push(answers[answerId].key);
});
answer = answerKeys.join(', ');
}
}
return {

View File

@ -4,6 +4,7 @@
--poll-stats-element-width: 17%;
--poll-sm-margin: 0.3125rem;
--poll-md-margin: 0.7rem;
--poll-user-line-height: 1.75rem;
--poll-result-width: 15rem;
}

View File

@ -200,6 +200,13 @@
text-align: center;
}
.checkbox {
display: inline-block;
margin-right: var(--poll-sm-margin);
margin-bottom: var(--poll-md-margin);
}
.responseType {
display: flex;
justify-content: space-between;

View File

@ -7,6 +7,7 @@ import cx from 'classnames';
import { Meteor } from 'meteor/meteor';
import { styles } from './styles.scss';
import AudioService from '/imports/ui/components/audio/service';
import Checkbox from '/imports/ui/components/checkbox/component';
const MAX_INPUT_CHARS = Meteor.settings.public.poll.maxTypedAnswerLength;
@ -52,10 +53,15 @@ class Polling extends Component {
this.state = {
typedAns: '',
checkedAnswers: [],
};
this.play = this.play.bind(this);
this.handleUpdateResponseInput = this.handleUpdateResponseInput.bind(this);
this.renderButtonAnswers = this.renderButtonAnswers.bind(this);
this.handleCheckboxChange = this.handleCheckboxChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.renderCheckboxAnswers = this.renderCheckboxAnswers.bind(this);
this.handleMessageKeyDown = this.handleMessageKeyDown.bind(this);
}
@ -75,6 +81,23 @@ class Polling extends Component {
this.setState({ typedAns: this.responseInput.value });
}
handleSubmit(pollId) {
const { handleVote } = this.props;
const { checkedAnswers } = this.state;
handleVote(pollId, checkedAnswers);
}
handleCheckboxChange(pollId, answerId) {
const { checkedAnswers } = this.state;
if (checkedAnswers.includes(answerId)) {
checkedAnswers.splice(checkedAnswers.indexOf(answerId), 1);
} else {
checkedAnswers.push(answerId);
}
checkedAnswers.sort();
this.setState({ checkedAnswers });
}
handleMessageKeyDown(e) {
const {
poll,
@ -90,7 +113,7 @@ class Polling extends Component {
}
}
render() {
renderButtonAnswers(pollAnswerStyles) {
const {
isMeteorConnected,
intl,
@ -108,9 +131,185 @@ class Polling extends Component {
if (!poll) return null;
const { stackOptions, answers, question, pollType } = poll;
const { answers, question, pollType } = poll;
const defaultPoll = isDefaultPoll(pollType);
return (
<div>
{
poll.pollType !== pollTypes.Response && (
<span>
{
question.length === 0 && (
<div className={styles.pollingTitle}>
{intl.formatMessage(intlMessages.pollingTitleLabel)}
</div>
)
}
<div className={cx(pollAnswerStyles)}>
{answers.map((pollAnswer) => {
const formattedMessageIndex = pollAnswer.key.toLowerCase();
let label = pollAnswer.key;
if (defaultPoll && pollAnswerIds[formattedMessageIndex]) {
label = intl.formatMessage(pollAnswerIds[formattedMessageIndex]);
}
return (
<div
key={pollAnswer.id}
className={styles.pollButtonWrapper}
>
<Button
disabled={!isMeteorConnected}
className={styles.pollingButton}
color="primary"
size="md"
label={label}
key={pollAnswer.key}
onClick={() => handleVote(poll.pollId, [pollAnswer.id])}
aria-labelledby={`pollAnswerLabel${pollAnswer.key}`}
aria-describedby={`pollAnswerDesc${pollAnswer.key}`}
data-test="pollAnswerOption"
/>
<div
className={styles.hidden}
id={`pollAnswerLabel${pollAnswer.key}`}
>
{intl.formatMessage(intlMessages.pollAnswerLabel, { 0: label })}
</div>
<div
className={styles.hidden}
id={`pollAnswerDesc${pollAnswer.key}`}
>
{intl.formatMessage(intlMessages.pollAnswerDesc, { 0: label })}
</div>
</div>
);
})}
</div>
</span>
)
}
{
poll.pollType === pollTypes.Response
&& (
<div className={styles.typedResponseWrapper}>
<input
data-test="pollAnswerOption"
onChange={(e) => {
this.handleUpdateResponseInput(e);
}}
onKeyDown={(e) => {
this.handleMessageKeyDown(e);
}}
type="text"
className={styles.typedResponseInput}
placeholder={intl.formatMessage(intlMessages.responsePlaceholder)}
maxLength={MAX_INPUT_CHARS}
ref={(r) => { this.responseInput = r; }}
/>
<Button
data-test="submitAnswer"
className={styles.submitVoteBtn}
disabled={typedAns.length === 0}
color="primary"
size="sm"
label={intl.formatMessage(intlMessages.submitLabel)}
aria-label={intl.formatMessage(intlMessages.submitAriaLabel)}
onClick={() => {
handleTypedVote(poll.pollId, typedAns);
}}
/>
</div>
)
}
<div className={styles.pollingSecret}>
{intl.formatMessage(poll.secretPoll ? intlMessages.responseIsSecret : intlMessages.responseNotSecret)}
</div>
</div>
);
}
renderCheckboxAnswers() {
const {
isMeteorConnected,
intl,
poll,
pollAnswerIds,
} = this.props;
const { checkedAnswers } = this.state;
const { question } = poll;
return (
<div>
{question.length === 0
&& (
<div className={styles.pollingTitle}>
{intl.formatMessage(intlMessages.pollingTitleLabel)}
</div>
)}
<table className={styles.multipleResponseAnswersTable}>
{poll.answers.map((pollAnswer) => {
const formattedMessageIndex = pollAnswer.key.toLowerCase();
let label = pollAnswer.key;
if (pollAnswerIds[formattedMessageIndex]) {
label = intl.formatMessage(pollAnswerIds[formattedMessageIndex]);
}
return (
<tr
key={pollAnswer.id}
className={styles.checkboxContainer}
>
<td>
<Checkbox
disabled={!isMeteorConnected}
id={`answerInput${pollAnswer.key}`}
onChange={() => this.handleCheckboxChange(poll.pollId, pollAnswer.id)}
checked={checkedAnswers.includes(pollAnswer.id)}
className={styles.checkbox}
ariaLabelledBy={`pollAnswerLabel${pollAnswer.key}`}
ariaDescribedBy={`pollAnswerDesc${pollAnswer.key}`}
/>
</td>
<td className={styles.multipleResponseAnswersTableAnswerText}>
<label id={`pollAnswerLabel${pollAnswer.key}`}>
{label}
</label>
<div
className={styles.hidden}
id={`pollAnswerDesc${pollAnswer.key}`}
>
{intl.formatMessage(intlMessages.pollAnswerDesc, { 0: label })}
</div>
</td>
</tr>
);
})}
</table>
<div>
<Button
className={styles.submitVoteBtn}
disabled={!isMeteorConnected || checkedAnswers.length === 0}
color="primary"
size="sm"
label={intl.formatMessage(intlMessages.submitLabel)}
aria-label={intl.formatMessage(intlMessages.submitAriaLabel)}
onClick={() => this.handleSubmit(poll.pollId)}
/>
</div>
</div>
);
}
render() {
const {
intl,
poll,
} = this.props;
if (!poll) return null;
const { stackOptions, answers, question } = poll;
const pollAnswerStyles = {
[styles.pollingAnswers]: true,
[styles.removeColumns]: answers.length === 1,
@ -137,96 +336,7 @@ class Polling extends Component {
</span>
)
}
{
poll.pollType !== pollTypes.Response && (
<span>
{
question.length === 0 && (
<div className={styles.pollingTitle}>
{intl.formatMessage(intlMessages.pollingTitleLabel)}
</div>
)
}
<div className={cx(pollAnswerStyles)}>
{poll.answers.map((pollAnswer) => {
const formattedMessageIndex = pollAnswer.key.toLowerCase();
let label = pollAnswer.key;
if (defaultPoll && pollAnswerIds[formattedMessageIndex]) {
label = intl.formatMessage(pollAnswerIds[formattedMessageIndex]);
}
return (
<div
key={pollAnswer.id}
className={styles.pollButtonWrapper}
>
<Button
disabled={!isMeteorConnected}
className={styles.pollingButton}
color="primary"
size="md"
label={label}
key={pollAnswer.key}
onClick={() => handleVote(poll.pollId, pollAnswer)}
aria-labelledby={`pollAnswerLabel${pollAnswer.key}`}
aria-describedby={`pollAnswerDesc${pollAnswer.key}`}
data-test="pollAnswerOption"
/>
<div
className={styles.hidden}
id={`pollAnswerLabel${pollAnswer.key}`}
>
{intl.formatMessage(intlMessages.pollAnswerLabel, { 0: label })}
</div>
<div
className={styles.hidden}
id={`pollAnswerDesc${pollAnswer.key}`}
>
{intl.formatMessage(intlMessages.pollAnswerDesc, { 0: label })}
</div>
</div>
);
})}
</div>
</span>
)
}
{
poll.pollType === pollTypes.Response
&& (
<div className={styles.typedResponseWrapper}>
<input
data-test="pollAnswerOption"
onChange={(e) => {
this.handleUpdateResponseInput(e);
}}
onKeyDown={(e) => {
this.handleMessageKeyDown(e);
}}
type="text"
className={styles.typedResponseInput}
placeholder={intl.formatMessage(intlMessages.responsePlaceholder)}
maxLength={MAX_INPUT_CHARS}
ref={(r) => { this.responseInput = r; }}
/>
<Button
data-test="submitAnswer"
className={styles.submitVoteBtn}
disabled={typedAns.length === 0}
color="primary"
size="sm"
label={intl.formatMessage(intlMessages.submitLabel)}
aria-label={intl.formatMessage(intlMessages.submitAriaLabel)}
onClick={() => {
handleTypedVote(poll.pollId, typedAns);
}}
/>
</div>
)
}
<div className={styles.pollingSecret}>
{intl.formatMessage(poll.secretPoll ? intlMessages.responseIsSecret : intlMessages.responseNotSecret)}
</div>
{poll.isMultipleResponse ? this.renderCheckboxAnswers(pollAnswerStyles) : this.renderButtonAnswers(pollAnswerStyles)}
</div>
</div>
);

View File

@ -4,8 +4,8 @@ import { debounce } from 'lodash';
const MAX_CHAR_LENGTH = 5;
const handleVote = (pollId, answerId) => {
makeCall('publishVote', pollId, answerId.id);
const handleVote = (pollId, answerIds) => {
makeCall('publishVote', pollId, answerIds);
};
const handleTypedVote = (pollId, answer) => {
@ -35,6 +35,7 @@ const mapPolls = () => {
poll: {
answers: poll.answers,
pollId: poll.id,
isMultipleResponse: poll.isMultipleResponse,
pollType: poll.pollType,
stackOptions,
question: poll.question,

View File

@ -121,6 +121,15 @@
display: none;
}
.checkbox {
display: inline-block;
margin-right: var(--poll-sm-margin);
}
.checkboxContainer {
margin-bottom: var(--poll-sm-margin);
}
.qHeader {
text-align: left;
position: relative;
@ -162,6 +171,14 @@
margin-bottom: 1rem;
}
.multipleResponseAnswersTable {
margin-left: auto;
margin-right: auto;
}
.multipleResponseAnswersTableAnswerText {
text-align: left;
}
.pollingSecret {
font-size: var(--font-size-small);
max-width: var(--poll-width);

View File

@ -40,7 +40,7 @@ export default withTracker((params) => {
Session.set('forcePollOpen', true);
window.dispatchEvent(new Event('panelChanged'));
makeCall('startPoll', PollService.pollTypes, type, id, false, '', answers);
makeCall('startPoll', PollService.pollTypes, type, id, false, '', false, answers);
};
return {

View File

@ -211,9 +211,8 @@ class PollDrawComponent extends Component {
// calculating only the parts which have to be done just once and don't require
// rendering / rerendering the text objects
// if (!state.initialState) return;
const { annotation } = this.props;
const { points, result, pollType } = annotation;
const { points, result, numResponders, pollType } = annotation;
const { slideWidth, slideHeight, intl } = this.props;
// group duplicated responses and keep track of the number of removed items
@ -277,11 +276,11 @@ class PollDrawComponent extends Component {
}
}
_tempArray.push(_result.key, `${_result.numVotes}`);
if (votesTotal === 0) {
if (numResponders === 0) {
_tempArray.push('0%');
_tempArray.push(i);
} else {
const percResult = (_result.numVotes / votesTotal) * 100;
const percResult = (_result.numVotes / numResponders) * 100;
_tempArray.push(`${Math.round(percResult)}%`);
_tempArray.push(i);
}
@ -475,7 +474,7 @@ class PollDrawComponent extends Component {
fill={backgroundColor}
strokeWidth={thickness}
/>
{extendedTextArray.map(line => (
{extendedTextArray.map((line) => (
<text
x={line.keyColumn.xLeft}
y={line.keyColumn.yLeft}
@ -490,7 +489,7 @@ class PollDrawComponent extends Component {
{line.keyColumn.keyString}
</text>
))}
{extendedTextArray.map(line => (
{extendedTextArray.map((line) => (
<rect
key={`${line.key}_bar`}
x={line.barColumn.xBar}
@ -510,7 +509,7 @@ class PollDrawComponent extends Component {
fontSize={calcFontSize}
textAnchor={isRTL ? 'start' : 'end'}
>
{extendedTextArray.map(line => (
{extendedTextArray.map((line) => (
<tspan
x={line.percentColumn.xRight}
y={line.percentColumn.yRight}
@ -530,7 +529,7 @@ class PollDrawComponent extends Component {
fontSize={calcFontSize}
textAnchor={isRTL ? 'end' : 'start'}
>
{extendedTextArray.map(line => (
{extendedTextArray.map((line) => (
<tspan
x={line.barColumn.xNumVotes + (line.barColumn.barWidth / 2)}
y={line.barColumn.yNumVotes + (line.barColumn.barHeight / 2)}
@ -604,7 +603,7 @@ class PollDrawComponent extends Component {
}
return (
<g aria-hidden>
{textArray.map(line => this.renderLine(line))}
{textArray.map((line) => this.renderLine(line))}
<text
fontFamily="Arial"
fontSize={calcFontSize}
@ -623,7 +622,7 @@ class PollDrawComponent extends Component {
const { prepareToDisplay, textArray } = this.state;
let ariaResultLabel = `${intl.formatMessage(intlMessages.pollResultAria)}: `;
textArray.map((t, idx) => {
textArray.forEach((t, idx) => {
const pollLine = t.slice(0, -1);
ariaResultLabel += `${idx > 0 ? ' |' : ''} ${pollLine.join(' | ')}`;
});
@ -632,8 +631,7 @@ class PollDrawComponent extends Component {
<g aria-label={ariaResultLabel} data-test="pollResultAria">
{prepareToDisplay
? this.renderTestStrings()
: this.renderPoll()
}
: this.renderPoll()}
</g>
);
}

View File

@ -235,6 +235,7 @@
"app.presentationUploder.clearErrorsDesc": "Löscht fehlgeschlagene Präsentationsuploads",
"app.presentationUploder.uploadViewTitle": "Präsentation hochladen",
"app.poll.pollPaneTitle": "Umfrage",
"app.poll.enableMultipleResponseLabel": "Mehrere Antworten pro Teilnehmer zulassen?",
"app.poll.quickPollTitle": "Schnellumfrage",
"app.poll.hidePollDesc": "Versteckt das Umfragemenü",
"app.poll.quickPollInstruction": "Wählen Sie eine der unten stehenden Optionen, um die Umfrage zu starten.",

View File

@ -235,6 +235,7 @@
"app.presentationUploder.clearErrorsDesc": "Clears failed presentation uploads",
"app.presentationUploder.uploadViewTitle": "Upload Presentation",
"app.poll.pollPaneTitle": "Polling",
"app.poll.enableMultipleResponseLabel": "Allow multiple answers per respondent?",
"app.poll.quickPollTitle": "Quick Poll",
"app.poll.hidePollDesc": "Hides the poll menu pane",
"app.poll.quickPollInstruction": "Select an option below to start your poll.",