Skip to content

Commit d74039c

Browse files
committed
feat: add/clone question at position
Signed-off-by: Christian Hartmann <chris-hartmann@gmx.de>
1 parent 8244673 commit d74039c

16 files changed

Lines changed: 689 additions & 347 deletions

docs/API_v3.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,7 @@ Returns the questions and options of the given form (without submissions).
370370
|-----------|---------|----------|-------------|
371371
| _type_ | [QuestionType](DataStructure.md#question-types) | | The question-type of the new question |
372372
| _text_ | String | yes | _Optional_ The text of the new question. |
373+
| _position_ | Integer | yes | _(optional)_ 1-based position to insert the new question. When omitted the question is appended at the end. |
373374
- Response: The new question object.
374375

375376
```
@@ -506,6 +507,10 @@ Creates a clone of a question with all its options.
506507
|-----------|---------|-------------|
507508
| _formId_ | Integer | ID of the form containing the question |
508509
| _questionId_ | Integer | ID of the question to clone |
510+
- Parameters:
511+
| Parameter | Type | Optional | Description |
512+
|-----------|---------|----------|-------------|
513+
| _position_ | Integer | yes | _(optional)_ 1-based position to insert the cloned question. When omitted the clone is appended at the end. |
509514
- Response: Returns cloned question object with the new ID set.
510515

511516
```

lib/Controller/ApiController.php

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ public function getQuestion(int $formId, int $questionId): DataResponse {
485485
* @param FormsQuestionGridCellType $subtype the new question subtype
486486
* @param string $text the new question title
487487
* @param ?int $fromId (optional) id of the question that should be cloned
488+
* @param ?int $position (optional) the position of the new question
488489
* @return DataResponse<Http::STATUS_CREATED, FormsQuestion, array{}>
489490
* @throws OCSBadRequestException Invalid type
490491
* @throws OCSBadRequestException Datetime question type no longer supported
@@ -499,7 +500,7 @@ public function getQuestion(int $formId, int $questionId): DataResponse {
499500
#[NoAdminRequired()]
500501
#[BruteForceProtection(action: 'form')]
501502
#[ApiRoute(verb: 'POST', url: '/api/v3/forms/{formId}/questions')]
502-
public function newQuestion(int $formId, ?string $type = null, ?string $subtype = null, string $text = '', ?int $fromId = null): DataResponse {
503+
public function newQuestion(int $formId, ?string $type = null, ?string $subtype = null, string $text = '', ?int $fromId = null, ?int $position = null): DataResponse {
503504
$form = $this->formsService->getFormIfAllowed($formId, Constants::PERMISSION_EDIT);
504505
$this->formsService->obtainFormLock($form);
505506

@@ -526,13 +527,20 @@ public function newQuestion(int $formId, ?string $type = null, ?string $subtype
526527
throw new OCSBadRequestException('Datetime question type no longer supported');
527528
}
528529

529-
// Retrieve all active questions sorted by Order. Takes the order of the last array-element and adds one.
530+
// Retrieve all active questions sorted by Order.
530531
$questions = $this->questionMapper->findByForm($formId);
531-
$lastQuestion = array_pop($questions);
532-
if ($lastQuestion) {
533-
$questionOrder = $lastQuestion->getOrder() + 1;
532+
533+
if ($position !== null) {
534+
$position = $this->shiftQuestionsForInsert($questions, $position);
535+
$questionOrder = $position;
534536
} else {
535-
$questionOrder = 1;
537+
// Append at the end
538+
$lastQuestion = array_pop($questions);
539+
if ($lastQuestion) {
540+
$questionOrder = $lastQuestion->getOrder() + 1;
541+
} else {
542+
$questionOrder = 1;
543+
}
536544
}
537545

538546
$question = new Question();
@@ -574,7 +582,13 @@ public function newQuestion(int $formId, ?string $type = null, ?string $subtype
574582

575583
$questionData = $sourceQuestion->read();
576584
unset($questionData['id']);
577-
$questionData['order'] = end($allQuestions)->getOrder() + 1;
585+
586+
if ($position !== null) {
587+
$position = $this->shiftQuestionsForInsert($allQuestions, $position);
588+
$questionData['order'] = $position;
589+
} else {
590+
$questionData['order'] = end($allQuestions)->getOrder() + 1;
591+
}
578592

579593
$newQuestion = Question::fromParams($questionData);
580594
$this->questionMapper->insert($newQuestion);
@@ -2000,4 +2014,36 @@ private function handleOwnerTransfer(Form $form, int $formId, string $currentUse
20002014
$this->formMapper->update($form);
20012015
return new DataResponse($form->getOwnerId());
20022016
}
2017+
2018+
/**
2019+
* Shift existing question orders to make room for an insertion at $position.
2020+
* Normalizes the given $position to the valid range and updates all questions
2021+
* with order >= $position by incrementing their order by one.
2022+
*
2023+
* @param Question[] $questions
2024+
* @param int $position 1-based desired position
2025+
* @return int normalized position
2026+
*/
2027+
private function shiftQuestionsForInsert(array $questions, int $position): int {
2028+
$maxOrder = 0;
2029+
if (count($questions) > 0) {
2030+
$maxOrder = end($questions)->getOrder();
2031+
}
2032+
if ($position < 1) {
2033+
$position = 1;
2034+
}
2035+
if ($position > $maxOrder + 1) {
2036+
$position = $maxOrder + 1;
2037+
}
2038+
2039+
for ($i = count($questions) - 1; $i >= 0; $i--) {
2040+
$q = $questions[$i];
2041+
if ($q->getOrder() >= $position) {
2042+
$q->setOrder($q->getOrder() + 1);
2043+
$this->questionMapper->update($q);
2044+
}
2045+
}
2046+
2047+
return $position;
2048+
}
20032049
}

openapi.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,6 +1686,13 @@
16861686
"nullable": true,
16871687
"default": null,
16881688
"description": "(optional) id of the question that should be cloned"
1689+
},
1690+
"position": {
1691+
"type": "integer",
1692+
"format": "int64",
1693+
"nullable": true,
1694+
"default": null,
1695+
"description": "(optional) the position of the new question"
16891696
}
16901697
}
16911698
}

src/components/AddQuestionMenu.vue

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
<!--
2+
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
- SPDX-License-Identifier: AGPL-3.0-or-later
4+
-->
5+
6+
<template>
7+
<NcActions
8+
v-model:open="openLocal"
9+
:container="container"
10+
:menuName="menuName"
11+
:aria-label="ariaLabel"
12+
:variant="variant"
13+
:primary="primary">
14+
<template #icon>
15+
<NcLoadingIcon v-if="isLoadingQuestions" :size="20" />
16+
<NcIconSvgWrapper v-else :svg="IconPlus" />
17+
</template>
18+
19+
<template v-if="!activeQuestionType">
20+
<NcActionButton
21+
v-for="(answer, type) in answerTypesFilter"
22+
:key="answer.label"
23+
:closeAfterClick="!hasSubtypes(answer)"
24+
:disabled="isLoadingQuestions"
25+
:isMenu="hasSubtypes(answer)"
26+
class="question-menu__question"
27+
@click="onPrimaryClick(answer, type, position)">
28+
<template #icon>
29+
<NcIconSvgWrapper :svg="answer.icon" />
30+
</template>
31+
{{ answer.label }}
32+
</NcActionButton>
33+
</template>
34+
35+
<template v-else>
36+
<NcActionButton
37+
:disabled="isLoadingQuestions"
38+
class="question-menu__question"
39+
@click="activeQuestionType = null">
40+
<template #icon>
41+
<NcIconSvgWrapper :svg="IconChevronLeft" />
42+
</template>
43+
{{ t('forms', 'Grid') }}
44+
</NcActionButton>
45+
<NcActionSeparator />
46+
47+
<NcActionButton
48+
v-for="(answer, type) in answerTypesFilter[activeQuestionType]
49+
.subtypes"
50+
:key="'subtype-' + answer.label"
51+
closeAfterClick
52+
:disabled="isLoadingQuestions"
53+
class="question-menu__question"
54+
@click="onSubtypeClick(activeQuestionType, type, position)">
55+
<template #icon>
56+
<NcIconSvgWrapper :svg="answer.icon" />
57+
</template>
58+
{{ answer.label }}
59+
</NcActionButton>
60+
</template>
61+
</NcActions>
62+
</template>
63+
64+
<script>
65+
import IconPlus from '@material-symbols/svg-400/outlined/add.svg?raw'
66+
import IconChevronLeft from '@material-symbols/svg-400/outlined/chevron_left.svg?raw'
67+
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
68+
import NcActions from '@nextcloud/vue/components/NcActions'
69+
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
70+
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
71+
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
72+
73+
export default {
74+
name: 'AddQuestionMenu',
75+
76+
components: {
77+
NcActions,
78+
NcActionButton,
79+
NcActionSeparator,
80+
NcIconSvgWrapper,
81+
NcLoadingIcon,
82+
},
83+
84+
props: {
85+
open: { type: Boolean, default: false },
86+
container: { type: String, default: 'body' },
87+
menuName: { type: String, default: null },
88+
ariaLabel: { type: String, default: null },
89+
variant: { type: String, default: null },
90+
primary: { type: Boolean, default: false },
91+
position: { type: Number, default: null },
92+
isLoadingQuestions: { type: Boolean, default: false },
93+
answerTypesFilter: { type: Object, required: true },
94+
hasSubtypes: { type: Function, required: true },
95+
},
96+
97+
emits: ['update:open', 'addQuestion'],
98+
99+
setup() {
100+
return {
101+
IconChevronLeft,
102+
IconPlus,
103+
}
104+
},
105+
106+
data() {
107+
return {
108+
activeQuestionType: null,
109+
openLocal: this.open,
110+
}
111+
},
112+
113+
watch: {
114+
open(v) {
115+
this.openLocal = v
116+
},
117+
118+
openLocal(v) {
119+
this.$emit('update:open', v)
120+
if (!v) this.activeQuestionType = null
121+
},
122+
},
123+
124+
methods: {
125+
onPrimaryClick(answer, type, position) {
126+
if (this.hasSubtypes(answer)) {
127+
this.activeQuestionType = type
128+
return
129+
}
130+
this.$emit('addQuestion', type, null, position)
131+
this.openLocal = false
132+
},
133+
134+
onSubtypeClick(type, subtype, position) {
135+
this.$emit('addQuestion', type, subtype, position)
136+
this.openLocal = false
137+
},
138+
},
139+
}
140+
</script>
141+
142+
<style scoped>
143+
.question-menu__question {
144+
min-width: 200px;
145+
}
146+
</style>

src/components/Questions/Question.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@
157157

158158
<!-- Question content -->
159159
<slot />
160+
<!-- Insert question menu -->
161+
<slot name="insert" />
160162
</li>
161163
</template>
162164

src/components/Questions/QuestionColor.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@
3535
</NcButton>
3636
</div>
3737
</div>
38+
<template #insert>
39+
<slot name="insert" />
40+
</template>
3841
</Question>
3942
</template>
4043

src/components/Questions/QuestionDate.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@
9898
rangeSeparator=" - "
9999
@update:modelValue="onValueChange" />
100100
</div>
101+
<template #insert>
102+
<slot name="insert" />
103+
</template>
101104
</Question>
102105
</template>
103106

0 commit comments

Comments
 (0)