Merge pull request #347 from nextcloud/enh/mandatory
Mandatory option on questions
This commit is contained in:
commit
0904899811
7 changed files with 150 additions and 61 deletions
|
|
@ -43,10 +43,13 @@
|
|||
minlength="1"
|
||||
:maxlength="maxQuestionLength"
|
||||
required
|
||||
@input="onInput"
|
||||
@keyup="onTitleChange">
|
||||
<h3 v-else class="question__header-title" v-text="text" />
|
||||
@input="onTitleChange">
|
||||
<h3 v-else class="question__header-title" v-text="computedText" />
|
||||
<Actions v-if="!readOnly" class="question__header-menu" :force-menu="true">
|
||||
<ActionCheckbox :checked="mandatory"
|
||||
@update:checked="onMandatoryChange">
|
||||
{{ t('forms', 'Mandatory') }}
|
||||
</ActionCheckbox>
|
||||
<ActionButton icon="icon-delete" @click="onDelete">
|
||||
{{ t('forms', 'Delete question') }}
|
||||
</ActionButton>
|
||||
|
|
@ -59,14 +62,10 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import axios from '@nextcloud/axios'
|
||||
import debounce from 'debounce'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { directive as ClickOutside } from 'v-click-outside'
|
||||
|
||||
import Actions from '@nextcloud/vue/dist/Components/Actions'
|
||||
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
|
||||
import ActionCheckbox from '@nextcloud/vue/dist/Components/ActionCheckbox'
|
||||
|
||||
export default {
|
||||
name: 'Question',
|
||||
|
|
@ -78,13 +77,10 @@ export default {
|
|||
components: {
|
||||
Actions,
|
||||
ActionButton,
|
||||
ActionCheckbox,
|
||||
},
|
||||
|
||||
props: {
|
||||
id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
|
|
@ -93,6 +89,10 @@ export default {
|
|||
type: String,
|
||||
required: true,
|
||||
},
|
||||
mandatory: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
edit: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
|
|
@ -107,11 +107,28 @@ export default {
|
|||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* Extend text with asterisk if question is mandatory
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
computedText() {
|
||||
if (this.mandatory) {
|
||||
return this.text + ' *'
|
||||
}
|
||||
return this.text
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onInput({ target }) {
|
||||
onTitleChange({ target }) {
|
||||
this.$emit('update:text', target.value)
|
||||
},
|
||||
|
||||
onMandatoryChange(mandatory) {
|
||||
this.$emit('update:mandatory', mandatory)
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable the edit mode
|
||||
*/
|
||||
|
|
@ -136,25 +153,6 @@ export default {
|
|||
onDelete() {
|
||||
this.$emit('delete')
|
||||
},
|
||||
|
||||
onTitleChange: debounce(function() {
|
||||
this.saveQuestionProperty('text')
|
||||
}, 200),
|
||||
|
||||
async saveQuestionProperty(key) {
|
||||
try {
|
||||
// TODO: add loading status feedback ?
|
||||
await axios.post(generateUrl('/apps/forms/api/v1/question/update'), {
|
||||
id: this.id,
|
||||
keyValuePairs: {
|
||||
[key]: this[key],
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
showError(t('forms', 'Error while saving question'))
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -24,16 +24,18 @@
|
|||
<Question
|
||||
v-bind.sync="$attrs"
|
||||
:text="text"
|
||||
:mandatory="mandatory"
|
||||
:edit.sync="edit"
|
||||
:max-question-length="maxStringLengths.questionText"
|
||||
@delete="onDelete"
|
||||
@update:text="onTitleChange">
|
||||
@update:text="onTitleChange"
|
||||
@update:mandatory="onMandatoryChange"
|
||||
@delete="onDelete">
|
||||
<div class="question__content">
|
||||
<!-- TODO: properly choose max length -->
|
||||
<textarea ref="textarea"
|
||||
:aria-label="t('forms', 'A long answer for the question “{text}”', { text })"
|
||||
:placeholder="t('forms', 'Long answer text')"
|
||||
:required="true /* TODO: implement required option */"
|
||||
:required="mandatory"
|
||||
:value="values[0]"
|
||||
class="question__text"
|
||||
:maxlength="maxStringLengths.answerText"
|
||||
|
|
|
|||
|
|
@ -22,13 +22,14 @@
|
|||
|
||||
<template>
|
||||
<Question
|
||||
:id="id"
|
||||
v-bind.sync="$attrs"
|
||||
:text="text"
|
||||
:mandatory="mandatory"
|
||||
:edit.sync="edit"
|
||||
:max-question-length="maxStringLengths.questionText"
|
||||
@delete="onDelete"
|
||||
@update:text="onTitleChange">
|
||||
@update:text="onTitleChange"
|
||||
@update:mandatory="onMandatoryChange"
|
||||
@delete="onDelete">
|
||||
<ul class="question__content">
|
||||
<template v-for="(answer, index) in options">
|
||||
<li v-if="!edit" :key="answer.id" class="question__item">
|
||||
|
|
@ -96,13 +97,6 @@ export default {
|
|||
|
||||
mixins: [QuestionMixin],
|
||||
|
||||
props: {
|
||||
id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
isLastEmpty() {
|
||||
const value = this.options[this.options.length - 1]
|
||||
|
|
@ -172,10 +166,18 @@ export default {
|
|||
* @returns {boolean}
|
||||
*/
|
||||
isRequired(id) {
|
||||
// false, if question not mandatory
|
||||
if (!this.mandatory) {
|
||||
return false
|
||||
}
|
||||
|
||||
// true for Radiobuttons
|
||||
if (this.isUnique) {
|
||||
return true
|
||||
}
|
||||
return this.areNoneChecked || this.isChecked(id)
|
||||
|
||||
// For checkboxes, only required if no other is checked
|
||||
return this.areNoneChecked
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -24,16 +24,18 @@
|
|||
<Question
|
||||
v-bind.sync="$attrs"
|
||||
:text="text"
|
||||
:mandatory="mandatory"
|
||||
:edit.sync="edit"
|
||||
:max-question-length="maxStringLengths.questionText"
|
||||
@delete="onDelete"
|
||||
@update:text="onTitleChange">
|
||||
@update:text="onTitleChange"
|
||||
@update:mandatory="onMandatoryChange"
|
||||
@delete="onDelete">
|
||||
<div class="question__content">
|
||||
<!-- TODO: properly choose max length -->
|
||||
<input ref="input"
|
||||
:aria-label="t('forms', 'A short answer for the question “{text}”', { text })"
|
||||
:placeholder="t('forms', 'Short answer text')"
|
||||
:required="true /* TODO: implement required option */"
|
||||
:required="mandatory"
|
||||
:value="values[0]"
|
||||
class="question__input"
|
||||
:maxlength="maxStringLengths.answerText"
|
||||
|
|
|
|||
|
|
@ -18,12 +18,25 @@
|
|||
* 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/>.
|
||||
*/
|
||||
import { debounce } from 'debounce'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import axios from '@nextcloud/axios'
|
||||
|
||||
import Question from '../components/Questions/Question'
|
||||
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
|
||||
/**
|
||||
* Question-Id
|
||||
*/
|
||||
id: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* The question title
|
||||
*/
|
||||
|
|
@ -32,6 +45,14 @@ export default {
|
|||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* Mandatory-Setting
|
||||
*/
|
||||
mandatory: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
|
||||
/**
|
||||
* The user answers
|
||||
*/
|
||||
|
|
@ -80,13 +101,24 @@ export default {
|
|||
|
||||
methods: {
|
||||
/**
|
||||
* Forward the title change to the parent
|
||||
* Forward the title change to the parent and store to db
|
||||
*
|
||||
* @param {string} text the title
|
||||
*/
|
||||
onTitleChange(text) {
|
||||
onTitleChange: debounce(function(text) {
|
||||
this.$emit('update:text', text)
|
||||
},
|
||||
this.saveQuestionProperty('text', text)
|
||||
}, 200),
|
||||
|
||||
/**
|
||||
* Forward the mandatory change to the parent and store to db
|
||||
*
|
||||
* @param {Boolean} mandatoryValue new mandatory Value
|
||||
*/
|
||||
onMandatoryChange: debounce(function(mandatoryValue) {
|
||||
this.$emit('update:mandatory', mandatoryValue)
|
||||
this.saveQuestionProperty('mandatory', mandatoryValue)
|
||||
}, 200),
|
||||
|
||||
/**
|
||||
* Forward the answer(s) change to the parent
|
||||
|
|
@ -117,5 +149,20 @@ export default {
|
|||
}
|
||||
})
|
||||
},
|
||||
|
||||
async saveQuestionProperty(key, value) {
|
||||
try {
|
||||
// TODO: add loading status feedback ?
|
||||
await axios.post(generateUrl('/apps/forms/api/v1/question/update'), {
|
||||
id: this.id,
|
||||
keyValuePairs: {
|
||||
[key]: value,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
showError(t('forms', 'Error while saving question'))
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,14 +60,18 @@
|
|||
@keyup="onTitleChange">
|
||||
<label class="hidden-visually" for="form-desc">{{ t('forms', 'Description') }}</label>
|
||||
<textarea
|
||||
id="form-desc"
|
||||
ref="description"
|
||||
v-model="form.description"
|
||||
class="form-desc"
|
||||
:maxlength="maxStringLengths.formDescription"
|
||||
:placeholder="t('forms', 'Description')"
|
||||
@change="autoSizeDescription"
|
||||
@keydown="autoSizeDescription"
|
||||
@keyup="onDescChange" />
|
||||
<!-- Only visible if at least one question is marked as mandatory-->
|
||||
<p v-if="mandatoryUsed" class="form-desc info-mandatory">
|
||||
* {{ t('forms', 'Mandatory questions') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
|
|
@ -181,6 +185,16 @@ export default {
|
|||
hasQuestions() {
|
||||
return this.form.questions && this.form.questions.length === 0
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if at least one question is mandatory
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
mandatoryUsed() {
|
||||
return this.form.questions.reduce(
|
||||
(isUsed, question) => isUsed || question.mandatory
|
||||
, false)
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
|
@ -378,10 +392,11 @@ export default {
|
|||
header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 44px;
|
||||
margin-top: 44px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
#form-title,
|
||||
#form-desc {
|
||||
.form-desc {
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
border: none;
|
||||
|
|
@ -395,10 +410,14 @@ export default {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#form-desc {
|
||||
min-height: 60px;
|
||||
.form-desc {
|
||||
padding-bottom: 20px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.info-mandatory {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-content__button {
|
||||
|
|
|
|||
|
|
@ -28,9 +28,13 @@
|
|||
<h2 id="form-title">
|
||||
{{ form.title }}
|
||||
</h2>
|
||||
<p v-if="!loading && !success" id="form-desc">
|
||||
<p v-if="!loading && !success" class="form-desc">
|
||||
{{ form.description }}
|
||||
</p>
|
||||
<!-- Only visible if at least one question is marked as mandatory-->
|
||||
<p v-if="mandatoryUsed && !loading && !success" class="form-desc info-mandatory">
|
||||
* {{ t('forms', 'Mandatory questions') }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Questions list -->
|
||||
|
|
@ -121,6 +125,16 @@ export default {
|
|||
return true
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if at least one question is mandatory
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
mandatoryUsed() {
|
||||
return this.form.questions.reduce(
|
||||
(isUsed, question) => isUsed || question.mandatory
|
||||
, false)
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
|
@ -170,9 +184,10 @@ export default {
|
|||
// Title & description header
|
||||
header {
|
||||
margin-top: 44px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
#form-title,
|
||||
#form-desc {
|
||||
.form-desc {
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
border: none;
|
||||
|
|
@ -186,10 +201,14 @@ export default {
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#form-desc {
|
||||
min-height: 60px;
|
||||
.form-desc {
|
||||
padding-bottom: 20px;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.info-mandatory {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue