Skip to content

Commit

Permalink
feat: Add 'Other' option for radio/checkbox questions.
Browse files Browse the repository at this point in the history
Signed-off-by: Andrii Ilkiv <a.ilkiv.ye@gmail.com>
  • Loading branch information
AIlkiv committed Aug 26, 2023
1 parent 38cae22 commit c46ac20
Show file tree
Hide file tree
Showing 8 changed files with 295 additions and 32 deletions.
5 changes: 5 additions & 0 deletions lib/Constants.php
Expand Up @@ -131,4 +131,9 @@ class Constants {
*/
public const EMPTY_NOTFOUND = 'notfound';
public const EMPTY_EXPIRED = 'expired';

/**
* Constants related to extra settings for questions
*/
public const QUESTION_EXTRASETTINGS_OTHER_PREFIX = 'system-other-answer:';
}
21 changes: 12 additions & 9 deletions lib/Controller/ApiController.php
Expand Up @@ -1002,27 +1002,30 @@ public function insertSubmission(int $formId, array $answers, string $shareHash
$questionIndex = array_search($questionId, array_column($questions, 'id'));
if ($questionIndex === false) {
continue;
} else {
$question = $questions[$questionIndex];
}

$question = $questions[$questionIndex];

foreach ($answerArray as $answer) {
$answerText = '';

// Are we using answer ids as values
if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED)) {
// Search corresponding option, skip processing if not found
$optionIndex = array_search($answer, array_column($question['options'], 'id'));
if ($optionIndex === false) {
continue;
} else {
$option = $question['options'][$optionIndex];
if ($optionIndex !== false) {
$answerText = $question['options'][$optionIndex]['text'];
} elseif (!empty($question['extraSettings']->otherAnswer) && strpos($answer, Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX) === 0) {
$answerText = str_replace(Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX, "", $answer);
}

// Load option-text
$answerText = $option['text'];
} else {
$answerText = $answer; // Not a multiple-question, answerText is given answer
}

if ($answerText === "") {
continue;
}

$answerEntity = new Answer();
$answerEntity->setSubmissionId($submissionId);
$answerEntity->setQuestionId($question['id']);
Expand Down
5 changes: 3 additions & 2 deletions lib/Service/SubmissionService.php
Expand Up @@ -309,7 +309,8 @@ public function validateSubmission(array $questions, array $answers): bool {
$questionAnswered = array_key_exists($questionId, $answers);

// Check if all required questions have an answer
if ($question['isRequired'] && (!$questionAnswered || !array_filter($answers[$questionId], 'strlen'))) {
if ($question['isRequired'] &&
(!$questionAnswered || !array_filter($answers[$questionId], 'strlen') || !array_filter($answers[$questionId], fn ($value) => $value !== Constants::QUESTION_EXTRASETTINGS_OTHER_PREFIX))) {
return false;
}

Expand All @@ -330,7 +331,7 @@ public function validateSubmission(array $questions, array $answers): bool {
}

// Check if all answers are within the possible options
if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED)) {
if (in_array($question['type'], Constants::ANSWER_TYPES_PREDEFINED) && empty($question['extraSettings']->otherAnswer)) {
foreach ($answers[$questionId] as $answer) {
// Search corresponding option, return false if non-existent
if (array_search($answer, array_column($question['options'], 'id')) === false) {
Expand Down
118 changes: 104 additions & 14 deletions src/components/Questions/QuestionMultiple.vue
Expand Up @@ -43,6 +43,10 @@
@update:checked="onShuffleOptionsChange">
{{ t('forms', 'Shuffle options') }}
</NcActionCheckbox>
<NcActionCheckbox :checked="extraSettings?.otherAnswer"
@update:checked="onOtherAnswerChange">
{{ t('forms', 'Add "other"') }}
</NcActionCheckbox>
</template>
<template v-if="!edit">
<fieldset :name="name || undefined" :aria-labelledby="titleId">
Expand All @@ -57,6 +61,22 @@
@keydown.enter.exact.prevent="onKeydownEnter">
{{ answer.text }}
</NcCheckboxRadioSwitch>
<div v-if="hasOtherAnswer" class="question__other-answer">
<NcCheckboxRadioSwitch :checked.sync="questionValues"
:value="valueOtherAnswer"
:name="`${id}-answer`"
:type="isUnique ? 'radio' : 'checkbox'"
:required="checkRequired('other-answer')"
class="question__label"
@update:checked="onChange"
@keydown.enter.exact.prevent="onKeydownEnter">
{{ t('forms', 'Other:') }}
</NcCheckboxRadioSwitch>
<NcInputField :placeholder="placeholderOtherAnswer"
:required="hasRequiredOtherAnswerInput"
:value.sync="inputOtherAnswer"
class="question__input" />
</div>
</fieldset>
</template>
Expand All @@ -76,7 +96,15 @@
@delete="deleteOption"
@update:answer="updateAnswer" />
</template>
<li v-if="hasOtherAnswer" class="question__item">
<div :is="pseudoIcon" class="question__item__pseudoInput" />
<input :placeholder="t('forms', 'Other')"
class="question__input"
:maxlength="maxStringLengths.optionText"
minlength="1"
type="text"
:readonly="edit">
</li>
<li v-if="(edit && !isLastEmpty) || hasNoAnswer" class="question__item">
<div :is="pseudoIcon" class="question__item__pseudoInput" />
<input :aria-label="t('forms', 'Add a new answer')"
Expand All @@ -100,6 +128,7 @@ import { generateOcsUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import NcActionCheckbox from '@nextcloud/vue/dist/Components/NcActionCheckbox.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js'
import IconCheckboxBlankOutline from 'vue-material-design-icons/CheckboxBlankOutline.vue'
import IconRadioboxBlank from 'vue-material-design-icons/RadioboxBlank.vue'
Expand All @@ -117,17 +146,27 @@ export default {
IconRadioboxBlank,
NcActionCheckbox,
NcCheckboxRadioSwitch,
NcInputField,
},
mixins: [QuestionMixin],
data() {
return {
questionValues: this.values,
inputOtherAnswer: this.valueToInputOtherAnswer(),
QUESTION_EXTRASETTINGS_OTHER_PREFIX: 'system-other-answer:',
}
},
computed: {
placeholderOtherAnswer() {
if (this.readOnly) {
return this.answerType.submitPlaceholder
}
return this.answerType.createPlaceholder
},
contentValid() {
return this.answerType.validate(this)
},
Expand Down Expand Up @@ -160,6 +199,19 @@ export default {
titleId() {
return `q${this.$attrs.index}_title`
},
hasOtherAnswer() {
return this.extraSettings?.otherAnswer
},
valueOtherAnswer() {
return this.QUESTION_EXTRASETTINGS_OTHER_PREFIX + this.inputOtherAnswer
},
hasRequiredOtherAnswerInput() {
const checkedOtherAnswer = this.questionValues.filter(item => item.startsWith(this.QUESTION_EXTRASETTINGS_OTHER_PREFIX))
return checkedOtherAnswer[0] !== undefined
},
},
watch: {
Expand All @@ -178,23 +230,27 @@ export default {
this.updateOptions(options)
}
},
},
methods: {
onChange() {
// Checkbox: convert to array of Numbers
if (!this.isUnique) {
const arrOfNum = []
this.questionValues.forEach(str => {
arrOfNum.push(Number(str))
})
this.$emit('update:values', arrOfNum)
inputOtherAnswer() {
if (this.isUnique) {
this.questionValues = this.valueOtherAnswer
this.onChange()
return
}
// Radio: create array
this.$emit('update:values', [this.questionValues])
this.questionValues = this.questionValues.filter(item => !item.startsWith(this.QUESTION_EXTRASETTINGS_OTHER_PREFIX))
if (this.inputOtherAnswer !== '') {
this.questionValues.push(this.valueOtherAnswer)
}
this.onChange()
},
},
methods: {
onChange() {
this.$emit('update:values', this.isUnique ? [this.questionValues] : this.questionValues)
},
/**
Expand Down Expand Up @@ -348,6 +404,20 @@ export default {
input.focus()
}
},
/**
* Update status extra setting otherAnswer and save on DB
*
* @param {boolean} showInputOtherAnswer show/hide field for other answer
*/
onOtherAnswerChange(showInputOtherAnswer) {
return this.onExtraSettingsChange('otherAnswer', showInputOtherAnswer)
},
valueToInputOtherAnswer() {
const otherAnswer = this.values.filter(item => item.startsWith(this.QUESTION_EXTRASETTINGS_OTHER_PREFIX))
return otherAnswer[0] !== undefined ? otherAnswer[0].substring(this.QUESTION_EXTRASETTINGS_OTHER_PREFIX.length) : ''
},
},
}
</script>
Expand Down Expand Up @@ -402,4 +472,24 @@ export default {
}
}
}
.question__other-answer::v-deep {
display: flex;
gap: 4px 16px;
flex-wrap: wrap;
.question__label {
flex-basis: content;
}
.question__input {
flex: 1;
min-width: 260px;
}
.input-field__input {
min-height: 44px;
}
}
</style>
23 changes: 18 additions & 5 deletions src/components/Results/ResultsSummary.vue
Expand Up @@ -92,6 +92,15 @@ export default {
percentage: 0,
}))
// Also record 'Other'
if (this.question.extraSettings?.otherAnswer) {
questionOptionsStats.unshift({
text: t('forms', 'Other'),
count: 0,
percentage: 0,
})
}
// Also record 'No response'
questionOptionsStats.unshift({
// TRANSLATORS Counts on Results-Summary, how many users did not respond to this question.
Expand All @@ -112,11 +121,15 @@ export default {
answers.forEach(answer => {
const optionsStatIndex = questionOptionsStats.findIndex(option => option.text === answer.text)
if (optionsStatIndex < 0) {
questionOptionsStats.push({
text: answer.text,
count: 1,
percentage: 0,
})
if (this.question.extraSettings?.otherAnswer) {
questionOptionsStats[1].count++
} else {
questionOptionsStats.push({
text: answer.text,
count: 1,
percentage: 0,
})
}
} else {
questionOptionsStats[optionsStatIndex].count++
}
Expand Down
4 changes: 4 additions & 0 deletions src/models/AnswerTypes.js
Expand Up @@ -70,6 +70,8 @@ export default {
validate: question => question.options.length > 0,

titlePlaceholder: t('forms', 'Checkbox question title'),
createPlaceholder: t('forms', 'People can submit a different answer'),
submitPlaceholder: t('forms', 'Enter your answer'),
warningInvalid: t('forms', 'This question needs a title and at least one answer!'),
},

Expand All @@ -81,6 +83,8 @@ export default {
validate: question => question.options.length > 0,

titlePlaceholder: t('forms', 'Radio buttons question title'),
createPlaceholder: t('forms', 'People can submit a different answer'),
submitPlaceholder: t('forms', 'Enter your answer'),
warningInvalid: t('forms', 'This question needs a title and at least one answer!'),

// Using the same vue-component as multiple, this specifies that the component renders as multiple_unique.
Expand Down

0 comments on commit c46ac20

Please sign in to comment.