Option management

Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ (skjnldsv) 2020-04-22 17:57:31 +02:00
parent c6855b9fdf
commit 50594f0a01
No known key found for this signature in database
GPG key ID: 60C25B8C072916CF
9 changed files with 440 additions and 211 deletions

View file

@ -34,17 +34,24 @@ return [
['name' => 'page#goto_form', 'url' => '/{hash}', 'verb' => 'GET'],
['name' => 'page#insert_submission', 'url' => '/insert/submission', 'verb' => 'POST'],
// Forms
['name' => 'api#getForms', 'url' => '/api/v1/forms', 'verb' => 'GET'],
['name' => 'api#newForm', 'url' => '/api/v1/form', 'verb' => 'POST'],
['name' => 'api#getForm', 'url' => '/api/v1/form/{id}', 'verb' => 'GET'],
['name' => 'api#updateForm', 'url' => '/api/v1/form/update', 'verb' => 'POST'],
['name' => 'api#newForm', 'url' => '/api/v1/form', 'verb' => 'POST'],
['name' => 'api#deleteForm', 'url' => '/api/v1/form/{id}', 'verb' => 'DELETE'],
// Questions
['name' => 'api#newQuestion', 'url' => '/api/v1/question', 'verb' => 'POST'],
['name' => 'api#updateQuestion', 'url' => '/api/v1/question/update', 'verb' => 'POST'],
['name' => 'api#reorderQuestions', 'url' => '/api/v1/question/reorder', 'verb' => 'POST'],
['name' => 'api#newQuestion', 'url' => '/api/v1/question', 'verb' => 'POST'],
['name' => 'api#deleteQuestion', 'url' => '/api/v1/question/{id}', 'verb' => 'DELETE'],
// Answers
['name' => 'api#newOption', 'url' => '/api/v1/option', 'verb' => 'POST'],
['name' => 'api#updateOption', 'url' => '/api/v1/option/update', 'verb' => 'POST'],
['name' => 'api#deleteOption', 'url' => '/api/v1/option/{id}', 'verb' => 'DELETE'],
['name' => 'api#getSubmissions', 'url' => '/api/v1/submissions/{hash}', 'verb' => 'GET'],
['name' => 'system#get_site_users_and_groups', 'url' => '/get/siteusers', 'verb' => 'POST'],

View file

@ -473,16 +473,15 @@ class ApiController extends Controller {
/**
* @NoAdminRequired
*/
public function newOption(int $formId, int $questionId, string $text): Http\JSONResponse {
$this->logger->debug('Adding new option: formId: {formId}, questionId: {questionId}, text: {text}', [
'formId' => $formId,
public function newOption(int $questionId, string $text): Http\JSONResponse {
$this->logger->debug('Adding new option: questionId: {questionId}, text: {text}', [
'questionId' => $questionId,
'text' => $text,
]);
try {
$form = $this->formMapper->findById($formId);
$question = $this->questionMapper->findById($questionId);
$form = $this->formMapper->findById($question->getFormId());
} catch (IMapperException $e) {
$this->logger->debug('Could not find form or question so option can\'t be added');
return new Http\JSONResponse([], Http::STATUS_BAD_REQUEST);
@ -493,11 +492,6 @@ class ApiController extends Controller {
return new Http\JSONResponse([], Http::STATUS_FORBIDDEN);
}
if ($question->getFormId() !== $formId) {
$this->logger->debug('This question is not part of the current form');
return new Http\JSONResponse([], Http::STATUS_FORBIDDEN);
}
$option = new Option();
$option->setQuestionId($questionId);
@ -505,6 +499,45 @@ class ApiController extends Controller {
$option = $this->optionMapper->insert($option);
return new Http\JSONResponse([
'id' => $option->getId()
]);
}
/**
* @NoAdminRequired
* Writes the given key-value pairs into Database.
* @param int $id OptionId of option to update
* @param array $keyValuePairs Array of key=>value pairs to update.
*/
public function updateOption(int $id, array $keyValuePairs): Http\JSONResponse {
$this->logger->debug('Updating option: option: {id}, values: {keyValuePairs}', [
'id' => $id,
'keyValuePairs' => $keyValuePairs
]);
try {
$option = $this->optionMapper->findById($id);
$question = $this->questionMapper->findById($option->getQuestionId());
$form = $this->formMapper->findById($question->getFormId());
} catch (IMapperException $e) {
$this->logger->debug('Could not find option, question or form');
return new Http\JSONResponse(['message' => 'Could not find option, question or form'], Http::STATUS_BAD_REQUEST);
}
if ($form->getOwnerId() !== $this->userId) {
$this->logger->debug('This form is not owned by the current user');
return new Http\JSONResponse([], Http::STATUS_FORBIDDEN);
}
// Create OptionEntity with given Params & Id.
$option = Option::fromParams($keyValuePairs);
$option->setId($id);
// Update changed Columns in Db.
$this->optionMapper->update($option);
return new Http\JSONResponse($option->getId());
}
@ -532,7 +565,6 @@ class ApiController extends Controller {
$this->optionMapper->delete($option);
//TODO useful response
return new Http\JSONResponse($id);
}

30
package-lock.json generated
View file

@ -4623,6 +4623,11 @@
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="
},
"eventemitter3": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz",
"integrity": "sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg=="
},
"events": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.0.0.tgz",
@ -8209,6 +8214,11 @@
"os-tmpdir": "^1.0.0"
}
},
"p-debounce": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/p-debounce/-/p-debounce-2.1.0.tgz",
"integrity": "sha512-M9bMt62TTnozdZhqFgs+V7XD2MnuKCaz+7fZdlu2/T7xruI3uIE5CicQ0vx1hV7HIUYF0jF+4/R1AgfOkl74Qw=="
},
"p-defer": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz",
@ -8218,8 +8228,7 @@
"p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=",
"dev": true
"integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4="
},
"p-is-promise": {
"version": "2.1.0",
@ -8245,6 +8254,23 @@
"p-limit": "^2.0.0"
}
},
"p-queue": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.3.0.tgz",
"integrity": "sha512-fg5dJlFpd5+3CgG3/0ogpVZUeJbjiyXFg0nu53hrOYsybqSiDyxyOpad0Rm6tAiGjgztAwkyvhlYHC53OiAJOA==",
"requires": {
"eventemitter3": "^4.0.0",
"p-timeout": "^3.1.0"
}
},
"p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"requires": {
"p-finally": "^1.0.0"
}
},
"p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",

