Skip to content

Commit

Permalink
feat(frontend): Allow to reorder "multiple" and "dropdown" question t…
Browse files Browse the repository at this point in the history
…ype options

Signed-off-by: Ferdinand Thiessen <[email protected]>
  • Loading branch information
susnux committed Apr 22, 2024
1 parent 17ecc62 commit ef615de
Show file tree
Hide file tree
Showing 10 changed files with 448 additions and 459 deletions.
9 changes: 0 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
"crypto-js": "^4.2.0",
"debounce": "^2.0.0",
"markdown-it": "^14.1.0",
"p-debounce": "^4.0.0",
"p-queue": "^8.0.1",
"v-click-outside": "^3.2.0",
"vue": "^2.7.16",
Expand Down
178 changes: 130 additions & 48 deletions src/components/Questions/AnswerInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,63 @@
v-if="!isDropdown"
class="question__item__pseudoInput" />
<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"
v-model="localAnswer.text"
:aria-label="ariaLabel"
:placeholder="placeholder"
class="question__input"
:class="{ 'question__input--shifted' : !isDropdown }"
:maxlength="maxOptionLength"
minlength="1"
type="text"
dir="auto"
@input="onInput"
@keydown.delete="deleteEntry"
@keydown.enter.prevent="focusNextInput">

<!-- Delete answer -->
<NcActions>
<NcActionButton @click="deleteEntry">
<template #icon>
<IconClose :size="20" />
<!-- Actions for reordering and deleting the option -->
<div class="option__actions">
<template v-if="!answer.local">
<template v-if="allowReorder">
<NcButton ref="buttonUp"
:aria-label="t('forms', 'Move option up')"
:disabled="index === 0"
type="tertiary"
@click="onMoveUp">
<template #icon>
<IconArrowUp :size="20" />
</template>
</NcButton>
<NcButton ref="buttonDown"
:aria-label="t('forms', 'Move option down')"
:disabled="index === maxIndex"
type="tertiary"
@click="onMoveDown">
<template #icon>
<IconArrowDown :size="20" />
</template>
</NcButton>
</template>
{{ t('forms', 'Delete answer') }}
</NcActionButton>
</NcActions>
<NcButton type="tertiary"
:aria-label="t('forms', 'Delete answer')"
@click="deleteEntry">
<template #icon>
<IconClose :size="20" />
</template>
</NcButton>
</template>
</div>
</li>
</template>

