Fix forms change saving & add question scroll

Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ (skjnldsv) 2020-04-21 18:03:43 +02:00 committed by Jonas Rittershofer
parent 2cd445d201
commit ebf66d7e82
7 changed files with 70 additions and 94 deletions

View file

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

View file

@ -25,7 +25,7 @@
<Content app-name="forms"> <Content app-name="forms">
<AppNavigation> <AppNavigation>
<AppNavigationNew button-class="icon-add" :text="t('forms', 'New form')" @click="onNewForm" /> <AppNavigationNew button-class="icon-add" :text="t('forms', 'New form')" @click="onNewForm" />
<AppNavigationForm v-for="form in formattedForms" <AppNavigationForm v-for="form in forms"
:key="form.id" :key="form.id"
:form="form" :form="form"
@delete="onDeleteForm" /> @delete="onDeleteForm" />
@ -71,8 +71,6 @@ import Content from '@nextcloud/vue/dist/Components/Content'
import AppNavigationForm from './components/AppNavigationForm' import AppNavigationForm from './components/AppNavigationForm'
import EmptyContent from './components/EmptyContent' import EmptyContent from './components/EmptyContent'
import { formatForm } from './utils/FormsUtils'
export default { export default {
name: 'Forms', name: 'Forms',
@ -97,10 +95,6 @@ export default {
return this.forms && this.forms.length === 0 return this.forms && this.forms.length === 0
}, },
formattedForms() {
return this.forms.map(formatForm)
},
hash() { hash() {
return this.$route.params.hash return this.$route.params.hash
}, },

View file

@ -94,5 +94,19 @@ export default {
onDelete() { onDelete() {
this.$emit('delete') this.$emit('delete')
}, },
/**
* Focus the first focusable element
*/
focus() {
this.edit = true
this.$el.scrollIntoView({ behavior: 'smooth' })
this.$nextTick(() => {
const title = this.$el.querySelector('.question__header-title')
if (title) {
title.select()
}
})
},
}, },
} }

View file