View file

@ -81,6 +81,8 @@
"crypto-js": "^4.0.0",
"debounce": "^1.2.0",
"json2csv": "5.0.0",
"p-debounce": "^2.1.0",
"p-queue": "^6.3.0",
"regenerator-runtime": "^0.13.5",
"v-click-outside": "^3.0.1",
"vue": "^2.6.11",

View file

@ -0,0 +1,182 @@
<template>
<li class="question__item">
<!-- TODO: properly choose max length -->
<input
ref="input"
:aria-label="t('forms', 'An answer for the {index} option', { index: index + 1 })"
:placeholder="t('forms', 'Answer number {index}', { index: index + 1 })"
:value="answer.text"
class="question__input"
maxlength="256"
minlength="1"
type="text"
@input="onInput"
@keydown.delete="deleteEntry"
@keydown.enter.prevent="addNewEntry">
<!-- Delete answer -->
<Actions>
<ActionButton icon="icon-close" @click="deleteEntry">
{{ t('forms', 'Delete answer') }}
</ActionButton>
</Actions>
</li>
</template>
<script>
import { showError } from '@nextcloud/dialogs'
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import pDebounce from 'p-debounce'
import PQueue from 'p-queue'
import Actions from '@nextcloud/vue/dist/Components/Actions'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
export default {
name: 'AnswerInput',
components: {
Actions,
ActionButton,
},
props: {
answer: {
type: Object,
required: true,
},
index: {
type: Number,
required: true,
},
},
data() {
return {
queue: new PQueue({ concurrency: 1 }),
}
},
methods: {
/**
* Focus the input
*/
focus() {
this.$refs.input.focus()
},
/**
* Option changed, processing the data
*/
async onInput() {
// clone answer
const answer = Object.assign({}, this.answer)
answer.text = this.$refs.input.value
if (this.answer.local) {
// Dispatched for creation. Marked as synced
this.answer.local = false
const newAnswer = await this.debounceCreateAnswer(answer)
this.$emit('update:answer', answer.id, newAnswer)
} else {
this.debounceUpdateAnswer(answer)
this.$emit('update:answer', answer.id, answer)
}
},
/**
* Request a new answer
*/
addNewEntry() {
this.$emit('add')
},
/**
* Emit a delete request for this answer
* when pressing the delete key on an empty input
*
* @param {Event} e the event
*/
async deleteEntry(e) {
if (e.type !== 'click' && this.$refs.input.value.length !== 0) {
return
}
// Dismiss delete key action
e.preventDefault()
const answer = Object.assign({}, this.answer)
const index = this.index
if (!answer.local) {
// let's not await, deleting in background
axios.delete(generateUrl('/apps/forms/api/v1/option/{id}', { id: this.answer.id }))
.catch(error => {
showError(t('forms', 'There was an issue deleting this option'))
console.error(error)
// restore option
this.$emit('restore', answer, index)
})
}
this.$emit('delete', answer.id, index)
},
/**
* Create an unsynced answer to the server
*
* @param {Object} answer the answer to sync
* @returns {Object} answer
*/
async createAnswer(answer) {
try {
const response = await axios.post(generateUrl('/apps/forms/api/v1/option'), {
questionId: answer.question_id,
text: answer.text,
})
// Was synced once, this is now up to date with the server
delete answer.local
return Object.assign({}, answer, response.data)
} catch (error) {
showError(t('forms', 'Error while saving the answer'))
console.error(error)
}
return answer
},
debounceCreateAnswer: pDebounce(function(answer) {
return this.queue.add(() => this.createAnswer(answer))
}, 100),
/**
* Save to the server, only do it after 500ms
* of no change
*
* @param {Object} answer the answer to sync
*/
async updateAnswer(answer) {
try {
await axios.post(generateUrl('/apps/forms/api/v1/option/update'), {
id: this.answer.id,
keyValuePairs: {
text: answer.text,
},
})
console.debug('Updated answer', answer)
} catch (error) {
showError(t('forms', 'Error while saving the answer'))
console.error(error)
}
},
debounceUpdateAnswer: pDebounce(function(answer) {
return this.queue.add(() => this.updateAnswer(answer))
}, 500),
},
}
</script>
<style lang="scss" scoped>
</style>

