Skip to content

Commit 5ab818c

Browse files
authored
Merge pull request #2092 from nextcloud/feat/reorder-options
feat: Allow to reorder options of "checkbox" "radio" and "dropdown" question types in frontend
2 parents be0ac4a + 450c19a commit 5ab818c

File tree

12 files changed

+568
-555
lines changed

12 files changed

+568
-555
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",

playwright/support/sections/QuestionSection.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ export class QuestionSection {
1919
name: /title of/i,
2020
})
2121
this.newAnswerInput = this.section.getByRole('textbox', {
22-
name: 'Add a new answer',
22+
name: 'Add a new answer option',
2323
})
2424
this.answerInputs = this.section.getByRole('textbox', {
25-
name: /Answer number \d+/i,
25+
name: /The text of option \d+/i,
2626
})
2727
}
2828

src/components/Questions/AnswerInput.vue

Lines changed: 176 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -11,42 +11,73 @@
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 })"
14+
:aria-label="ariaLabel"
15+
:placeholder="placeholder"
1616
:value="answer.text"
1717
class="question__input"
1818
:class="{ 'question__input--shifted': !isDropdown }"
1919
:maxlength="maxOptionLength"
20-
minlength="1"
2120
type="text"
2221
dir="auto"
2322
@input="debounceOnInput"
2423
@keydown.delete="deleteEntry"
25-
@keydown.enter.prevent="focusNextInput" />
26-
27-
<!-- Delete answer -->
28-
<NcActions>
29-
<NcActionButton @click="deleteEntry">
30-
<template #icon>
31-
<IconClose :size="20" />
32-
</template>
33-
{{ t('forms', 'Delete answer') }}
34-
</NcActionButton>
35-
</NcActions>
24+
@keydown.enter.prevent="focusNextInput"
25+
@compositionstart="onCompositionEnd"
26+
@compositionend="onCompositionEnd" />
27+
28+
<!-- Actions for reordering and deleting the option -->
29+
<div class="option__actions">
30+
<template v-if="!answer.local">
31+
<NcButton
32+
ref="buttonUp"
33+
class="option__actions-button"
34+
:aria-label="t('forms', 'Move option up')"
35+
:disabled="index === 0"
36+
size="small"
37+
type="tertiary"
38+
@click="onMoveUp">
39+
<template #icon>
40+
<IconArrowUp :size="20" />
41+
</template>
42+
</NcButton>
43+
<NcButton
44+
ref="buttonDown"
45+
class="option__actions-button"
46+
:aria-label="t('forms', 'Move option down')"
47+
:disabled="index === maxIndex"
48+
size="small"
49+
type="tertiary"
50+
@click="onMoveDown">
51+
<template #icon>
52+
<IconArrowDown :size="20" />
53+
</template>
54+
</NcButton>
55+
<NcButton
56+
class="option__actions-button"
57+
:aria-label="t('forms', 'Delete answer')"
58+
size="small"
59+
type="tertiary"
60+
@click="deleteEntry">
61+
<template #icon>
62+
<IconDelete :size="20" />
63+
</template>
64+
</NcButton>
65+
</template>
66+
</div>
3667
</li>
3768
</template>
3869

