Skip to content

Commit d087b97

Browse files
susnuxChartman123
authored andcommitted
feat(frontend): Allow to reorder "multiple" and "dropdown" question type options
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de> Signed-off-by: Christian Hartmann <chris-hartmann@gmx.de>
1 parent e8b0347 commit d087b97

File tree

10 files changed

+464
-387
lines changed

10 files changed

+464
-387
lines changed

package-lock.json

Lines changed: 0 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343
"crypto-js": "^4.2.0",
4444
"debounce": "^2.2.0",
4545
"markdown-it": "^14.1.0",
46-
"p-debounce": "^4.0.0",
4746
"p-queue": "^8.0.1",
4847
"qrcode": "^1.5.4",
4948
"v-click-outside": "^3.2.0",

src/components/Questions/AnswerInput.vue

Lines changed: 137 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -11,42 +11,67 @@
1111
class="question__item__pseudoInput" />
1212
<input
1313
ref="input"
14-
:aria-label="t('forms', 'Answer number {index}', { index: index + 1 })"
15-
:placeholder="t('forms', 'Answer number {index}', { index: index + 1 })"
16-
:value="answer.text"
14+
v-model="localAnswer.text"
15+
:aria-label="ariaLabel"
16+
:placeholder="placeholder"
1717
class="question__input"
1818
:class="{ 'question__input--shifted': !isDropdown }"
1919
:maxlength="maxOptionLength"
20-
minlength="1"
2120
type="text"
2221
dir="auto"
2322
@input="onInput"
2423
@keydown.delete="deleteEntry"
2524
@keydown.enter.prevent="focusNextInput" />
2625

27-
<!-- Delete answer -->
28-
<NcActions>
29-
<NcActionButton @click="deleteEntry">
30-
<template #icon>
31-
<IconClose :size="20" />
26+
<!-- Actions for reordering and deleting the option -->
27+
<div class="option__actions">
28+
<template v-if="!answer.local">
29+
<template v-if="allowReorder">
30+
<NcButton
31+
ref="buttonUp"
32+
:aria-label="t('forms', 'Move option up')"
33+
:disabled="index === 0"
34+
type="tertiary"
35+
@click="onMoveUp">
36+
<template #icon>
37+
<IconArrowUp :size="20" />
38+
</template>
39+
</NcButton>
40+
<NcButton
41+
ref="buttonDown"
42+
:aria-label="t('forms', 'Move option down')"
43+
:disabled="index === maxIndex"
44+
type="tertiary"
45+
@click="onMoveDown">
46+
<template #icon>
47+
<IconArrowDown :size="20" />
48+
</template>
49+
</NcButton>
3250
</template>
33-
{{ t('forms', 'Delete answer') }}
34-
</NcActionButton>
35-
</NcActions>
51+
<NcButton
52+
type="tertiary"
53+
:aria-label="t('forms', 'Delete answer')"
54+
@click="deleteEntry">
55+
<template #icon>
56+
<IconDelete :size="20" />
57+
</template>
58+
</NcButton>
59+
</template>
60+
</div>
3661
</li>
3762
</template>
3863