View file

@ -22,6 +22,7 @@
<template>
<Question
:id="id"
v-bind.sync="$attrs"
:text="text"
:edit.sync="edit"
@ -29,13 +30,13 @@
@update:text="onTitleChange">
<ul class="question__content" :role="isUnique ? 'radiogroup' : ''">
<template v-for="(answer, index) in options">
<li :key="index" class="question__item">
<li v-if="!edit" :key="answer.id" class="question__item">
<!-- Answer radio/checkbox + label -->
<!-- TODO: migrate to radio/checkbox component once ready -->
<input :id="`${id}-answer-${index}`"
<!-- TODO: migrate to radio/checkbox component once available -->
<input :id="`${id}-answer-${answer.id}`"
ref="checkbox"
:aria-checked="isChecked(index)"
:checked="isChecked(index)"
:aria-checked="isChecked(answer.id)"
:checked="isChecked(answer.id)"
:class="{
'radio question__radio': isUnique,
'checkbox question__checkbox': !isUnique,
@ -45,33 +46,23 @@
:type="isUnique ? 'radio' : 'checkbox'">
<label v-if="!edit"
ref="label"
:for="`${id}-answer-${index}`"
:for="`${id}-answer-${answer.id}`"
class="question__label">{{ answer.text }}</label>
<!-- Answer text input edit -->
<!-- TODO: properly choose max length -->
<input v-else
ref="input"
:aria-label="t('forms', 'An answer for the {index} option', { index: index + 1 })"
:placeholder="t('forms', 'Answer number {index}', { index: index + 1 })"
:value="answer.text"
class="question__input"
maxlength="256"
minlength="1"
type="text"
@input="onInput(index)"
@keydown.enter.prevent="addNewEntry"
@keydown.delete="deleteEntry($event, index)">
<!-- Delete answer -->
<Actions v-if="edit">
<ActionButton icon="icon-close" @click="deleteEntry($event, index)">
{{ t('forms', 'Delete answer') }}
</ActionButton>
</Actions>
</li>
<!-- Answer text input edit -->
<AnswerInput v-else
:key="index /* using index to keep the same vnode after new answer creation */"
ref="input"
:answer="answer"
:index="index"
@add="addNewEntry"
@delete="deleteAnswer"
@update:answer="updateAnswer"
@restore="restoreAnswer" />
</template>
<li v-if="edit && !isLastEmpty" class="question__item">
<li v-if="(edit && !isLastEmpty) || hasNoAnswer" class="question__item">
<!-- TODO: properly choose max length -->
<input
:aria-label="t('forms', 'Add a new answer')"
@ -87,9 +78,7 @@
</template>
<script>
import Actions from '@nextcloud/vue/dist/Components/Actions'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import AnswerInput from './AnswerInput'
import QuestionMixin from '../../mixins/QuestionMixin'
import GenRandomId from '../../utils/GenRandomId'
@ -100,34 +89,38 @@ export default {
name: 'QuestionMultiple',
components: {
Actions,
ActionButton,
AnswerInput,
},
mixins: [QuestionMixin],
data() {
return {
id: GenRandomId(),
}
props: {
id: {
type: Number,
required: true,
},
},
computed: {
isLastEmpty() {
const value = this.values[this.values.length - 1]
return value && value.trim().length === 0
const value = this.options[this.options.length - 1]
return value?.text?.trim().length === 0
},
isUnique() {
return this.model.unique === true
},
hasNoAnswer() {
return this.options.length === 0
},
},
watch: {
edit(edit) {
if (!edit) {
// Filter and update questions
this.$emit('update:values', this.values.filter(answer => !!answer))
// Filter out empty options and update question
this.$emit('update:options', this.options.filter(answer => !!answer.text))
}
},
},
@ -145,54 +138,80 @@ export default {
},
/**
* Update the values
* @param {Array} values values to change
* Update the options
* @param {Array} options options to change
*/
updateValues(values) {
this.$emit('update:values', this.isUnique ? [values[0]] : values)
updateOptions(options) {
this.$emit('update:options', options)
},
onInput(index) {
// Update values
const input = this.$refs.input[index]
const values = this.values.slice()
values[index] = input.value
/**
* Update an existing answer locally
*
* @param {string|number} id the answer id
* @param {Object} answer the answer to update
*/
updateAnswer(id, answer) {
const options = this.options.slice()
const answerIndex = options.findIndex(option => option.id === id)
options[answerIndex] = answer
// Update question
this.updateValues(values)
this.updateOptions(options)
},
/**
* Add a new empty answer locally
*/
addNewEntry() {
// Add entry
const values = this.values.slice()
values.push('')
// Add local entry
const options = this.options.slice()
options.push({
id: GenRandomId(),
question_id: this.id,
text: '',
local: true,
})
// Update question
this.updateValues(values)
this.updateOptions(options)
this.$nextTick(() => {
this.focusIndex(values.length - 1)
this.focusIndex(options.length - 1)
})
},
deleteEntry(e, index) {
const input = this.$refs.input[index]
/**
* Restore an answer locally
*
* @param {Object} answer the answer
* @param {number} index the answer index in this.options
*/
restoreAnswer(answer, index) {
const options = this.options.slice()
options.splice(index, 0, answer)
if (input.value.length === 0) {
// Dismiss delete action
e.preventDefault()
this.updateOptions(options)
this.focusIndex(index)
},
// Remove entry
const values = this.values.slice()
values.splice(index, 1)
/**
* Delete an answer locally
*
* @param {number} id the answer is
* @param {number} index the answer index in this.options
*/
deleteAnswer(id, index) {
// Remove entry
const options = this.options.slice()
const optionIndex = options.findIndex(option => option.id === id)
options.splice(optionIndex, 1)
// Update question
this.updateValues(values)
// Update question
this.updateOptions(options)
this.$nextTick(() => {
this.focusNext(index)
})
}
this.$nextTick(() => {
this.focusIndex(index + 1)
})
},
/**
@ -201,9 +220,10 @@ export default {
* @param {Number} index the value index
*/
focusIndex(index) {
const input = this.$refs.input[index]
if (input) {
input.focus()
const inputs = this.$refs.input
if (inputs && inputs[index]) {
const input = inputs[index]
input.focus()
}
},
},

View file

@ -39,7 +39,7 @@ export default {
async saveFormProperty(key) {
try {
// TODO: add loading status feedback ?
await axios.post(generateUrl('/apps/forms/api/v1/form/update/'), {
await axios.post(generateUrl('/apps/forms/api/v1/form/update'), {
id: this.form.id,
keyValuePairs: {
[key]: this.form[key],

View file

@ -0,0 +1,57 @@
/**
* @copyright Copyright (c) 2019 Marco Ambrosini <marcoambrosini@pm.me>
*
* @author Marco Ambrosini <marcoambrosini@pm.me>
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* 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/>.
*
*/
import axios from '@nextcloud/axios'
/**
* Creates a cancelable axios 'request object'.
*
* @param {function} request the axios promise request
* @returns {Object}
*/
const CancelableRequest = function(request) {
/**
* Generate an axios cancel token
*/
const CancelToken = axios.CancelToken
const source = CancelToken.source()
/**
* Execute the request
*
* @param {string} url the url to send the request to
* @param {Object} [options] optional config for the request
*/
const fetch = async function(url, options) {
return request(
url,
Object.assign({ cancelToken: source.token }, { options })
)
}
return {
request: fetch,
cancel: source.cancel,
}
}
export default CancelableRequest

View file

@ -58,16 +58,17 @@
:required="true"
autofocus
type="text"
@change="onTitleChange"
@click="selectIfUnchanged">
@click="selectIfUnchanged"
@keyup="onTitleChange">
<label class="hidden-visually" for="form-desc">{{ t('forms', 'Description') }}</label>
<textarea
id="form-desc"
ref="description"
v-model="form.description"
:placeholder="t('forms', 'Description')"
@change="onDescChange"
@keydown="autoSizeDescription" />
@change="autoSizeDescription"
@keydown="autoSizeDescription"
@keyup="onDescChange" />
</header>
<section>
@ -102,24 +103,22 @@
</EmptyContent>
<!-- Questions list -->
<form @submit.prevent="onSubmit">
<Draggable v-model="form.questions"
:animation="200"
tag="ul"
@change="onQuestionOrderChange"
@start="isDragging = true"
@end="isDragging = false">
<Questions
:is="answerTypes[question.type].component"
v-for="(question, index) in form.questions"
ref="questions"
:key="question.id"
:model="answerTypes[question.type]"
:index="index + 1"
v-bind.sync="question"
@delete="deleteQuestion(question)" />
</Draggable>
</form>
<Draggable v-model="form.questions"
:animation="200"
tag="ul"
@change="onQuestionOrderChange"
@start="isDragging = true"
@end="isDragging = false">
<Questions
:is="answerTypes[question.type].component"
v-for="(question, index) in form.questions"
ref="questions"
:key="question.id"
:model="answerTypes[question.type]"
:index="index + 1"
v-bind.sync="question"
@delete="deleteQuestion(question)" />
</Draggable>
</section>
</AppContent>
</template>
@ -230,13 +229,6 @@ export default {
}
},
/**
* Save form on submit
*/
onSubmit: debounce(function() {
this.saveForm()
}, 200),
/**
* Title & description save methods
*/
@ -380,7 +372,9 @@ export default {
</script>
<style lang="scss">
#app-content {
// Replace with new vue components release
#app-content,
#app-content-vue {
display: flex;
align-items: center;
flex-direction: column;
@ -460,95 +454,4 @@ export default {
}
}
}
/* Transitions for inserting and removing list items */
/*.list-enter-active,
.list-leave-active {
transition: all .5s ease;
}
.list-enter,
.list-leave-to {
opacity: 0;
}
.list-move {
transition: transform .5s;
}
#form-item-selector-text {
> input {
width: 100%;
}
}
.form-table {
> li {
display: flex;
overflow: hidden;
align-items: baseline;
min-height: 24px;
padding-right: 8px;
padding-left: 8px;
white-space: nowrap;
border-bottom: 1px solid var(--color-border);
line-height: 24px;
&:active,
&:hover {
transition: var(--background-dark) .3s ease;
background-color: var(--color-background-dark); //$hover-color;
}
> div {
display: flex;
flex-grow: 1;
padding-right: 4px;
white-space: normal;
opacity: .7;
font-size: 1.2em;
&.avatar {
flex-grow: 0;
}
}
> div:nth-last-child(1) {
flex-grow: 0;
flex-shrink: 0;
justify-content: center;
}
}
}
button {
&.button-inline {
border: 0;
background-color: transparent;
}
}
.tab {
display: flex;
flex-wrap: wrap;
}
.selectUnit {
display: flex;
align-items: center;
flex-wrap: nowrap;
> label {
padding-right: 4px;
}
}
#shiftDates {
min-width: 16px;
min-height: 16px;
margin: 0;
padding: 10px;
padding-left: 34px;
text-align: left;
background-repeat: no-repeat;
background-position: 10px center;
} */
</style>