3970
<script>
4071
import { showError } from '@nextcloud/dialogs'
4172
import { generateOcsUrl } from '@nextcloud/router'
4273
import axios from '@nextcloud/axios'
43-
import pDebounce from 'p-debounce'
44-
// eslint-disable-next-line import/no-unresolved, n/no-missing-import
74+
import debounce from 'debounce'
4575
import PQueue from 'p-queue'
4676
47-
import NcActions from '@nextcloud/vue/components/NcActions'
48-
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
49-
import IconClose from 'vue-material-design-icons/Close.vue'
77+
import NcButton from '@nextcloud/vue/components/NcButton'
78+
import IconArrowDown from 'vue-material-design-icons/ArrowDown.vue'
79+
import IconArrowUp from 'vue-material-design-icons/ArrowUp.vue'
80+
import IconDelete from 'vue-material-design-icons/Delete.vue'
5081
import IconCheckboxBlankOutline from 'vue-material-design-icons/CheckboxBlankOutline.vue'
5182
import IconRadioboxBlank from 'vue-material-design-icons/RadioboxBlank.vue'
5283
@@ -57,11 +88,12 @@ export default {
5788
name: 'AnswerInput',
5889
5990
components: {
60-
IconClose,
91+
IconArrowDown,
92+
IconArrowUp,
6193
IconCheckboxBlankOutline,
94+
IconDelete,
6295
IconRadioboxBlank,
63-
NcActions,
64-
NcActionButton,
96+
NcButton,
6597
},
6698
6799
props: {
@@ -83,6 +115,10 @@ export default {
83115
},
84116
isDropdown: {
85117
type: Boolean,
118+
default: false,
119+
},
120+
maxIndex: {
121+
type: Number,
86122
required: true,
87123
},
88124
maxOptionLength: {
@@ -95,10 +131,27 @@ export default {
95131
return {
96132
queue: null,
97133
debounceOnInput: null,
134+
isIMEComposing: false,
98135
}
99136
},
100137
101138
computed: {
139+
ariaLabel() {
140+
if (this.answer.local) {
141+
return t('forms', 'Add a new answer option')
142+
}
143+
return t('forms', 'The text of option {index}', {
144+
index: this.index + 1,
145+
})
146+
},
147+
148+
placeholder() {
149+
if (this.answer.local) {
150+
return t('forms', 'Add a new answer option')
151+
}
152+
return t('forms', 'Answer number {index}', { index: this.index + 1 })
153+
},
154+
102155
pseudoIcon() {
103156
return this.isUnique ? IconRadioboxBlank : IconCheckboxBlankOutline
104157
},
@@ -108,8 +161,8 @@ export default {
108161
this.queue = new PQueue({ concurrency: 1 })
109162
110163
// As data instead of method, to have a separate debounce per AnswerInput
111-
this.debounceOnInput = pDebounce(() => {
112-
return this.queue.add(() => this.onInput())
164+
this.debounceOnInput = debounce((event) => {
165+
return this.queue.add(() => this.onInput(event))
113166
}, 500)
114167
},
115168
@@ -122,37 +175,44 @@ export default {
122175
* Focus the input
123176
*/
124177
focus() {
125-
this.$refs.input.focus()
178+
this.$refs.input?.focus()
126179
},
127180
128181
/**
129182
* Option changed, processing the data
183+
*
184+
* @param {InputEvent} event The input event that triggered adding a new entry
130185
*/
131-
async onInput() {
132-
// clone answer
133-
const answer = Object.assign({}, this.answer)
134-
answer.text = this.$refs.input.value
186+
async onInput({ target, isComposing }) {
187+
if (!isComposing && !this.isIMEComposing && target.value !== '') {
188+
// clone answer
189+
const answer = Object.assign({}, this.answer)
190+
answer.text = this.$refs.input.value
135191
136-
if (this.answer.local) {
137-
// Dispatched for creation. Marked as synced
138-
this.$set(this.answer, 'local', false)
139-
const newAnswer = await this.createAnswer(answer)
140-
141-
// Forward changes, but use current answer.text to avoid erasing
142-
// any in-between changes while creating the answer
143-
newAnswer.text = this.$refs.input.value
144-
this.$emit('update:answer', answer.id, newAnswer)
145-
} else {
146-
await this.updateAnswer(answer)
147-
this.$emit('update:answer', answer.id, answer)
192+
if (this.answer.local) {
193+
// Dispatched for creation. Marked as synced
194+
this.$set(this.answer, 'local', false)
195+
const newAnswer = await this.createAnswer(answer)
196+
197+
// Forward changes, but use current answer.text to avoid erasing
198+
// any in-between changes while creating the answer
199+
newAnswer.text = this.$refs.input.value
200+
201+
this.$emit('create-answer', this.index, newAnswer)
202+
} else {
203+
await this.updateAnswer(answer)
204+
this.$emit('update:answer', this.index, answer)
205+
}
148206
}
149207
},
150208
151209
/**
152210
* Request a new answer
153211
*/
154212
focusNextInput() {
155-
this.$emit('focus-next', this.index)
213+
if (this.index <= this.maxIndex) {
214+
this.$emit('focus-next', this.index)
215+
}
156216
},
157217
158218
/**
@@ -162,14 +222,24 @@ export default {
162222
* @param {Event} e the event
163223
*/
164224
async deleteEntry(e) {
225+
if (this.answer.local) {
226+
return
227+
}
228+
165229
if (e.type !== 'click' && this.$refs.input.value.length !== 0) {
166230
return
167231
}
168232
169233
// Dismiss delete key action
170234
e.preventDefault()
171235
172-
this.$emit('delete', this.answer.id)
236+
// do this in queue to prevent race conditions between PATCH and DELETE
237+
this.queue.add(() => {
238+
this.$emit('delete', this.answer.id)
239+
// Prevent any patch requests
240+
this.queue.pause()
241+
this.queue.clear()
242+
})
173243
},
174244
175245
/**
@@ -196,7 +266,7 @@ export default {
196266
197267
// Was synced once, this is now up to date with the server
198268
delete answer.local
199-
return Object.assign({}, answer, OcsResponse2Data(response)[0])
269+
return OcsResponse2Data(response)[0]
200270
} catch (error) {
201271
logger.error('Error while saving answer', { answer, error })
202272
showError(t('forms', 'Error while saving the answer'))
@@ -233,6 +303,45 @@ export default {
233303
logger.error('Error while saving answer', { answer, error })
234304
showError(t('forms', 'Error while saving the answer'))
235305
}
306+
return answer
307+
},
308+
309+
/**
310+
* Reorder option but keep focus on the button
311+
*/
312+
onMoveDown() {
313+
this.$emit('move-down')
314+
if (this.index < this.maxIndex - 1) {
315+
this.$nextTick(() => this.$refs.buttonDown.$el.focus())
316+
} else {
317+
this.$nextTick(() => this.$refs.buttonUp.$el.focus())
318+
}
319+
},
320+
onMoveUp() {
321+
this.$emit('move-up')
322+
if (this.index > 1) {
323+
this.$nextTick(() => this.$refs.buttonUp.$el.focus())
324+
} else {
325+
this.$nextTick(() => this.$refs.buttonDown.$el.focus())
326+
}
327+
},
328+
329+
/**
330+
* Handle composition start event for IME inputs
331+
*/
332+
onCompositionStart() {
333+
this.isIMEComposing = true
334+
},
335+
336+
/**
337+
* Handle composition end event for IME inputs
338+
* @param {CompositionEvent} event The input event that triggered adding a new entry
339+
*/
340+
onCompositionEnd({ target, isComposing }) {
341+
this.isIMEComposing = false
342+
if (!isComposing) {
343+
this.onInput({ target, isComposing })
344+
}
236345
},
237346
},
238347
}
@@ -243,24 +352,42 @@ export default {
243352
position: relative;
244353
display: inline-flex;
245354
min-height: var(--default-clickable-area);
355+
width: 100%;
246356
247357
&__pseudoInput {
248358
color: var(--color-primary-element);
249-
margin-inline-start: -2px;
359+
margin-inline-start: calc(-1 * var(--default-grid-baseline));
250360
z-index: 1;
251361
}
252362
363+
.option__actions {
364+
display: flex;
365+
position: absolute;
366+
gap: var(--default-grid-baseline);
367+
inset-inline-end: 16px;
368+
height: var(--default-clickable-area);
369+
}
370+
371+
.option__actions-button {
372+
margin-block: auto;
373+
374+
&:last-of-type {
375+
margin-inline: 5px;
376+
}
377+
}
378+
253379
.question__input {
254380
width: calc(100% - var(--default-clickable-area));
255381
position: relative;
256382
inset-inline-start: -12px;
383+
margin-block: 0 !important;
257384
margin-inline-end: -12px !important;
258385
259386
&--shifted {
260-
inset-inline-start: -34px;
261-
inset-block-start: 1px;
262-
margin-inline-end: -34px !important;
263-
padding-inline-start: 36px !important;
387+
inset-inline-start: calc(-1 * var(--default-clickable-area));
388+
padding-inline-start: calc(
389+
var(--default-clickable-area) + var(--default-grid-baseline)
390+
) !important;
264391
}
265392
}
266393
}

0 commit comments

Comments
 (0)