<script>
import { showError } from '@nextcloud/dialogs'
import { generateOcsUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import pDebounce from 'p-debounce'
// eslint-disable-next-line import/no-unresolved, n/no-missing-import
import debounce from 'debounce'
import PQueue from 'p-queue'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import IconArrowDown from 'vue-material-design-icons/ArrowDown.vue'
import IconArrowUp from 'vue-material-design-icons/ArrowUp.vue'
import IconClose from 'vue-material-design-icons/Close.vue'
import IconCheckboxBlankOutline from 'vue-material-design-icons/CheckboxBlankOutline.vue'
import IconRadioboxBlank from 'vue-material-design-icons/RadioboxBlank.vue'
Expand All @@ -50,22 +72,31 @@ export default {
name: 'AnswerInput',
components: {
IconArrowDown,
IconArrowUp,
IconClose,
IconCheckboxBlankOutline,
IconRadioboxBlank,
NcActions,
NcActionButton,
NcButton,
},
props: {
answer: {
type: Object,
required: true,
},
allowReorder: {
type: Boolean,
default: true,
},
index: {
type: Number,
required: true,
},
maxIndex: {
type: Number,
required: true,
},
isUnique: {
type: Boolean,
required: true,
Expand All @@ -82,19 +113,41 @@ export default {
data() {
return {
localAnswer: this.answer,
queue: new PQueue({ concurrency: 1 }),
// As data instead of Method, to have a separate debounce per AnswerInput
debounceUpdateAnswer: pDebounce(function(answer) {
return this.queue.add(() => this.updateAnswer(answer))
}, 500),
}
},
computed: {
ariaLabel() {
if (this.local) {
return t('forms', 'Add a new answer')
}
return t('forms', 'An answer for the {index} option', { index: this.index + 1 })
},
placeholder() {
if (this.answer.local) {
return t('forms', 'Add a new answer')
}
return t('forms', 'Answer number {index}', { index: this.index + 1 })
},
pseudoIcon() {
return this.isUnique ? IconRadioboxBlank : IconCheckboxBlankOutline
},
onInput() {
return debounce(() => this.queue.add(this.handleInput), 150)
},
},
watch: {
answer() {
this.localAnswer = { ...this.answer }
// If this component is recycled but was stopped previously (delete of option) - then we need to restart the queue
this.queue.start()
},
},
methods: {
Expand All @@ -106,39 +159,32 @@ export default {
* Focus the input
*/
focus() {
this.$refs.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
// eslint-disable-next-line vue/no-mutating-props
this.answer.local = false
const newAnswer = await this.debounceCreateAnswer(answer)
// Forward changes, but use current answer.text to avoid erasing
// any in-between changes while creating the answer
newAnswer.text = this.$refs.input.value
this.$emit('update:answer', answer.id, newAnswer)
async handleInput() {
let response
if (this.localAnswer.local) {
response = await this.createAnswer(this.localAnswer)
} else {
this.debounceUpdateAnswer(answer)
this.$emit('update:answer', answer.id, answer)
response = await this.updateAnswer(this.localAnswer)
}
// Forward changes, but use current answer.text to avoid erasing any in-between changes
this.localAnswer = { ...response, text: this.localAnswer.text }
this.$emit('update:answer', this.localAnswer)
},
/**
* Request a new answer
*/
focusNextInput() {
this.$emit('focus-next', this.index)
if (this.index <= this.maxIndex) {
this.$emit('focus-next', this.index)
}
},
/**
Expand All @@ -148,14 +194,24 @@ export default {
* @param {Event} e the event
*/
async deleteEntry(e) {
if (this.answer.local) {
return
}
if (e.type !== 'click' && this.$refs.input.value.length !== 0) {
return
}
// Dismiss delete key action
e.preventDefault()
this.$emit('delete', this.answer.id)
// do this in queue to prevent race conditions between PATCH and DELETE
this.queue.add(() => {
this.$emit('delete', this.answer.id)
// Prevent any patch requests
this.queue.pause()
this.queue.clear()
})
},
/**
Expand All @@ -168,23 +224,21 @@ export default {
try {
const response = await axios.post(generateOcsUrl('apps/forms/api/v2.4/option'), {
questionId: answer.questionId,
order: answer.order ?? this.maxIndex,
text: answer.text,
})
logger.debug('Created answer', { answer })
// Was synced once, this is now up to date with the server
delete answer.local
return Object.assign({}, answer, OcsResponse2Data(response))
return { ...answer, ...OcsResponse2Data(response) }
} catch (error) {
logger.error('Error while saving answer', { answer, error })
showError(t('forms', 'Error while saving the answer'))
}
return answer
},
debounceCreateAnswer: pDebounce(function(answer) {
return this.queue.add(() => this.createAnswer(answer))
}, 100),
/**
* Save to the server, only do it after 500ms
Expand All @@ -205,6 +259,27 @@ export default {
logger.error('Error while saving answer', { answer, error })
showError(t('forms', 'Error while saving the answer'))
}
return answer
},
/**
* Reorder option but keep focus on the button
*/
onMoveDown() {
this.$emit('move-down')
if (this.index < this.maxIndex - 1) {
this.$nextTick(() => this.$refs.buttonDown.$el.focus())
} else {
this.$nextTick(() => this.$refs.buttonUp.$el.focus())
}
},
onMoveUp() {
this.$emit('move-up')
if (this.index > 1) {
this.$nextTick(() => this.$refs.buttonUp.$el.focus())
} else {
this.$nextTick(() => this.$refs.buttonDown.$el.focus())
}
},
},
}
Expand All @@ -215,13 +290,20 @@ export default {
position: relative;
display: inline-flex;
min-height: 44px;
width: 100%;
&__pseudoInput {
color: var(--color-primary-element);
margin-inline-start: -2px;
z-index: 1;
}
.option__actions {
display: flex;
// make sure even the "add new" option is aligned correctly
min-width: 44px;
}
.question__input {
width: 100%;
position: relative;
Expand Down
Loading

0 comments on commit ef615de

Please sign in to comment.