Skip to content

Commit 514ee87

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

5 files changed

Lines changed: 392 additions & 140 deletions

File tree

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);
@@ -1992,4 +2006,36 @@ private function handleOwnerTransfer(Form $form, int $formId, string $currentUse
19922006
$this->formMapper->update($form);
19932007
return new DataResponse($form->getOwnerId());
19942008
}
2009+
2010+
/**
2011+
* Shift existing question orders to make room for an insertion at $position.
2012+
* Normalizes the given $position to the valid range and updates all questions
2013+
* with order >= $position by incrementing their order by one.
2014+
*
2015+
* @param Question[] $questions
2016+
* @param int $position 1-based desired position
2017+
* @return int normalized position
2018+
*/
2019+
private function shiftQuestionsForInsert(array $questions, int $position): int {
2020+
$maxOrder = 0;
2021+
if (count($questions) > 0) {
2022+
$maxOrder = end($questions)->getOrder();
2023+
}
2024+
if ($position < 1) {
2025+
$position = 1;
2026+
}
2027+
if ($position > $maxOrder + 1) {
2028+
$position = $maxOrder + 1;
2029+
}
2030+
2031+
for ($i = count($questions) - 1; $i >= 0; $i--) {
2032+
$q = $questions[$i];
2033+
if ($q->getOrder() >= $position) {
2034+
$q->setOrder($q->getOrder() + 1);
2035+
$this->questionMapper->update($q);
2036+
}
2037+
}
2038+
2039+
return $position;
2040+
}
19952041
}

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

0 commit comments

Comments
 (0)