Skip to content

Commit

Permalink
Feature: Allow shuffling of answer options
Browse files Browse the repository at this point in the history
This introduces an question option to randomize the order of the answer options.
Shuffling the order of the answer choices reduces bias in responses.
Implements #1067

If no shuffling is enabled the options are sorted in the order they were
created, as currently they are shown as returned by the database which
is not necessarily sorted. Fixes #1007

Signed-off-by: Ferdinand Thiessen <[email protected]>
  • Loading branch information
susnux committed Sep 6, 2022
1 parent fdade81 commit 2cd3bc6
Show file tree
Hide file tree
Showing 8 changed files with 134 additions and 2 deletions.
1 change: 1 addition & 0 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@ public function newQuestion(int $formId, string $type, string $text = ''): DataR
$question->setText($text);
$question->setDescription('');
$question->setIsRequired(false);
$question->setExtraSettings([]);

$question = $this->questionMapper->insert($question);

Expand Down
13 changes: 13 additions & 0 deletions lib/Db/Question.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
* @method void setText(string $value)
* @method string getDescription()
* @method void setDescription(string $value)
* @method array getExtraSettings()
* @method void setExtraSettings(array $value)
*/
class Question extends Entity {
protected $formId;
Expand All @@ -52,6 +54,7 @@ class Question extends Entity {
protected $isRequired;
protected $text;
protected $description;
protected $extraSettingsJson;

public function __construct() {
$this->addType('formId', 'integer');
Expand All @@ -62,6 +65,15 @@ public function __construct() {
$this->addType('description', 'string');
}

public function getExtraSettings(): array {
return json_decode($this->getExtraSettingsJson(), true);
}

public function setExtraSettings(array $extraSettings) {
// Make sure to be an object (empty assoc. array)
$this->setExtraSettingsJson(json_encode($extraSettings, JSON_FORCE_OBJECT));
}

public function read(): array {
return [
'id' => $this->getId(),
Expand All @@ -71,6 +83,7 @@ public function read(): array {
'isRequired' => $this->getIsRequired(),
'text' => htmlspecialchars_decode($this->getText()),
'description' => htmlspecialchars_decode($this->getDescription()),
'extraSettings' => $this->getExtraSettings(),
];
}
}
57 changes: 57 additions & 0 deletions lib/Migration/Version030000Date20220831195000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Ferdinand Thiessen <[email protected]>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Forms\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

class Version030000Date20220831195000 extends SimpleMigrationStep {

// Types::JSON is only available starting with NC24, we still support NC22
private const TYPE_JSON = 'json';

/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable('forms_v2_questions');

if (!$table->hasColumn('extra_settings_json')) {
$table->addColumn('extra_settings_json', self::TYPE_JSON, [
'notnull' => false,
]);

return $schema;
}

return null;
}
}
14 changes: 14 additions & 0 deletions src/components/Questions/Question.vue
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@
<!-- TRANSLATORS Making this question necessary to be answered when submitting to a form -->
{{ t('forms', 'Required') }}
</NcActionCheckbox>
<NcActionCheckbox v-if="shuffleOptions !== undefined"
:checked="shuffleOptions"
@update:checked="onShuffleOptionsChange">
{{ t('forms', 'Shuffle options') }}
</NcActionCheckbox>
<NcActionButton @click="onDelete">
<template #icon>
<IconDelete :size="20" />
Expand Down Expand Up @@ -141,6 +146,10 @@ export default {
type: Boolean,
default: false,
},
shuffleOptions: {
type: Boolean,
default: undefined,
},
edit: {
type: Boolean,
required: true,
Expand Down Expand Up @@ -190,6 +199,7 @@ export default {
onTitleChange({ target }) {
this.$emit('update:text', target.value)
},
onDescriptionChange({ target }) {
this.autoSizeDescription()
this.$emit('update:description', target.value)
Expand All @@ -199,6 +209,10 @@ export default {
this.$emit('update:isRequired', isRequired)
},
onShuffleOptionsChange(shuffleOptions) {
this.$emit('update:shuffleOptions', shuffleOptions)
},
/**
* Enable the edit mode
*/
Expand Down
4 changes: 3 additions & 1 deletion src/components/Questions/QuestionDropdown.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
:text="text"
:description="description"
:is-required="isRequired"
:shuffle-options="!!extraSettings?.shuffleOptions"
:edit.sync="edit"
:read-only="readOnly"
:max-string-lengths="maxStringLengths"
Expand All @@ -35,14 +36,15 @@
@update:text="onTitleChange"
@update:description="onDescriptionChange"
@update:isRequired="onRequiredChange"
@update:shuffleOptions="onShuffleOptionsChange"
@delete="onDelete">
<NcMultiselect v-if="!edit"
v-model="selectedOption"
:name="text"
:placeholder="selectOptionPlaceholder"
:multiple="isMultiple"
:required="isRequired"
:options="options"
:options="sortedOptions"
:searchable="false"
label="text"
track-by="id"
Expand Down
4 changes: 3 additions & 1 deletion src/components/Questions/QuestionMultiple.vue
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
:text="text"
:description="description"
:is-required="isRequired"
:shuffle-options="!!extraSettings?.shuffleOptions"
:edit.sync="edit"
:read-only="readOnly"
:max-string-lengths="maxStringLengths"
Expand All @@ -35,9 +36,10 @@
@update:text="onTitleChange"
@update:description="onDescriptionChange"
@update:isRequired="onRequiredChange"
@update:shuffleOptions="onShuffleOptionsChange"
@delete="onDelete">
<ul class="question__content">
<template v-for="(answer, index) in options">
<template v-for="(answer, index) in sortedOptions">
<li v-if="!edit" :key="answer.id" class="question__item">
<!-- Answer radio/checkbox + label -->
<!-- TODO: migrate to radio/checkbox component once available -->
Expand Down
37 changes: 37 additions & 0 deletions src/mixins/QuestionMixin.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ export default {
type: Object,
required: true,
},

extraSettings: {
type: Object,
default: () => { return {} },
},
},

components: {
Expand All @@ -117,6 +122,17 @@ export default {
}
},

computed: {
sortedOptions() {
// Only shuffle options if not in editing mode (and shuffling is enabled)
if (!this.edit && this.extraSettings?.shuffleOptions) {
return [...this.options].sort(() => 0.5 - Math.random())
}
// Ensure order of options always is the same
return [...this.options].sort((a, b) => a.id - b.id)
},
},

methods: {
/**
* Forward the title change to the parent and store to db
Expand Down Expand Up @@ -147,6 +163,27 @@ export default {
this.saveQuestionProperty('isRequired', isRequiredValue)
}, 200),

/**
* Create mapper to forward the required change to the parent and store to db
*
* @param {string} parameter Name of the setting that changed
* @param {any} value New value of the setting
*/
onExtraSettingsChange: debounce(function(parameter, value) {
const newSettings = Object.assign({}, this.extraSettings)
newSettings[parameter] = value
this.$emit('update:extraSettings', newSettings)
this.saveQuestionProperty('extraSettings', newSettings)
}, 200),

/**
* Forward the required change to the parent and store to db
* @param {boolean} shuffle Should options be shuffled
*/
onShuffleOptionsChange(shuffle) {
return this.onExtraSettingsChange('shuffleOptions', shuffle)
},

/**
* Forward the answer(s) change to the parent
*
Expand Down
6 changes: 6 additions & 0 deletions tests/Unit/Service/FormsServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ public function dataGetForm() {
'order' => 1,
'type' => 'dropdown',
'isRequired' => false,
'extraSettings' => ['shuffleOptions' => true],
'text' => 'Question 1',
'description' => 'This is our first question.',
'options' => [
Expand All @@ -185,6 +186,7 @@ public function dataGetForm() {
'order' => 2,
'type' => 'short',
'isRequired' => true,
'extraSettings' => [],
'text' => 'Question 2',
'description' => '',
'options' => []
Expand Down Expand Up @@ -252,6 +254,9 @@ public function testGetForm(array $expected) {
$question1->setOrder(1);
$question1->setType('dropdown');
$question1->setIsRequired(false);
$question1->setExtraSettings([
'shuffleOptions' => true
]);
$question1->setText('Question 1');
$question1->setDescription('This is our first question.');
$question2 = new Question();
Expand All @@ -262,6 +267,7 @@ public function testGetForm(array $expected) {
$question2->setIsRequired(true);
$question2->setText('Question 2');
$question2->setDescription('');
$question2->setExtraSettings([]);
$this->questionMapper->expects($this->once())
->method('findByForm')
->with(42)
Expand Down

0 comments on commit 2cd3bc6

Please sign in to comment.