@ -21,6 +21,10 @@
export default { export default {
props: { props: {
hash: {
type: String,
default: '',
},
form: { form: {
type: Object, type: Object,
required: true, required: true,

View file

@ -56,7 +56,7 @@ export default new Router({
{ {
path: '/:hash', path: '/:hash',
name: 'fill', name: 'fill',
props: true, props: { default: true },
}, },
{ {
path: '/:hash/edit', path: '/:hash/edit',
@ -65,13 +65,13 @@ export default new Router({
sidebar: Sidebar, sidebar: Sidebar,
}, },
name: 'edit', name: 'edit',
props: true, props: { default: true },
}, },
{ {
path: '/:hash/results', path: '/:hash/results',
component: Results, component: Results,
name: 'results', name: 'results',
props: true, props: { default: true },
}, },
{ {
path: '/:hash/clone', path: '/:hash/clone',
@ -80,7 +80,7 @@ export default new Router({
sidebar: Sidebar, sidebar: Sidebar,
}, },
name: 'clone', name: 'clone',
props: true, props: { default: true },
}, },
], ],
}) })

View file

@ -1,37 +0,0 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @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/>.
*/
/**
* Format a form object prior to forms v2.0
*
* @param {Object} form the form raw object
* @returns {Object} properly formatted form object
*/
const formatForm = function(form) {
// clone form
const newForm = Object.assign({}, form)
// cleanup
delete newForm.event
return newForm
}
export { formatForm }

View file

@ -27,7 +27,7 @@
--> -->
<template> <template>
<AppContent v-if="loadingForm"> <AppContent v-if="isLoadingForm">
<EmptyContent icon="icon-loading"> <EmptyContent icon="icon-loading">
{{ t('forms', 'Loading form “{title}”', { title: form.title }) }} {{ t('forms', 'Loading form “{title}”', { title: form.title }) }}
</EmptyContent> </EmptyContent>
@ -76,10 +76,11 @@
v-tooltip="t('forms', 'Add a question to this form')" v-tooltip="t('forms', 'Add a question to this form')"
:aria-label="t('forms', 'Add a question to this form')" :aria-label="t('forms', 'Add a question to this form')"
:open.sync="questionMenuOpened" :open.sync="questionMenuOpened"
:default-icon="loadingQuestions ? 'icon-loading-small' : 'icon-add-white'"> :default-icon="isLoadingQuestions ? 'icon-loading-small' : 'icon-add-white'">
<ActionButton v-for="(answer, type) in answerTypes" <ActionButton v-for="(answer, type) in answerTypes"
:key="answer.label" :key="answer.label"
:disabled="loadingQuestions" :close-after-click="true"
:disabled="isLoadingQuestions"
:icon="answer.icon" :icon="answer.icon"
class="question-toolbar__question" class="question-toolbar__question"
@click="addQuestion(type)"> @click="addQuestion(type)">
@ -104,9 +105,10 @@
<Draggable v-model="form.questions" <Draggable v-model="form.questions"
:animation="200" :animation="200"
tag="ul" tag="ul"
@start="dragging = true" @start="isDragging = true"
@end="dragging = false"> @end="isDragging = false">
<Questions :is="answerTypes[question.type].component" <Questions ref="questions"
:is="answerTypes[question.type].component"
v-for="(question, index) in form.questions" v-for="(question, index) in form.questions"
:key="question.id" :key="question.id"
:model="answerTypes[question.type]" :model="answerTypes[question.type]"
@ -165,10 +167,13 @@ export default {
return { return {
questionMenuOpened: false, questionMenuOpened: false,
answerTypes, answerTypes,
loadingForm: true,
loadingQuestions: false, // Various states
isLoadingForm: true,
isLoadingQuestions: false,
errorForm: false, errorForm: false,
dragging: false,
isDragging: false,
} }
}, },
@ -186,15 +191,10 @@ export default {
}, },
watch: { watch: {
form: { // Fetch full form on change
deep: true, hash() {
handler: function(newForm, oldForm) { // TODO: cancel previous request if not done
if (newForm.hash === oldForm.hash) { this.fetchFullForm(this.form.id)
this.debounceSaveForm()
} else {
this.fetchFullForm(newForm.id)
}
},
}, },
}, },
@ -213,7 +213,7 @@ export default {
* @param {number} id the unique form hash * @param {number} id the unique form hash
*/ */
async fetchFullForm(id) { async fetchFullForm(id) {
this.loadingForm = true this.isLoadingForm = true
console.debug('Loading form', id) console.debug('Loading form', id)
try { try {
@ -223,14 +223,10 @@ export default {
console.error(error) console.error(error)
this.errorForm = true this.errorForm = true
} finally { } finally {
this.loadingForm = false this.isLoadingForm = false
} }
}, },
onSubmit(e) {
this.saveForm()
},
/** /**
* Add a new question to the current form * Add a new question to the current form
* *
@ -238,7 +234,7 @@ export default {
*/ */
async addQuestion(type) { async addQuestion(type) {
const text = t('forms', 'New question') const text = t('forms', 'New question')
this.loadingQuestions = true this.isLoadingQuestions = true
try { try {
const response = await axios.post(generateUrl('/apps/forms/api/v1/question'), { const response = await axios.post(generateUrl('/apps/forms/api/v1/question'), {
@ -255,23 +251,28 @@ export default {
answers: [], answers: [],
}, question)) }, question))
// Focus newly added question
this.$nextTick(() => {
const lastQuestion = this.$refs.questions[this.$refs.questions.length - 1]
lastQuestion.focus()
})
} catch (error) { } catch (error) {
console.error(error) console.error(error)
showError(t('forms', 'There was an error while adding the new question')) showError(t('forms', 'There was an error while adding the new question'))
} finally { } finally {
this.loadingQuestions = false this.isLoadingQuestions = false
} }
}, },
/** /**
* Delete a question * Delete a question
*
* @param {Object} question the question to delete * @param {Object} question the question to delete
* @param {number} question.id the question id to delete * @param {number} question.id the question id to delete
*/ */
async deleteQuestion(question) { async deleteQuestion({ id }) {
console.info(question) this.isLoadingQuestions = true
const id = question.id
this.loadingQuestions = true
try { try {
await axios.delete(generateUrl('/apps/forms/api/v1/question/{id}', { id })) await axios.delete(generateUrl('/apps/forms/api/v1/question/{id}', { id }))
@ -281,7 +282,7 @@ export default {
console.error(error) console.error(error)
showError(t('forms', 'There was an error while removing the question')) showError(t('forms', 'There was an error while removing the question'))
} finally { } finally {
this.loadingQuestions = false this.isLoadingQuestions = false
} }
}, },
@ -313,16 +314,16 @@ export default {
}, },
/** /**
* Forms saving handlers * Save form on submit
*/ */
debounceSaveForm: debounce(function() { onSubmit: debounce(function() {
this.saveForm() this.saveForm()
}, 200), }, 200),
async saveForm() { async saveForm() {
try { try {
// TODO: add loading status feedback ? // TODO: add loading status feedback ?
await axios.post(OC.generateUrl('/apps/forms/write/form'), this.form) await axios.post(OC.generateUrl('/apps/forms/update/form'), this.form)
} catch (error) { } catch (error) {
showError(t('forms', 'Error on saving form, see console')) showError(t('forms', 'Error on saving form, see console'))
console.error(error) console.error(error)