Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/API_v3.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Some requests succeed with their usual status, and their payload keeps its shape

| Key | Value | Set by | Meaning |
|-----|-------|--------|---------|
| `removed-jumps` | Inner list of question ids, e.g. `(3 7)` | Updating, reordering and copying questions; deleting options | Jumps to the question itself or to an earlier question, or jumps of the deleted option, were removed from the `extraSettings` of these questions, see [Jumps](DataStructure.md#jumps) |
| `removed-jumps` | Inner list of question ids, e.g. `(3 7)` | Updating, reordering and copying questions; deleting options; changing the bounds of a linear scale | Jumps to the question itself or to an earlier question, jumps of the deleted option, or jumps for values outside the scale, were removed from the `extraSettings` of these questions, see [Jumps](DataStructure.md#jumps) |

```
X-Forms-Adjusted: removed-jumps=(3 7)
Expand All @@ -62,7 +62,7 @@ X-Forms-Adjusted: removed-jumps=(3 7)

### Other API changes

- Questions can jump to a later question or to the end of the form with the extra settings `goTo` (per answer, on dropdown and radio questions) or `next` (after the question, on the other types with jumps), never both (see [Jumps](DataStructure.md#jumps)). Jumps to questions of other forms or to options that are not options of the question are rejected, and deleting an option removes its jumps. Submissions and submission updates ignore answers to the questions the jumps skip, and an answer "Other" needs a text. Updating, reordering and copying questions removes jumps to the question itself or to an earlier question; the response header `X-Forms-Adjusted` then lists the questions concerned under `removed-jumps` (see [Adjustments by the server](#adjustments-by-the-server)). Copying or importing a form drops such jumps as well, without the header, as these create a new form.
- Questions can jump to a later question or to the end of the form with the extra settings `goTo` (per answer, on dropdown, radio and linear scale questions) or `next` (after the question, on the other types with jumps), never both (see [Jumps](DataStructure.md#jumps)). Jumps to questions of other forms or to options that are not options of the question are rejected, deleting an option removes its jumps, and narrowing a linear scale removes the jumps of the values that are gone. Submissions and submission updates ignore answers to the questions the jumps skip, and an answer "Other" needs a text. Updating, reordering and copying questions removes jumps to the question itself or to an earlier question; the response header `X-Forms-Adjusted` then lists the questions concerned under `removed-jumps` (see [Adjustments by the server](#adjustments-by-the-server)). Copying or importing a form drops such jumps as well, without the header, as these create a new form.
- In API version 3.0 the following endpoints were introduced/changed:
- `GET /api/v3/forms/{formId}/questions` to get all questions of a form
- `GET /api/v3/forms/{formId}/questions/{questionId}` to get a single question
Expand Down Expand Up @@ -466,7 +466,7 @@ Update a single or multiple properties of a question-object.
|-----------|---------|-------------|
| _keyValuePairs_ | Array | Array of key-value pairs to update |
- Restrictions: It is **not allowed** to update one of the following key-value pairs: _id, formId, order, type_.
- Response: **Status-Code OK**, as well as the id of the updated question. Jumps in `extraSettings` that go to the question itself or to an earlier question are removed before saving (see [Jumps](DataStructure.md#jumps)), and the response header `X-Forms-Adjusted` then holds `removed-jumps=(<question id>)` (see [Adjustments by the server](#adjustments-by-the-server)). Jumps with an invalid structure, `goTo` or `next` on a question type without it, and ids of questions of other forms or of options that are not options of the question are rejected with **Status-Code Bad Request** "Invalid extraSettings, will not update."
- Response: **Status-Code OK**, as well as the id of the updated question. Jumps in `extraSettings` that go to the question itself or to an earlier question, and jumps of a linear scale for values outside its bounds, are removed before saving (see [Jumps](DataStructure.md#jumps)), and the response header `X-Forms-Adjusted` then holds `removed-jumps=(<question id>)` (see [Adjustments by the server](#adjustments-by-the-server)). Jumps with an invalid structure, `goTo` or `next` on a question type without it, and ids of questions of other forms or of options that are not options of the question are rejected with **Status-Code Bad Request** "Invalid extraSettings, will not update."

```
"data": 1
Expand Down
6 changes: 3 additions & 3 deletions docs/DataStructure.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,16 +270,16 @@ Optional extra settings for some [Question Types](#question-types)
| `columns` | `grid` | Array | - | Array of column identifiers / labels for grid questions |
| `rows` | `grid` | Array | - | Array of row identifiers / labels for grid questions |
| `questionType` | `grid` | String | `checkbox`, `number`, `radio` | Type of cell for grid questions (checkbox, numeric input, or radio) |
| `goTo` | `dropdown, multiple_unique` | Array | see [Jumps](#jumps) | Where to go next for an answer |
| `goTo` | `dropdown, linearscale, multiple_unique` | Array | see [Jumps](#jumps) | Where to go next for an answer |
| `next` | `color, date, file, grid, long, multiple, ranking, short, time` | Integer / string | question id, `'submit'` | Where to go after the question, see [Jumps](#jumps) |

### Jumps

An answer can lead to a later question or to the end of the form, as in Microsoft and Google Forms. The form stays a flat list of questions.

- `goTo` is a list of jumps per answer: `{"optionId": 12, "target": 34}` for an option, `{"other": true, "target": "submit"}` for the answer "Other". There is at most one jump per answer.
- `goTo` is a list of jumps per answer: `{"optionId": 12, "target": 34}` for an option, `{"other": true, "target": "submit"}` for the answer "Other", `{"value": 4, "target": 34}` for a value of a linear scale. There is at most one jump per answer. A `value` is an integer from 0 to 10, the range of all scales. When a question is updated, or its form copied or imported, the server removes the jumps for values outside the scale of the question; on update the response header `X-Forms-Adjusted` then holds `removed-jumps` with the id of the question.
- `next` is one target after the whole question; on a checkbox question it applies whichever options are checked.
- A question has either jumps per answer or one jump after the question, depending on its type: `goTo` only on `dropdown` and `multiple_unique`, `next` only on the other types listed above. Sending `next` on a question type with `goTo`, or `goTo` on a question type with `next`, is rejected with 400 "Invalid extraSettings". An answer without a `goTo` entry leads to the following question.
- A question has either jumps per answer or one jump after the question, depending on its type: `goTo` only on `dropdown`, `linearscale` and `multiple_unique`, `next` only on the other types listed above. Sending `next` on a question type with `goTo`, or `goTo` on a question type with `next`, is rejected with 400 "Invalid extraSettings". An answer without a `goTo` entry leads to the following question.
- A target is the id of a later question of the same form or `"submit"` for the end of the form, and `optionId` is the id of an option of the question. Ids of questions of other forms and option ids that are not options of the question are rejected as invalid `extraSettings`; ids of deleted questions of the form are accepted. Without a jump the respondent goes to the following question.
- Jumps never go back: the server removes jumps to the question itself or to an earlier question when a question is updated, reordered or copied, and when a form is copied or imported; copying or importing a form also drops `goTo` or `next` on question types without it. Updating, reordering and copying a question still succeed with their usual status, and the payload keeps its shape: a reorder adds the new `extraSettings` of the questions concerned, and a copied question is returned with its reduced `extraSettings`. The response header `X-Forms-Adjusted` lists the questions that lost jumps, e.g. `removed-jumps=(3 7)`, see [Adjustments by the server](API_v3.md#adjustments-by-the-server).
- Deleting an option removes the jumps of the question for that option; the response header `X-Forms-Adjusted` then holds `removed-jumps` with the id of the question.
Expand Down
1 change: 1 addition & 0 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ class Constants {

public const QUESTION_TYPES_WITH_GOTO = [
self::ANSWER_TYPE_DROPDOWN,
self::ANSWER_TYPE_LINEARSCALE,
self::ANSWER_TYPE_MULTIPLEUNIQUE,
];

Expand Down
5 changes: 3 additions & 2 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,7 @@ public function newQuestion(int $formId, ?string $type = null, ?string $subtype
* Writes the given key-value pairs into Database
* Key `order` should only be changed by reorderQuestions() and is not allowed here
*
* Jumps to the question itself or to an earlier question are removed before saving, and the header `X-Forms-Adjusted` then holds `removed-jumps` with the id of the question.
* Jumps to the question itself or to an earlier question, and jumps of a linear scale for values outside its bounds, are removed before saving, and the header `X-Forms-Adjusted` then holds `removed-jumps` with the id of the question.
* Jumps to questions of other forms or to options that are not options of the question are rejected as invalid `extraSettings`.
*
* @param int $formId the form id
Expand Down Expand Up @@ -782,7 +782,8 @@ public function updateQuestion(int $formId, int $questionId, array $keyValuePair
$updatedQuestion = clone $question;
$updatedQuestion->setExtraSettings($keyValuePairs['extraSettings']);
$formQuestions = array_values(array_map(static fn (Question $formQuestion): Question => $formQuestion->getId() === $questionId ? $updatedQuestion : $formQuestion, array_filter($allQuestions, static fn (Question $formQuestion): bool => $formQuestion->getOrder() !== 0)));
$removedJumps = in_array($updatedQuestion, FormsService::removeBackwardJumps($formQuestions), true);
$removedJumps = FormsService::removeJumpsOutsideScale($updatedQuestion);
$removedJumps = in_array($updatedQuestion, FormsService::removeBackwardJumps($formQuestions), true) || $removedJumps;
$keyValuePairs['extraSettings'] = $updatedQuestion->getExtraSettings();
}

Expand Down
2 changes: 1 addition & 1 deletion lib/ResponseDefinitions.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
* dateMax?: string|int,
* dateMin?: string|int,
* dateRange?: bool,
* goTo?: list<array{optionId?: int, other?: bool, target: int|string}>,
* goTo?: list<array{optionId?: int, other?: bool, value?: int<0, 10>, target: int|string}>,
* maxAllowedFilesCount?: int,
* maxFileSize?: int,
* next?: int|string,
Expand Down
34 changes: 32 additions & 2 deletions lib/Service/FormsService.php
Original file line number Diff line number Diff line change
Expand Up @@ -1078,7 +1078,7 @@ private static function isJumpTarget(mixed $target): bool {
}

/**
* Whether the value is a list of jumps per answer, each with one answer key and a target, at most one per answer
* Whether the value is a list of jumps per answer, each with one answer key (`value` from 0 to 10 on linear scales, else `optionId` or `other`) and a target, at most one per answer
*
* @param mixed $goTo the value of `goTo`
* @param string $questionType the question type
Expand All @@ -1094,7 +1094,12 @@ private static function isGoToValid(mixed $goTo, string $questionType): bool {
return false;
}

if (is_int($jump['optionId'] ?? null) && $jump['optionId'] > 0) {
if ($questionType === Constants::ANSWER_TYPE_LINEARSCALE) {
if (!is_int($jump['value'] ?? null) || $jump['value'] < 0 || $jump['value'] > 10) {
return false;
}
$answer = 'value:' . $jump['value'];
} elseif (is_int($jump['optionId'] ?? null) && $jump['optionId'] > 0) {
$answer = 'option:' . $jump['optionId'];
} elseif (($jump['other'] ?? null) === true) {
$answer = 'other';
Expand Down Expand Up @@ -1215,6 +1220,7 @@ public static function mapJumpsOfCopies(array $questions, array $questionIdMap,
$extraSettings['goTo'] = $goTo;
}
$question->setExtraSettings($extraSettings);
self::removeJumpsOutsideScale($question);
$copiesWithJumps[] = $question;
}
}
Expand Down Expand Up @@ -1261,6 +1267,30 @@ public static function removeBackwardJumps(array $questions): array {
return $changed;
}

/**
* Remove the jumps of a linear scale for values outside its bounds
*
* @param Question $question the question
* @return bool whether jumps were removed, with the new extra settings set
*/
public static function removeJumpsOutsideScale(Question $question): bool {
$extraSettings = $question->getExtraSettings();
if ($question->getType() !== Constants::ANSWER_TYPE_LINEARSCALE || !is_array($extraSettings['goTo'] ?? null)) {
return false;
}

$lowest = $extraSettings['optionsLowest'] ?? 1;
$highest = $extraSettings['optionsHighest'] ?? 5;
$goTo = array_values(array_filter($extraSettings['goTo'], static fn (mixed $jump): bool => !is_int(is_array($jump) ? ($jump['value'] ?? null) : null) || ($jump['value'] >= $lowest && $jump['value'] <= $highest)));
if (count($goTo) === count($extraSettings['goTo'])) {
return false;
}

$extraSettings['goTo'] = $goTo;
$question->setExtraSettings($extraSettings);
return true;
}

/**
* Get list of forms
*
Expand Down
3 changes: 2 additions & 1 deletion lib/Service/SubmissionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -871,7 +871,8 @@ private static function getJumpTarget(array $question, mixed $answer): string|in
if (is_int($value) || is_float($value) || is_string($value)) {
foreach (is_array($extraSettings['goTo'] ?? null) ? $extraSettings['goTo'] : [] as $jump) {
if ((isset($jump['optionId']) && (string)$jump['optionId'] === (string)$value)
|| (($jump['other'] ?? false) === true && self::isOtherAnswer($question, $value))) {
|| (($jump['other'] ?? false) === true && self::isOtherAnswer($question, $value))
|| (isset($jump['value']) && ctype_digit((string)$value) && (int)$value === $jump['value'])) {
$target = $jump['target'] ?? null;
return is_int($target) || is_string($target) ? $target : null;
}
Expand Down
8 changes: 7 additions & 1 deletion openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,12 @@
"other": {
"type": "boolean"
},
"value": {
"type": "integer",
"format": "int64",
"minimum": 0,
"maximum": 10
},
"target": {
"oneOf": [
{
Expand Down Expand Up @@ -2378,7 +2384,7 @@
"patch": {
"operationId": "api-update-question",
"summary": "Writes the given key-value pairs into Database Key `order` should only be changed by reorderQuestions() and is not allowed here",
"description": "Jumps to the question itself or to an earlier question are removed before saving, and the header `X-Forms-Adjusted` then holds `removed-jumps` with the id of the question. Jumps to questions of other forms or to options that are not options of the question are rejected as invalid `extraSettings`.\nThis endpoint allows CORS requests",
"description": "Jumps to the question itself or to an earlier question, and jumps of a linear scale for values outside its bounds, are removed before saving, and the header `X-Forms-Adjusted` then holds `removed-jumps` with the id of the question. Jumps to questions of other forms or to options that are not options of the question are rejected as invalid `extraSettings`.\nThis endpoint allows CORS requests",
"tags": [
"api"
],
Expand Down
25 changes: 25 additions & 0 deletions playwright/e2e/jump-issues.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,28 @@ test('jump issues: a jump of the answer "Other" breaks while "Other" is off', ()
),
).toEqual([2])
})

test('jump issues: a required scale whose values all jump past a question makes it unreachable', () => {
const scale = (goTo: Array<{ value: number; target: number }>) =>
({
id: 1,
text: 'Question 1',
type: 'linearscale',
isRequired: true,
extraSettings: { optionsLowest: 1, optionsHighest: 2, goTo },
}) as FormsQuestion
expect(
issues([scale([{ value: 1, target: 3 }]), short(2), short(3)]).unreachable,
).toEqual([])
expect(
issues([
scale([
{ value: 1, target: 3 },
{ value: 2, target: 3 },
{ value: 3, target: 2 },
]),
short(2),
short(3),
]).unreachable,
).toEqual([2])
})
67 changes: 67 additions & 0 deletions playwright/e2e/jumps.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,4 +159,71 @@ test.describe('Jumps', () => {
await submitView.checkRadio('Do you have a pet?', 'Yes')
await expect(submitView.getQuestion('Comments')).toBeVisible()
})

test('A value of a linear scale jumps past a question, set in the editor', async ({
page,
submitView,
}) => {
const form = await api(page.request, 'POST', '/forms')
await api(page.request, 'PATCH', `/forms/${form.id}`, {
keyValuePairs: { title: 'Scale form' },
})
const scale = await api(
page.request,
'POST',
`/forms/${form.id}/questions`,
{
type: 'linearscale',
text: 'How do you like it?',
},
)
await api(page.request, 'POST', `/forms/${form.id}/questions`, {
type: 'short',
text: 'What can we improve?',
})
const comments = await api(
page.request,
'POST',
`/forms/${form.id}/questions`,
{
type: 'short',
text: 'Comments',
},
)
await page.goto(`apps/forms/${form.hash}/edit`)

const question = page.getByRole('listitem', { name: 'Question number 1' })
await question.getByRole('button', { name: 'Actions', exact: true }).click()
await page
.getByRole('checkbox', { name: 'Go to based on answer' })
.click({ force: true })
await page.keyboard.press('Escape')

await question
.getByRole('combobox', {
name: 'Question 1: after "5 - Strongly agree" go to',
})
.click()
await page
.getByRole('option')
.filter({ hasText: 'Question 3: Comments' })
.click()

await expect(async () => {
const stored = await api(
page.request,
'GET',
`/forms/${form.id}/questions/${scale.id}`,
)
expect(stored.extraSettings.goTo).toEqual([
{ value: 5, target: comments.id },
])
}).toPass()

await page.goto(`apps/forms/${form.hash}/submit`)
await submitView.checkRadio('How do you like it?', '5')
await expect(submitView.getQuestion('What can we improve?')).toBeHidden()
await submitView.checkRadio('How do you like it?', '4')
await expect(submitView.getQuestion('What can we improve?')).toBeVisible()
})
})
Loading
Loading