11
11
class =" question__item__pseudoInput" />
12
12
<input
13
13
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 "
17
17
class =" question__input"
18
18
:class =" { 'question__input--shifted': !isDropdown }"
19
19
:maxlength =" maxOptionLength"
20
- minlength =" 1"
21
20
type =" text"
22
21
dir =" auto"
23
22
@input =" onInput"
24
23
@keydown.delete =" deleteEntry"
25
24
@keydown.enter.prevent =" focusNextInput" />
26
25
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 >
32
50
</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 >
36
61
</li >
37
62
</template >
38
63
39
64
<script >
40
65
import { showError } from ' @nextcloud/dialogs'
41
66
import { generateOcsUrl } from ' @nextcloud/router'
42
67
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'
45
69
import PQueue from ' p-queue'
46
70
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'
50
75
import IconCheckboxBlankOutline from ' vue-material-design-icons/CheckboxBlankOutline.vue'
51
76
import IconRadioboxBlank from ' vue-material-design-icons/RadioboxBlank.vue'
52
77
@@ -57,18 +82,23 @@ export default {
57
82
name: ' AnswerInput' ,
58
83
59
84
components: {
60
- IconClose,
85
+ IconArrowDown,
86
+ IconArrowUp,
61
87
IconCheckboxBlankOutline,
88
+ IconDelete,
62
89
IconRadioboxBlank,
63
- NcActions,
64
- NcActionButton,
90
+ NcButton,
65
91
},
66
92
67
93
props: {
68
94
answer: {
69
95
type: Object ,
70
96
required: true ,
71
97
},
98
+ allowReorder: {
99
+ type: Boolean ,
100
+ default: true ,
101
+ },
72
102
index: {
73
103
type: Number ,
74
104
required: true ,
@@ -77,6 +107,10 @@ export default {
77
107
type: Number ,
78
108
required: true ,
79
109
},
110
+ maxIndex: {
111
+ type: Number ,
112
+ required: true ,
113
+ },
80
114
isUnique: {
81
115
type: Boolean ,
82
116
required: true ,
@@ -93,19 +127,43 @@ export default {
93
127
94
128
data () {
95
129
return {
130
+ localAnswer: this .answer ,
96
131
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 ),
102
132
}
103
133
},
104
134
105
135
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
+
106
152
pseudoIcon () {
107
153
return this .isUnique ? IconRadioboxBlank : IconCheckboxBlankOutline
108
154
},
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
+ },
109
167
},
110
168
111
169
methods: {
@@ -117,38 +175,32 @@ export default {
117
175
* Focus the input
118
176
*/
119
177
focus () {
120
- this .$refs .input .focus ()
178
+ this .$refs .input ? .focus ()
121
179
},
122
180
123
181
/**
124
182
* Option changed, processing the data
125
183
*/
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 )
141
188
} else {
142
- this .debounceUpdateAnswer (answer)
143
- this .$emit (' update:answer' , answer .id , answer)
189
+ response = await this .updateAnswer (this .localAnswer )
144
190
}
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 )
145
195
},
146
196
147
197
/**
148
198
* Request a new answer
149
199
*/
150
200
focusNextInput () {
151
- this .$emit (' focus-next' , this .index )
201
+ if (this .index <= this .maxIndex ) {
202
+ this .$emit (' focus-next' , this .index )
203
+ }
152
204
},
153
205
154
206
/**
@@ -158,14 +210,24 @@ export default {
158
210
* @param {Event} e the event
159
211
*/
160
212
async deleteEntry (e ) {
213
+ if (this .answer .local ) {
214
+ return
215
+ }
216
+
161
217
if (e .type !== ' click' && this .$refs .input .value .length !== 0 ) {
162
218
return
163
219
}
164
220
165
221
// Dismiss delete key action
166
222
e .preventDefault ()
167
223
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
+ })
169
231
},
170
232
171
233
/**
@@ -182,6 +244,7 @@ export default {
182
244
{
183
245
id: this .formId ,
184
246
questionId: answer .questionId ,
247
+ order: answer .order ?? this .maxIndex ,
185
248
},
186
249
),
187
250
{
@@ -192,17 +255,14 @@ export default {
192
255
193
256
// Was synced once, this is now up to date with the server
194
257
delete answer .local
195
- return Object . assign ({}, answer, OcsResponse2Data (response)[ 0 ])
258
+ return { ... answer, ... OcsResponse2Data (response) }
196
259
} catch (error) {
197
260
logger .error (' Error while saving answer' , { answer, error })
198
261
showError (t (' forms' , ' Error while saving the answer' ))
199
262
}
200
263
201
264
return answer
202
265
},
203
- debounceCreateAnswer: pDebounce (function (answer ) {
204
- return this .queue .add (() => this .createAnswer (answer))
205
- }, 100 ),
206
266
207
267
/**
208
268
* Save to the server, only do it after 500ms
@@ -232,6 +292,27 @@ export default {
232
292
logger .error (' Error while saving answer' , { answer, error })
233
293
showError (t (' forms' , ' Error while saving the answer' ))
234
294
}
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
+ }
235
316
},
236
317
},
237
318
}
@@ -242,13 +323,20 @@ export default {
242
323
position: relative;
243
324
display: inline- flex;
244
325
min- height: var (-- default- clickable- area);
326
+ width: 100 % ;
245
327
246
328
& __pseudoInput {
247
329
color: var (-- color- primary- element);
248
330
margin- inline- start: - 2px ;
249
331
z- index: 1 ;
250
332
}
251
333
334
+ .option__actions {
335
+ display: flex;
336
+ // make sure even the "add new" option is aligned correctly
337
+ min- width: 44px ;
338
+ }
339
+
252
340
.question__input {
253
341
width: 100 % ;
254
342
position: relative;
0 commit comments