3964
<script>
4065
import { showError } from '@nextcloud/dialogs'
4166
import { generateOcsUrl } from '@nextcloud/router'
4267
import axios from '@nextcloud/axios'
43-
import pDebounce from 'p-debounce'
44-
// eslint-disable-next-line import/no-unresolved, n/no-missing-import
68+
import debounce from 'debounce'
4569
import PQueue from 'p-queue'
4670
47-
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
48-
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
49-
import IconClose from 'vue-material-design-icons/Close.vue'
71+
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
72+
import IconArrowDown from 'vue-material-design-icons/ArrowDown.vue'
73+
import IconArrowUp from 'vue-material-design-icons/ArrowUp.vue'
74+
import IconDelete from 'vue-material-design-icons/Delete.vue'
5075
import IconCheckboxBlankOutline from 'vue-material-design-icons/CheckboxBlankOutline.vue'
5176
import IconRadioboxBlank from 'vue-material-design-icons/RadioboxBlank.vue'
5277
@@ -57,18 +82,23 @@ export default {
5782
name: 'AnswerInput',
5883
5984
components: {
60-
IconClose,
85+
IconArrowDown,
86+
IconArrowUp,
6187
IconCheckboxBlankOutline,
88+
IconDelete,
6289
IconRadioboxBlank,
63-
NcActions,
64-
NcActionButton,
90+
NcButton,
6591
},
6692
6793
props: {
6894
answer: {
6995
type: Object,
7096
required: true,
7197
},
98+
allowReorder: {
99+
type: Boolean,
100+
default: true,
101+
},
72102
index: {
73103
type: Number,
74104
required: true,
@@ -77,6 +107,10 @@ export default {
77107
type: Number,
78108
required: true,
79109
},
110+
maxIndex: {
111+
type: Number,
112+
required: true,
113+
},
80114
isUnique: {
81115
type: Boolean,
82116
required: true,
@@ -93,19 +127,43 @@ export default {
93127
94128
data() {
95129
return {
130+
localAnswer: this.answer,
96131
queue: new PQueue({ concurrency: 1 }),
97-
98-
// As data instead of Method, to have a separate debounce per AnswerInput
99-
debounceUpdateAnswer: pDebounce(function (answer) {
100-
return this.queue.add(() => this.updateAnswer(answer))
101-
}, 500),
102132
}
103133
},
104134
105135
computed: {
136+
ariaLabel() {
137+
if (this.local) {
138+
return t('forms', 'Add a new answer option')
139+
}
140+
return t('forms', 'The text of option {index}', {
141+
index: this.index + 1,
142+
})
143+
},
144+
145+
placeholder() {
146+
if (this.answer.local) {
147+
return t('forms', 'Add a new answer option')
148+
}
149+
return t('forms', 'Answer number {index}', { index: this.index + 1 })
150+
},
151+
106152
pseudoIcon() {
107153
return this.isUnique ? IconRadioboxBlank : IconCheckboxBlankOutline
108154
},
155+
156+
onInput() {
157+
return debounce(() => this.queue.add(this.handleInput), 150)
158+
},
159+
},
160+
161+
watch: {
162+
answer() {
163+
this.localAnswer = { ...this.answer }
164+
// If this component is recycled but was stopped previously (delete of option) - then we need to restart the queue
165+
this.queue.start()
166+
},
109167
},
110168
111169
methods: {
@@ -117,38 +175,32 @@ export default {
117175
* Focus the input
118176
*/
119177
focus() {
120-
this.$refs.input.focus()
178+
this.$refs.input?.focus()
121179
},
122180
123181
/**
124182
* Option changed, processing the data
125183
*/
126-
async onInput() {
127-
// clone answer
128-
const answer = Object.assign({}, this.answer)
129-
answer.text = this.$refs.input.value
130-
131-
if (this.answer.local) {
132-
// Dispatched for creation. Marked as synced
133-
// eslint-disable-next-line vue/no-mutating-props
134-
this.answer.local = false
135-
const newAnswer = await this.debounceCreateAnswer(answer)
136-
137-
// Forward changes, but use current answer.text to avoid erasing
138-
// any in-between changes while creating the answer
139-
newAnswer.text = this.$refs.input.value
140-
this.$emit('update:answer', answer.id, newAnswer)
184+
async handleInput() {
185+
let response
186+
if (this.localAnswer.local) {
187+
response = await this.createAnswer(this.localAnswer)
141188
} else {
142-
this.debounceUpdateAnswer(answer)
143-
this.$emit('update:answer', answer.id, answer)
189+
response = await this.updateAnswer(this.localAnswer)
144190
}
191+
192+
// Forward changes, but use current answer.text to avoid erasing any in-between changes
193+
this.localAnswer = { ...response, text: this.localAnswer.text }
194+
this.$emit('update:answer', this.localAnswer)
145195
},
146196
147197
/**
148198
* Request a new answer
149199
*/
150200
focusNextInput() {
151-
this.$emit('focus-next', this.index)
201+
if (this.index <= this.maxIndex) {
202+
this.$emit('focus-next', this.index)
203+
}
152204
},
153205
154206
/**
@@ -158,14 +210,24 @@ export default {
158210
* @param {Event} e the event
159211
*/
160212
async deleteEntry(e) {
213+
if (this.answer.local) {
214+
return
215+
}
216+
161217
if (e.type !== 'click' && this.$refs.input.value.length !== 0) {
162218
return
163219
}
164220
165221
// Dismiss delete key action
166222
e.preventDefault()
167223
168-
this.$emit('delete', this.answer.id)
224+
// do this in queue to prevent race conditions between PATCH and DELETE
225+
this.queue.add(() => {
226+
this.$emit('delete', this.answer.id)
227+
// Prevent any patch requests
228+
this.queue.pause()
229+
this.queue.clear()
230+
})
169231
},
170232
171233
/**
@@ -182,6 +244,7 @@ export default {
182244
{
183245
id: this.formId,
184246
questionId: answer.questionId,
247+
order: answer.order ?? this.maxIndex,
185248
},
186249
),
187250
{
@@ -192,17 +255,14 @@ export default {
192255
193256
// Was synced once, this is now up to date with the server
194257
delete answer.local
195-
return Object.assign({}, answer, OcsResponse2Data(response)[0])
258+
return { ...answer, ...OcsResponse2Data(response) }
196259
} catch (error) {
197260
logger.error('Error while saving answer', { answer, error })
198261
showError(t('forms', 'Error while saving the answer'))
199262
}
200263
201264
return answer
202265
},
203-
debounceCreateAnswer: pDebounce(function (answer) {
204-
return this.queue.add(() => this.createAnswer(answer))
205-
}, 100),
206266
207267
/**
208268
* Save to the server, only do it after 500ms
@@ -232,6 +292,27 @@ export default {
232292
logger.error('Error while saving answer', { answer, error })
233293
showError(t('forms', 'Error while saving the answer'))
234294
}
295+
return answer
296+
},
297+
298+
/**
299+
* Reorder option but keep focus on the button
300+
*/
301+
onMoveDown() {
302+
this.$emit('move-down')
303+
if (this.index < this.maxIndex - 1) {
304+
this.$nextTick(() => this.$refs.buttonDown.$el.focus())
305+
} else {
306+
this.$nextTick(() => this.$refs.buttonUp.$el.focus())
307+
}
308+
},
309+
onMoveUp() {
310+
this.$emit('move-up')
311+
if (this.index > 1) {
312+
this.$nextTick(() => this.$refs.buttonUp.$el.focus())
313+
} else {
314+
this.$nextTick(() => this.$refs.buttonDown.$el.focus())
315+
}
235316
},
236317
},
237318
}
@@ -242,13 +323,20 @@ export default {
242323
position: relative;
243324
display: inline-flex;
244325
min-height: var(--default-clickable-area);
326+
width: 100%;
245327
246328
&__pseudoInput {
247329
color: var(--color-primary-element);
248330
margin-inline-start: -2px;
249331
z-index: 1;
250332
}
251333
334+
.option__actions {
335+
display: flex;
336+
// make sure even the "add new" option is aligned correctly
337+
min-width: 44px;
338+
}
339+
252340
.question__input {
253341
width: 100%;
254342
position: relative;

0 commit comments

Comments
 (0)