Multiple uniques & aria

Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ (skjnldsv) 2020-04-10 17:35:40 +02:00 committed by Jonas Rittershofer
parent 7aa3f7ace0
commit df69a7a4a3
15 changed files with 399 additions and 375 deletions

View file

@ -95,7 +95,7 @@ class ApiController extends Controller {
} catch (DoesNotExistException $e) {
//handle silently
}finally{
} finally {
return $optionList;
}
}
@ -119,6 +119,7 @@ class ApiController extends Controller {
/**
* @NoAdminRequired
*
* Read Form-List only with necessary information for Listing.
*/
public function getForms(): Http\JSONResponse {
@ -150,7 +151,7 @@ class ApiController extends Controller {
}
$result = $form->read();
$result['questions'] = getQuestions();
$result['questions'] = $this->getQuestions($id);
return new Http\JSONResponse($result);
}
@ -241,7 +242,7 @@ class ApiController extends Controller {
// Delete Submissions(incl. Answers), Questions(incl. Options) and Form.
$this->submissionMapper->deleteByForm($id);
$this->questionMapper->deleteByForm($id);
$this->formMapper->delete($formToDelete);
$this->formMapper->delete($form);
return new Http\JSONResponse($id);
}
@ -291,10 +292,8 @@ class ApiController extends Controller {
$question = $this->questionMapper->insert($question);
$response = [
'id' => $question->getId(),
'order' => $question->getOrder()
];
$response = $question->read();
$response['options'] = [];
return new Http\JSONResponse($response);
}

View file

@ -107,6 +107,7 @@ class Version010200Date20200323141300 extends SimpleMigrationStep {
]);
$table->addColumn('expires_timestamp', Type::INTEGER, [
'notnull' => false,
'default' => 0,
'comment' => 'unix-timestamp',
]);
$table->addColumn('is_anonymous', Type::BOOLEAN, [
@ -140,7 +141,7 @@ class Version010200Date20200323141300 extends SimpleMigrationStep {
]);
$table->addColumn('mandatory', Type::BOOLEAN, [
'notnull' => true,
'default' => 1,
'default' => 0,
]);
$table->addColumn('text', Type::STRING, [
'notnull' => true,

90
package-lock.json generated
View file

@ -1415,11 +1415,11 @@
}
},
"@babel/runtime": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.8.4.tgz",
"integrity": "sha512-neAp3zt80trRVBI1x0azq6c57aNBqYZH8KhMm3TaB7wEI5Q4A2SHfBHE8w9gOhI/lrqxtEbXZgQIrHP+wvSGwQ==",
"version": "7.9.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.9.2.tgz",
"integrity": "sha512-NE2DtOdufG7R5vnfQUTehdTfNycfUANEtCa9PssN9O/xmTzP4E08UI797ixaei6hBEVL9BI/PsdJS5x7mWoB9Q==",
"requires": {
"regenerator-runtime": "^0.13.2"
"regenerator-runtime": "^0.13.4"
}
},
"@babel/template": {
@ -1518,13 +1518,6 @@
"requires": {
"@nextcloud/event-bus": "^1.1.3",
"core-js": "^3.6.4"
},
"dependencies": {
"core-js": {
"version": "3.6.5",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz",
"integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA=="
}
}
},
"@nextcloud/axios": {
@ -1586,9 +1579,9 @@
}
},
"@nextcloud/event-bus": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@nextcloud/event-bus/-/event-bus-1.1.4.tgz",
"integrity": "sha512-It27KzmUaSQ7w22nHFwOn8XgeVG0HYYOSNG9gs4UkP5VqcZ16m4ydt3GkMpWcyFec4OUjJc+yf7omRc3pNxsSw==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@nextcloud/event-bus/-/event-bus-1.1.3.tgz",
"integrity": "sha512-/f3OMh9Tu3bn17sCc1Sb5AaC/fjegP9bjFmlsPDFNcCAHrKKM5B2X+2eUDF2osLirYaBjVqypBmD87zyiE0WjQ==",
"requires": {
"@types/semver": "^6.2.1",
"core-js": "^3.6.2",
@ -1596,9 +1589,9 @@
},
"dependencies": {
"core-js": {
"version": "3.6.5",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz",
"integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA=="
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.4.tgz",
"integrity": "sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw=="
},
"semver": {
"version": "6.3.0",
@ -1614,16 +1607,6 @@
"requires": {
"core-js": "^3.6.4",
"node-gettext": "^3.0.0"
},
"dependencies": {
"node-gettext": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/node-gettext/-/node-gettext-3.0.0.tgz",
"integrity": "sha512-/VRYibXmVoN6tnSAY2JWhNRhWYJ8Cd844jrZU/DwLVoI4vBI6ceYbd8i42sYZ9uOgDH3S7vslIKOWV/ZrT2YBA==",
"requires": {
"lodash.get": "^4.4.2"
}
}
}
},
"@nextcloud/moment": {
@ -1657,16 +1640,13 @@
}
}
},
"core-js": {
<<<<<<< HEAD
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.4.tgz",
"integrity": "sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw=="
=======
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.1.tgz",
"integrity": "sha512-186WjSik2iTGfDjfdCZAxv2ormxtKgemjC3SI6PL31qOA0j5LhTDVjHChccoc7brwLvpvLPiMyRlcO88C4l1QQ=="
>>>>>>> f89f534... fixup! New question ui
"node-gettext": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/node-gettext/-/node-gettext-2.1.0.tgz",
"integrity": "sha512-vsHImHl+Py0vB7M2UXcFEJ5NJ3950gcja45YclBFtYxYeZiqdfQdcu+G9s4L7jpRFSh/J/7VoS3upR4JM1nS+g==",
"requires": {
"lodash.get": "^4.4.2"
}
}
}
},
@ -2873,14 +2853,14 @@
},
"dependencies": {
"caniuse-lite": {
"version": "1.0.30001040",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001040.tgz",
"integrity": "sha512-Ep0tEPeI5wCvmJNrXjE3etgfI+lkl1fTDU6Y3ZH1mhrjkPlVI9W4pcKbMo+BQLpEWKVYYp2EmYaRsqpPC3k7lQ=="
"version": "1.0.30001042",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001042.tgz",
"integrity": "sha512-igMQ4dlqnf4tWv0xjaaE02op9AJ2oQzXKjWf4EuAHFN694Uo9/EfPVIPJcmn2WkU9RqozCxx5e2KPcVClHDbDw=="
},
"electron-to-chromium": {
"version": "1.3.403",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.403.tgz",
"integrity": "sha512-JaoxV4RzdBAZOnsF4dAlZ2ijJW72MbqO5lNfOBHUWiBQl3Rwe+mk2RCUMrRI3rSClLJ8HSNQNqcry12H+0ZjFw=="
"version": "1.3.412",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.412.tgz",
"integrity": "sha512-4bVdSeJScR8fT7ERveLWbxemY5uXEHVseqMRyORosiKcTUSGtVwBkV8uLjXCqoFLeImA57Z9hbz3TOid01U4Hw=="
}
}
},
@ -3475,15 +3455,9 @@
"integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40="
},
"core-js": {
<<<<<<< HEAD
"version": "3.6.5",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.5.tgz",
"integrity": "sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA=="
=======
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.6.4.tgz",
"integrity": "sha512-4paDGScNgZP2IXXilaffL9X7968RuvwlkK3xWtZRVqgd8SYNiVKRJvkFd1aqqEuPfN7E68ZHEp9hDj6lHj4Hyw=="
>>>>>>> f89f534... fixup! New question ui
},
"core-js-compat": {
"version": "3.6.5",
@ -3658,12 +3632,6 @@
"supports-color": "^6.1.0"
}
},
"postcss-value-parser": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.0.3.tgz",
"integrity": "sha512-N7h4pG+Nnu5BEIzyeaaIYWs0LI5XC40OrRh5L60z0QjFsqGWcHcbkBvpe1WYpcIS9yQ8sOi/vIPt1ejQCrMVrg==",
"dev": true
},
"schema-utils": {
"version": "2.6.5",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.6.5.tgz",
@ -7835,9 +7803,9 @@
"dev": true
},
"node-gettext": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/node-gettext/-/node-gettext-2.0.0.tgz",
"integrity": "sha1-8dwSN83FRvUVk9o0AwS4vrpbhSU=",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/node-gettext/-/node-gettext-3.0.0.tgz",
"integrity": "sha512-/VRYibXmVoN6tnSAY2JWhNRhWYJ8Cd844jrZU/DwLVoI4vBI6ceYbd8i42sYZ9uOgDH3S7vslIKOWV/ZrT2YBA==",
"requires": {
"lodash.get": "^4.4.2"
}
@ -8824,9 +8792,9 @@
}
},
"postcss-value-parser": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.0.2.tgz",
"integrity": "sha512-LmeoohTpp/K4UiyQCwuGWlONxXamGzCMtFxLq4W1nZVGIQLYvMCJx3yAF9qyyuFpflABI9yVdtJAqbihOsCsJQ==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.0.3.tgz",
"integrity": "sha512-N7h4pG+Nnu5BEIzyeaaIYWs0LI5XC40OrRh5L60z0QjFsqGWcHcbkBvpe1WYpcIS9yQ8sOi/vIPt1ejQCrMVrg==",
"dev": true
},
"prelude-ls": {

View file

@ -52,7 +52,7 @@
<!-- No errors show router content -->
<template v-else>
<router-view :form="selectedForm" />
<router-view :form.sync="selectedForm" />
<router-view :form="selectedForm" name="sidebar" />
</template>
</Content>
@ -105,9 +105,16 @@ export default {
return this.$route.params.hash
},
selectedForm() {
// TODO: replace with form.hash
return this.forms.find(form => form.form.hash === this.hash)
selectedForm: {
get() {
return this.forms.find(form => form.hash === this.hash)
},
set(form) {
const index = this.forms.findIndex(search => search.hash === this.hash)
if (index > -1) {
this.$set(this.forms, index, form)
}
},
},
},
@ -141,7 +148,7 @@ export default {
const response = await axios.post(generateUrl('/apps/forms/api/v1/form'))
const newForm = response.data
this.forms.push(newForm)
this.$router.push({ name: 'edit', params: { hash: newForm.form.hash } })
this.$router.push({ name: 'edit', params: { hash: newForm.hash } })
} catch (error) {
showError(t('forms', 'Unable to create a new form'))
console.error(error)

View file

@ -23,25 +23,43 @@
<template>
<li v-click-outside="disableEdit"
:class="{ 'question--edit': edit }"
:aria-label="t('forms', 'Question number {index}', {index})"
class="question"
@click="enableEdit">
<!-- Drag handle -->
<!-- TODO: implement arrow key mapping to reorder question -->
<div class="question__drag-handle icon-drag-handle"
:aria-label="t('forms', 'Drag to re-order the questions')" />
<input v-if="edit"
:value="title"
class="question__title"
type="text"
minlength="1"
maxlength="256"
@input="onInput">
<h3 v-else class="question__title" v-text="title" />
<!-- Header -->
<div class="question__header">
<input v-if="edit"
:placeholder="t('forms', 'Enter a title for this question')"
:aria-label="t('forms', 'The title of the question number {index}', {index})"
:value="title"
class="question__header-title"
type="text"
minlength="1"
maxlength="256"
required
@input="onInput">
<h3 v-else class="question__header-title" v-text="title" />
<Actions class="question__header-menu" :force-menu="true">
<ActionButton icon="icon-delete" @click="onDelete">
{{ t('forms', 'Delete question') }}
</ActionButton>
</Actions>
</div>
<!-- Question content -->
<slot />
</li>
</template>
<script>
import { directive as ClickOutside } from 'v-click-outside'
import Actions from '@nextcloud/vue/dist/Components/Actions'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
export default {
name: 'Question',
@ -50,7 +68,20 @@ export default {
ClickOutside,
},
components: {
Actions,
ActionButton,
},
props: {
id: {
type: Number,
required: true,
},
index: {
type: Number,
required: true,
},
title: {
type: String,
required: true,
@ -79,11 +110,18 @@ export default {
disableEdit() {
this.$emit('update:edit', false)
},
/**
* Delete this question
*/
onDelete() {
this.$emit('delete', this.id)
},
},
}
</script>
<style lang="scss">
<style lang="scss" scoped>
.question {
position: relative;
display: flex;
@ -92,6 +130,8 @@ export default {
justify-content: stretch;
margin-bottom: 22px;
padding-left: 44px;
// room for the new question menu
padding-right: 44px;
user-select: none;
background-color: var(--color-main-background);
@ -118,26 +158,41 @@ export default {
padding: 0;
}
// Using type to have a higher order than the input styling of server
&__title,
&__title[type=text] {
&__header {
display: flex;
align-items: center;
flex: 1 1 100%;
justify-content: space-between;
width: auto;
max-width: calc(100% - 44px);
min-height: 22px;
margin: 20px;
margin-bottom: 0;
padding: 0;
padding-bottom: 6px;
color: var(--color-text-light);
border: 0;
border-radius: 0;
font-size: 16px;
line-height: 22px;
}
&__title[type=text] {
border-bottom: 1px dotted var(--color-border-dark);
// Using type to have a higher order than the input styling of server
&-title,
&-title[type=text] {
flex: 1 1 100%;
min-height: 22px;
margin: 0;
padding: 0;
padding-bottom: 6px;
color: var(--color-text-light);
border: 0;
border-bottom: 1px dotted transparent;
border-radius: 0;
font-size: 16px;
line-height: 22px;
}
&-title[type=text] {
border-bottom-color: var(--color-border-dark);
}
&-menu.action-item {
position: sticky;
top: var(--header-height);
// above other actions
z-index: 50;
}
}
}

View file

@ -21,7 +21,11 @@
-->
<template>
<Question :title="title" :edit.sync="edit" @update:title="onTitleChange">
<Question
v-bind.sync="$attrs"
:title="title"
:edit.sync="edit"
@update:title="onTitleChange">
<div class="question__content">
<!-- TODO: properly choose max length -->
<textarea ref="textarea"

View file

@ -21,25 +21,38 @@
-->
<template>
<Question :title="title" :edit.sync="edit" @update:title="onTitleChange">
<ul class="question__content">
<template v-for="(answer, index) in values">
<Question
v-bind.sync="$attrs"
:title="title"
:edit.sync="edit"
@update:title="onTitleChange">
<ul class="question__content" :role="isUnique ? 'radiogroup' : ''">
<template v-for="(answer, index) in options">
<li :key="index" class="question__item">
<input :id="`${id}-check-${index}`"
<!-- Answer radio/checkbox + label -->
<!-- TODO: migrate to radio/checkbox component once ready -->
<input :id="`${id}-answer-${index}`"
ref="checkbox"
:checked="false"
:aria-checked="isChecked(index)"
:checked="isChecked(index)"
:class="{
'radio question__radio': isUnique,
'checkbox question__checkbox': !isUnique,
}"
:name="`${id}-answer`"
:readonly="true"
type="checkbox"
class="checkbox question__checkbox">
:type="isUnique ? 'radio' : 'checkbox'">
<label v-if="!edit"
ref="label"
:for="`${id}-check-${index}`"
:for="`${id}-answer-${index}`"
class="question__label">{{ answer }}</label>
<!-- Answer text input edit -->
<!-- TODO: properly choose max length -->
<input v-else
ref="input"
:aria-label="t('forms', 'An answer for checkbox {index}', { index: index + 1 })"
:placeholder="t('forms', 'Answer for checkbox {index}', { index: index + 1 })"
:aria-label="t('forms', 'An answer for the {index} option', { index: index + 1 })"
:placeholder="t('forms', 'Answer number {index}', { index: index + 1 })"
:value="answer"
class="question__input"
maxlength="256"
@ -60,8 +73,8 @@
<li v-if="edit && !isLastEmpty" class="question__item">
<!-- TODO: properly choose max length -->
<input
:aria-label="t('forms', 'Add a new checkbox')"
:placeholder="t('forms', 'Add a new checkbox')"
:aria-label="t('forms', 'Add a new answer')"
:placeholder="t('forms', 'Add a new answer')"
class="question__input"
maxlength="256"
minlength="1"
@ -79,6 +92,9 @@ import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import QuestionMixin from '../../mixins/QuestionMixin'
import GenRandomId from '../../utils/GenRandomId'
// Implementations docs
// https://www.w3.org/TR/2016/WD-wai-aria-practices-1.1-20160317/examples/radio/radio.html
// https://www.w3.org/TR/2016/WD-wai-aria-practices-1.1-20160317/examples/checkbox/checkbox-2.html
export default {
name: 'QuestionMultiple',
@ -100,6 +116,10 @@ export default {
const value = this.values[this.values.length - 1]
return value && value.trim().length === 0
},
isUnique() {
return this.model.unique === true
},
},
watch: {
@ -112,6 +132,25 @@ export default {
},
methods: {
/**
* Is the provided index checked
* @param {number} index the option index
* @returns {boolean}
*/
isChecked(index) {
// TODO implement based on answers
return false
},
/**
* Update the values
* @param {Array} values values to change
*/
updateValues(values) {
this.$emit('update:values', this.isUnique ? [values[0]] : values)
},
onInput(index) {
// Update values
const input = this.$refs.input[index]
@ -119,7 +158,7 @@ export default {
values[index] = input.value
// Update question
this.$emit('update:values', values)
this.updateValues(values)
},
addNewEntry() {
@ -128,7 +167,7 @@ export default {
values.push('')
// Update question
this.$emit('update:values', values)
this.updateValues(values)
this.$nextTick(() => {
this.focusIndex(values.length - 1)
@ -147,7 +186,7 @@ export default {
values.splice(index, 1)
// Update question
this.$emit('update:values', values)
this.updateValues(values)
this.$nextTick(() => {
this.focusNext(index)
@ -187,6 +226,11 @@ export default {
margin: 14px !important;
}
}
// make sure to respect readonly on radio/checkbox
input[readonly] {
pointer-events: none;
}
}
// Using type to have a higher order than the input styling of server

View file

@ -21,7 +21,11 @@
-->
<template>
<Question :title="title" :edit.sync="edit" @update:title="onTitleChange">
<Question
v-bind.sync="$attrs"
:title="title"
:edit.sync="edit"
@update:title="onTitleChange">
<div class="question__content">
<!-- TODO: properly choose max length -->
<input ref="input"

View file

@ -1,48 +0,0 @@
<!--
- @copyright Copyright (c) 2018 René Gieling <github@dartcafe.de>
-
- @author René Gieling <github@dartcafe.de>
-
- @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/>.
-
-->
<template>
<div class="close flex-row">
<a id="closeDetails"
:title="closeDetailLabel"
:alt="closeDetailLabelAlt"
class="close icon-close has-tooltip-bottom"
href="#"
@:click="hideSidebar" />
</div>
</template>
<script>
export default {
data() {
return {
closeDetailLabel: t('Close details'),
closeDetailLabelAlt: t('Close'),
}
},
methods: {
hideSidebar() {
OC.Apps.hideAppSidebar()
},
},
}
</script>

View file

@ -23,12 +23,37 @@ import Question from '../components/Questions/Question'
export default {
inheritAttrs: false,
props: {
/**
* The question title
*/
title: {
type: String,
required: true,
},
/**
* The user answers
*/
values: {
type: Array,
default() {
return []
},
},
/**
* The question list of answers
*/
options: {
type: Array,
required: true,
},
/**
* Answer type model object
*/
model: {
type: Object,
required: true,
},
},
@ -39,14 +64,26 @@ export default {
data() {
return {
// Do we display this question in edit or fill mode
edit: false,
}
},
methods: {
/**
* Forward the title change to the parent
*
* @param {string} title the title
*/
onTitleChange(title) {
this.$emit('update:title', title)
},
/**
* Forward the answer(s) change to the parent
*
* @param {Array} values the array of answers
*/
onValuesChange(values) {
this.$emit('update:values', values)
},

View file

@ -21,14 +21,9 @@
export default {
props: {
hash: {
type: String,
default: null,
},
form: {
type: Object,
// TODO: use default Form object ?
default: {},
required: true,
},
},
}

View file

@ -20,29 +20,42 @@
*
*/
export default [
{
label: t('forms', 'Multiple choice'),
value: 'radiogroup',
import QuestionLong from '../components/Questions/QuestionLong'
import QuestionShort from '../components/Questions/QuestionShort'
import QuestionMultiple from '../components/Questions/QuestionMultiple'
/**
* @typedef {Object} AnswerTypes
* @property {string} multiple_unique
* @property {string} multiple
* @property {string} short
* @property {string} long
*/
export default {
multiple_unique: {
component: QuestionMultiple,
icon: 'icon-answer-multiple',
label: t('forms', 'Multiple choice'),
unique: true,
},
{
label: t('forms', 'Checkboxes'),
value: 'checkbox',
multiple: {
component: QuestionMultiple,
icon: 'icon-answer-checkbox',
label: t('forms', 'Checkboxes'),
},
{
label: t('forms', 'Short answer'),
value: 'text',
short: {
component: QuestionShort,
icon: 'icon-answer-short',
label: t('forms', 'Short answer'),
},
{
label: t('forms', 'Long text'),
value: 'comment',
long: {
component: QuestionLong,
icon: 'icon-answer-long',
label: t('forms', 'Long text'),
},
// {
// label: 'Drop Down',
// value: 'dropdown',
// },
]
}

View file

@ -27,7 +27,7 @@
*/
const formatForm = function(form) {
// clone form
const newForm = Object.assign({}, form, form.form)
const newForm = Object.assign({}, form)
// cleanup
delete newForm.event

View file

@ -27,7 +27,13 @@
-->
<template>
<AppContent>
<AppContent v-if="loadingForm">
<EmptyContent icon="icon-loading">
{{ t('forms', 'Loading form “{title}”', { title: form.title }) }}
</EmptyContent>
</AppContent>
<AppContent v-else>
<!-- Show results & sidebar button -->
<TopBar>
<button class="primary" @click="showResults">
@ -46,7 +52,7 @@
<label class="hidden-visually" for="form-title">{{ t('forms', 'Title') }}</label>
<input
id="form-title"
v-model="form.form.title"
v-model="form.title"
:minlength="0"
:placeholder="t('forms', 'Title')"
:required="true"
@ -57,7 +63,7 @@
<textarea
id="form-desc"
ref="description"
v-model="form.form.description"
v-model="form.description"
:placeholder="t('forms', 'Description')"
@change="autoSizeDescription"
@keydown="autoSizeDescription" />
@ -70,40 +76,20 @@
v-tooltip="t('forms', 'Add a question to this form')"
:aria-label="t('forms', 'Add a question to this form')"
:open.sync="questionMenuOpened"
default-icon="icon-add-white">
<ActionButton v-for="type in answerTypes"
:key="type.label"
:default-icon="loadingQuestions ? 'icon-loading-small' : 'icon-add-white'">
<ActionButton v-for="(answer, type) in answerTypes"
:key="answer.label"
:disabled="loadingQuestions"
:icon="answer.icon"
class="question-toolbar__question"
:icon="type.icon"
@click="addQuestion">
{{ type.label }}
@click="addQuestion(type)">
{{ answer.label }}
</ActionButton>
</Actions>
</div>
<!-- <div id="quiz-form-selector-text">
<label for="ans-type">Answer Type: </label>
<select v-model="selected">
<option value="" disabled>
Select
</option>
<option v-for="type in questionTypes" :key="type.value" :value="type.value">
{{ type.text }}
</option>
</select>
<input
v-model="newQuestion"
:placeholder=" t('forms', 'Add Question') "
maxlength="2048"
@keyup.enter="addQuestion()">
<button id="questButton"
@click="addQuestion()">
{{ t('forms', 'Add Question') }}
</button>
</div> -->
<!-- No questions -->
<EmptyContent v-if="form.questions.length === 0">
<EmptyContent v-if="hasQuestions">
{{ t('forms', 'This form does not have any questions') }}
<template #desc>
<button class="empty-content__button primary" @click="openQuestionMenu">
@ -129,17 +115,21 @@
@deleteOption="deleteOption"
@deleteQuestion="deleteQuestion(question, index)" />
</transitionGroup> -->
<Draggable v-model="questions"
:animation="200"
tag="ul"
@start="dragging = true"
@end="dragging = false">
<Questions :is="question.type"
v-for="question in questions"
:key="question.id"
v-bind.sync="question" />
</Draggable>
<form @submit.prevent="onSubmit">
<Draggable v-model="questions"
:animation="200"
tag="ul"
@start="dragging = true"
@end="dragging = false">
<Questions :is="answerTypes[question.type].component"
v-for="(question, index) in questions"
:key="question.id"
:model="answerTypes[question.type]"
:index="index + 1"
v-bind.sync="question"
@delete="deleteQuestion" />
</Draggable>
</form>
</section>
</AppContent>
</template>
@ -166,6 +156,8 @@ import QuizFormItem from '../components/quizFormItem'
import TopBar from '../components/TopBar'
import ViewsMixin from '../mixins/ViewsMixin'
window.axios = axios
export default {
name: 'Create',
components: {
@ -187,36 +179,32 @@ export default {
data() {
return {
questionMenuOpened: false,
placeholder: '',
newOption: '',
newQuestion: '',
nextOptionId: 1,
nextQuestionId: 1,
writingForm: false,
loadingForm: true,
selected: '',
uniqueQuestionText: false,
uniqueOptionText: false,
allHaveOpt: false,
answerTypes,
loadingForm: true,
loadingQuestions: false,
errorForm: false,
questions: [
{
id: 1,
type: QuestionShort,
type: 'short',
title: 'How old are you ?',
values: ['I\'m 48 years old'],
},
{
id: 2,
type: QuestionLong,
type: 'long',
title: 'Your latest best memory ?',
values: ['One day I was at the beach.\nIt was fun. The sun was shinning.\nThe water was warm'],
},
{
id: 3,
type: QuestionMultiple,
type: 'multiple',
title: 'Choose an answer ?',
values: ['Answer 1', 'Answer 2', 'Answer 3', 'Answer 4'],
options: ['Answer 1', 'Answer 2', 'Answer 3', 'Answer 4'],
},
{
id: 4,
type: 'multiple_unique',
title: 'Choose an answer ?',
options: ['Answer 1', 'Answer 2', 'Answer 3', 'Answer 4'],
},
],
dragging: false,
@ -225,32 +213,18 @@ export default {
computed: {
title() {
if (this.form.form.title === '') {
if (this.form.title === '') {
return t('forms', 'Create new form')
} else {
return this.form.form.title
return this.form.title
}
},
saveButtonTitle() {
if (this.writingForm) {
return t('forms', 'Writing form')
} else if (this.form.mode === 'edit') {
return t('forms', 'Update form')
} else {
return t('forms', 'Done')
}
hasQuestions() {
return this.form.questions && this.form.questions.length === 0
},
},
watch: {
title() {
// only used when the title changes after page load
document.title = t('forms', 'Forms') + ' - ' + this.title
},
form: {
deep: true,
handler: function() {
@ -259,53 +233,68 @@ export default {
},
},
created() {
if (this.$route.name === 'edit') {
this.form.mode = 'edit'
} else if (this.$route.name === 'clone') {
// TODO: CLONE
}
beforeMount() {
this.fetchFullForm(this.form.id)
},
mounted() {
updated() {
this.autoSizeDescription()
},
methods: {
/**
* Fetch the full form data and update parent
*
* @param {number} id the unique form hash
*/
async fetchFullForm(id) {
this.loadingForm = true
console.debug('Loading form', id)
switchSidebar() {
this.sidebar = !this.sidebar
try {
const form = await axios.get(generateUrl('/apps/forms/api/v1/form/{id}', { id }))
this.$emit('update:form', form.data)
} catch (error) {
console.error(error)
this.errorForm = true
} finally {
this.loadingForm = false
}
},
checkQuestionText() {
this.uniqueQuestionText = true
this.form.questions.forEach(q => {
if (q.text === this.newQuestion) {
this.uniqueQuestionText = false
}
})
onSubmit() {
this.saveForm()
},
async addQuestion() {
this.checkQuestionText()
if (this.selected === '') {
showError(t('forms', 'Select a question type!'), { duration: 3000 })
} else if (!this.uniqueQuestionText) {
showError(t('forms', 'Cannot have the same question!'))
} else {
if (this.newQuestion !== null & this.newQuestion !== '' & (/\S/.test(this.newQuestion))) {
const response = await axios.post(generateUrl('/apps/forms/api/v1/question/'), { formId: this.form.id, type: this.selected, text: this.newQuestion })
const respData = response.data
/**
* Add a new question to the current form
*
* @param {string} type the question type, see AnswerTypes
*/
async addQuestion(type) {
const text = t('forms', 'New question')
this.loadingQuestions = true
this.form.questions.push({
id: respData.id,
order: respData.order,
text: this.newQuestion,
type: this.selected,
answers: [],
})
}
this.newQuizQuestion = ''
try {
const response = await axios.post(generateUrl('/apps/forms/api/v1/question'), {
formId: this.form.id,
type,
text,
})
const question = response.data
// Add newly created question
this.form.questions.push(Object.assign({
text,
type,
answers: [],
}, question))
} catch (error) {
console.error(error)
showError(t('forms', 'There was an error while adding the new question'))
} finally {
this.loadingQuestions = false
}
},
@ -315,31 +304,14 @@ export default {
this.form.questions.splice(index, 1)
},
checkOptionText(item, question) {
this.uniqueOptionText = true
question.options.forEach(o => {
if (o.text === item.newOption) {
this.uniqueOptionText = false
}
})
},
async addOption(item, question) {
this.checkOptionText(item, question)
if (!this.uniqueOptionText) {
showError(t('forms', 'Two options cannot be the same!'), { duration: 3000 })
} else {
if (item.newOption !== null & item.newOption !== '' & (/\S/.test(item.newOption))) {
const response = await axios.post(generateUrl('/apps/forms/api/v1/option/'), { formId: this.form.id, questionId: question.id, text: item.newOption })
const optionId = response.data
const response = await axios.post(generateUrl('/apps/forms/api/v1/option/'), { formId: this.form.id, questionId: question.id, text: item.newOption })
const optionId = response.data
question.options.push({
id: optionId,
text: item.newOption,
})
}
item.newOption = ''
}
question.options.push({
id: optionId,
text: item.newOption,
})
},
async deleteOption(question, option, index) {
@ -348,52 +320,25 @@ export default {
question.options.splice(index, 1)
},
checkAllHaveOpt() {
this.allHaveOpt = true
this.form.questions.forEach(q => {
if (q.type !== 'text' && q.type !== 'comment' && q.options.length === 0) {
this.allHaveOpt = false
}
})
},
autoSizeDescription() {
const textarea = this.$refs.description
textarea.style.cssText = 'height:auto; padding:0'
textarea.style.cssText = `height: ${textarea.scrollHeight + 20}px`
if (textarea) {
textarea.style.cssText = 'height:auto; padding:0'
textarea.style.cssText = `height: ${textarea.scrollHeight + 20}px`
}
},
debounceWriteForm: debounce(function() {
this.writeForm()
debounceSaveForm: debounce(function() {
this.saveForm()
}, 200),
writeForm() {
this.checkAllHaveOpt()
if (this.form.form.title.length === 0 | !(/\S/.test(this.form.form.title))) {
this.titleEmpty = true
showError(t('forms', 'Title must not be empty!'), { duration: 3000 })
} else if (!this.allHaveOpt) {
showError(t('forms', 'All questions need answers!'), { duration: 3000 })
} else if (this.form.form.expires & this.form.form.expirationDate === '') {
showError(t('forms', 'Need to pick an expiration date!'), { duration: 3000 })
} else {
this.writingForm = true
this.titleEmpty = false
axios.post(OC.generateUrl('apps/forms/write/form'), this.form)
.then((response) => {
this.form.mode = 'edit'
this.form.form.hash = response.data.hash
this.form.form.id = response.data.id
this.writingForm = false
showSuccess(t('forms', '%n successfully saved', 1, this.form.form.title), { duration: 3000 })
}, (error) => {
this.form.form.hash = ''
this.writingForm = false
showError(t('forms', 'Error on saving form, see console'))
/* eslint-disable-next-line no-console */
console.log(error.response)
})
async saveForm() {
try {
await axios.post(OC.generateUrl('apps/forms/write/form'), this.form)
showSuccess(t('forms', '%n successfully saved', 1, this.form.title), { duration: 3000 })
} catch (error) {
showError(t('forms', 'Error on saving form, see console'))
console.error(error)
}
},
@ -437,7 +382,7 @@ export default {
</script>
<style lang="scss">
#app-content {
.app-content {
display: flex;
align-items: center;
flex-direction: column;

View file

@ -21,14 +21,14 @@
-->
<template>
<AppSidebar v-show="opened" :title="form.form.title" @close="onClose">
<AppSidebar v-show="opened" :title="form.title" @close="onClose">
<div class="configBox ">
<label class="title icon-settings">
{{ t('forms', 'Form configurations') }}
</label>
<input id="isAnonymous"
v-model="form.form.isAnonymous"
v-model="form.isAnonymous"
type="checkbox"
class="checkbox">
@ -37,8 +37,8 @@
</label>
<input id="submitOnce"
v-model="form.form.submitOnce"
:disabled="form.form.access.type === 'public' || form.form.isAnonymous"
v-model="form.submitOnce"
:disabled="form.access.type === 'public' || form.isAnonymous"
type="checkbox"
class="checkbox">
<label for="submitOnce" class="title">
@ -46,7 +46,7 @@
</label>
<input id="expires"
v-model="form.form.expires"
v-model="form.expires"
type="checkbox"
class="checkbox">
@ -54,9 +54,9 @@
{{ t('forms', 'Expires') }}
</label>
<DatetimePicker v-show="form.form.expires"
<DatetimePicker v-show="form.expires"
id="expiresDatetimePicker"
v-model="form.form.expiresTimestamp"
v-model="form.expiresTimestamp"
v-bind="expirationDatePicker" />
</div>
@ -66,7 +66,7 @@
</label>
<input id="registered"
v-model="form.form.access.type"
v-model="form.access.type"
type="radio"
value="registered"
class="radio">
@ -76,7 +76,7 @@
</label>
<input id="public"
v-model="form.form.access.type"
v-model="form.access.type"
type="radio"
value="public"
class="radio">
@ -86,7 +86,7 @@
</label>
<input id="selected"
v-model="form.form.access.type"
v-model="form.access.type"
type="radio"
value="selected"
class="radio">
@ -96,7 +96,7 @@
</label>
</div>
<ShareDiv v-show="form.form.access.type === 'selected'"
<ShareDiv v-show="form.access.type === 'selected'"
:active-shares="form.shares"
:placeholder="t('forms', 'Name of user or group')"
:hide-names="true"