Option management
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
This commit is contained in:
parent
c6855b9fdf
commit
50594f0a01
|
@ -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'],
|
||||
|
|
|
@ -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
30
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
182
src/components/Questions/AnswerInput.vue
Normal file
182
src/components/Questions/AnswerInput.vue
Normal 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>
|
|
@ -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()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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],
|
||||
|
|
57
src/utils/CancelableRequest.js
Normal file
57
src/utils/CancelableRequest.js
Normal 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
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue