From 18901add382a967b52f18685508cdd9d060d6928 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Mon, 22 Apr 2024 12:32:52 +0200 Subject: [PATCH] feat(frontend): Allow to reorder "multiple" and "dropdown" question type options Signed-off-by: Ferdinand Thiessen --- package-lock.json | 9 - package.json | 1 - src/components/Questions/AnswerInput.vue | 178 +++++++++---- src/components/Questions/QuestionDropdown.vue | 197 ++------------ src/components/Questions/QuestionMultiple.vue | 247 +++--------------- src/components/TransitionList.vue | 25 ++ src/mixins/QuestionMixin.js | 9 +- src/mixins/QuestionMultipleMixin.ts | 234 +++++++++++++++++ src/models/Entities.d.ts | 6 + tsconfig.json | 1 + 10 files changed, 448 insertions(+), 459 deletions(-) create mode 100644 src/components/TransitionList.vue create mode 100644 src/mixins/QuestionMultipleMixin.ts create mode 100644 src/models/Entities.d.ts diff --git a/package-lock.json b/package-lock.json index ad0af2c33..004803828 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,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", @@ -14478,14 +14477,6 @@ "integrity": "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==", "dev": true }, - "node_modules/p-debounce": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-debounce/-/p-debounce-4.0.0.tgz", - "integrity": "sha512-4Ispi9I9qYGO4lueiLDhe4q4iK5ERK8reLsuzH6BPaXn53EGaua8H66PXIFGrW897hwjXp+pVLrm/DLxN0RF0A==", - "engines": { - "node": ">=12" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", diff --git a/package.json b/package.json index d0d2a2441..9aaccb469 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Questions/AnswerInput.vue b/src/components/Questions/AnswerInput.vue index 7b3590ddb..cef081bff 100644 --- a/src/components/Questions/AnswerInput.vue +++ b/src/components/Questions/AnswerInput.vue @@ -4,28 +4,50 @@ v-if="!isDropdown" class="question__item__pseudoInput" /> - - - - @@ -33,12 +55,12 @@ 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' @@ -50,11 +72,12 @@ export default { name: 'AnswerInput', components: { + IconArrowDown, + IconArrowUp, IconClose, IconCheckboxBlankOutline, IconRadioboxBlank, - NcActions, - NcActionButton, + NcButton, }, props: { @@ -62,10 +85,18 @@ export default { type: Object, required: true, }, + allowReorder: { + type: Boolean, + default: true, + }, index: { type: Number, required: true, }, + maxIndex: { + type: Number, + required: true, + }, isUnique: { type: Boolean, required: true, @@ -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: { @@ -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) + } }, /** @@ -148,6 +194,10 @@ 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 } @@ -155,7 +205,13 @@ export default { // 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() + }) }, /** @@ -168,13 +224,14 @@ 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')) @@ -182,9 +239,6 @@ export default { return answer }, - debounceCreateAnswer: pDebounce(function(answer) { - return this.queue.add(() => this.createAnswer(answer)) - }, 100), /** * Save to the server, only do it after 500ms @@ -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()) + } }, }, } @@ -215,6 +290,7 @@ export default { position: relative; display: inline-flex; min-height: 44px; + width: 100%; &__pseudoInput { color: var(--color-primary-element); @@ -222,6 +298,12 @@ export default { 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; diff --git a/src/components/Questions/QuestionDropdown.vue b/src/components/Questions/QuestionDropdown.vue index 1666fad16..50f04ec75 100644 --- a/src/components/Questions/QuestionDropdown.vue +++ b/src/components/Questions/QuestionDropdown.vue @@ -44,48 +44,36 @@ label="text" @input="onInput" /> -
    + - - -
  1. - -
  2. -
+ @move-up="onOptionMoveUp(index)" + @move-down="onOptionMoveDown(index)" + @tabbed-out="checkValidOption" + @update:answer="updateAnswer(index, $event)" /> + diff --git a/src/components/Questions/QuestionMultiple.vue b/src/components/Questions/QuestionMultiple.vue index e5b0711d9..fc9ad2dba 100644 --- a/src/components/Questions/QuestionMultiple.vue +++ b/src/components/Questions/QuestionMultiple.vue @@ -69,51 +69,40 @@ - + + + + +
  • +
    + +
  • +