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]>
Signed-off-by: Christian Hartmann <[email protected]>
  • Loading branch information
susnux authored and Chartman123 committed Jan 25, 2025
1 parent e8b0347 commit dc5f225
Show file tree
Hide file tree
Showing 10 changed files with 473 additions and 384 deletions.
10 changes: 0 additions & 10 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 @@ -43,7 +43,6 @@
"crypto-js": "^4.2.0",
"debounce": "^2.2.0",
"markdown-it": "^14.1.0",
"p-debounce": "^4.0.0",
"p-queue": "^8.0.1",
"qrcode": "^1.5.4",
"v-click-outside": "^3.2.0",
Expand Down
186 changes: 137 additions & 49 deletions src/components/Questions/AnswerInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,42 +11,67 @@
class="question__item__pseudoInput" />
<input
ref="input"
:aria-label="t('forms', 'Answer number {index}', { 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>
<IconDelete :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 IconClose from 'vue-material-design-icons/Close.vue'
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 IconDelete from 'vue-material-design-icons/Delete.vue'
import IconCheckboxBlankOutline from 'vue-material-design-icons/CheckboxBlankOutline.vue'
import IconRadioboxBlank from 'vue-material-design-icons/RadioboxBlank.vue'

Expand All @@ -57,18 +82,23 @@ export default {
name: 'AnswerInput',

components: {
IconClose,
IconArrowDown,
IconArrowUp,
IconCheckboxBlankOutline,
IconDelete,
IconRadioboxBlank,
NcActions,
NcActionButton,
NcButton,
},

props: {
answer: {
type: Object,
required: true,
},
allowReorder: {
type: Boolean,
default: true,
},
index: {
type: Number,
required: true,
Expand All @@ -77,6 +107,10 @@ export default {
type: Number,
required: true,
},
maxIndex: {
type: Number,
required: true,
},
isUnique: {
type: Boolean,
required: true,
Expand All @@ -93,19 +127,43 @@ 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 option')
}
return t('forms', 'The text of option {index}', {
index: this.index + 1,
})
},

placeholder() {
if (this.answer.local) {
return t('forms', 'Add a new answer option')
}
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 @@ -117,38 +175,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 @@ -158,14 +210,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 @@ -182,6 +244,7 @@ export default {
{
id: this.formId,
questionId: answer.questionId,
order: answer.order ?? this.maxIndex,
},
),
{
Expand All @@ -192,17 +255,14 @@ export default {

// Was synced once, this is now up to date with the server
delete answer.local
return Object.assign({}, answer, OcsResponse2Data(response)[0])
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 Down Expand Up @@ -232,6 +292,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 @@ -242,13 +323,20 @@ export default {
position: relative;
display: inline-flex;
min-height: var(--default-clickable-area);
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 dc5f225

Please sign in to comment.