Skip to content

Conditional questions: go to a question based on the answer - #3833

Open
AndyScherzinger wants to merge 4 commits into
mainfrom
feat/conditional-questions-1-base
Open

AndyScherzinger wants to merge 4 commits into
mainfrom
feat/conditional-questions-1-base

Conversation

@AndyScherzinger

@AndyScherzinger AndyScherzinger commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Conditional questions as in Microsoft and Google Forms, based on #3123 by @mickenordin with the fixes by @timedin-de, split into a stack so it can be reviewed in parts (as suggested by @Chartman123 in #3123 (comment)).

Fixes #358

Stack

Each PR is based on the one before it. Review and merge from the top.

  1. Conditional questions: go to a question based on the answer #3833 Conditional questions: go to a question based on the answer ← this PR
  2. Conditional questions: go to a question after checkbox questions #3834 Conditional questions: go to a question after checkbox questions
  3. Conditional questions: go to a question after date and time questions #3835 Conditional questions: go to a question after date and time questions
  4. Conditional questions: go to a question based on the value of a linear scale #3837 Conditional questions: go to a question based on the value of a linear scale

This PR

An answer can lead to a later question or to the end of the form. The form stays a flat list of questions; the jumps live in the question's extraSettings, as suggested by @Chartman123 in #3833 (review).

Editor

  • "Go to based on answer": radio and dropdown questions get a select per option and for "Other": next question, a later question, or end of the form.
  • Text, file, color, ranking and grid questions get one select "After question N".
  • Jumps only go forward: a move that would break this asks to move back or remove the jumps.
  • Warnings and a toast name broken jumps and questions that became unreachable or skippable.

Data

  • goTo holds one target per answer, next one target after the question; a type has one of them, never both.
  • Targets are later questions of the same form or "submit"; ids from other forms or questions are rejected with a 400.
  • The server removes jumps that go back on update, reorder and copy, and reports them in the new header X-Forms-Adjusted (RFC 8941, e.g. removed-jumps=(3 7)); status and payload shape stay the same.
  • Deleting an option removes its jumps and reports it in X-Forms-Adjusted.
  • Copying and importing a form map the targets and option ids, and drop jumps with an invalid structure.

Answers

  • The path starts at the first question and follows the jumps; a target that no longer exists counts as the next question. Only the first value of an answer decides, and answers to questions the submit view does not show do not count.
  • The submit view shows only the questions on the path and announces changes; a skipped question keeps its state when it comes back.
  • The server ignores answers to skipped questions and requires only questions on the path; editing a submission keeps its stored answers to skipped questions.
  • tests/fixtures/jump-paths.json holds the path cases, run by PHPUnit and Playwright, so both sides walk the same way.

Follow-ups

Sections with "go to section" (#357, #624) follow as a separate feature, see #3664; a section will be a valid jump target without a format change.

Tests

  • PHPUnit passes, except the 3 IUserFolder errors main also has on the test instance.
  • psalm, php-cs-fixer and eslint are clean; vue-tsc shows no new errors; every commit builds.
  • The Playwright suite passes (94 tests, one worker as on CI).
  • Validated on a running instance with every jump type of the stack: API, submit view and editor checks, keyboard navigation, and axe without violations.

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

@codecov

codecov Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.72727% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
lib/Service/SubmissionService.php 94.44% 3 Missing ⚠️
lib/Controller/ApiController.php 98.27% 1 Missing ⚠️
lib/Db/AnswerMapper.php 66.66% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@AndyScherzinger AndyScherzinger changed the title Conditional questions: radio and dropdown triggers Conditional questions: base implementation plus support for radio and dropdown triggers Nov 4, 2026
@AndyScherzinger

Copy link
Copy Markdown
Member Author

I'll work on fixing CI checks and test coverage next

@AndyScherzinger
AndyScherzinger force-pushed the feat/conditional-questions-1-base branch from b833f24 to 9d9b414 Compare November 4, 2026 14:07

@Chartman123 Chartman123 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a few general comments/questions.

One thing that came to my mind: would it be easier to not have a new "question type" conditional but to make this part of the props of a "normal" question?

Comment thread docs/DataStructure.md Resolved
Comment thread lib/Controller/ApiController.php Resolved
Comment thread src/components/QuestionList.vue Resolved
@AndyScherzinger
AndyScherzinger force-pushed the feat/conditional-questions-1-base branch 2 times, most recently from 21f29c0 to c2cf51a Compare November 5, 2026 11:10
@timedin-de

Copy link
Copy Markdown

Added a few general comments/questions.

One thing that came to my mind: would it be easier to not have a new "question type" conditional but to make this part of the props of a "normal" question?

@Chartman123

I personally also would prefer this Version as this could allow trigger questions to be seperated from the conditional question. (E.g Last question is only shown when first question is selected).
It would also easily allow Nestes conditions and is the same logic like in MS-Forms. Although this might get a larger refactor from current implementation.

Grouping functionality would get implemented independently by other running PRs.

@AndyScherzinger AndyScherzinger changed the title Conditional questions: base implementation plus support for radio and dropdown triggers Conditional questions: base with radio and dropdown questions Nov 7, 2026
@AndyScherzinger
AndyScherzinger force-pushed the feat/conditional-questions-1-base branch from b444385 to 025620c Compare November 7, 2026 15:41
@AndyScherzinger

Copy link
Copy Markdown
Member Author

One thing that came to my mind: would it be easier to not have a new "question type" conditional but to make this part of the props of a "normal" question?

Agreed, and done: there is no conditional question type anymore. Branches are part of a normal question's extraSettings, and follow-up questions are answered like any other question. See 025620c and the background in #3123 (comment). The other question types follow in #3834–#3838.

@AndyScherzinger

Copy link
Copy Markdown
Member Author

@timedin-de thanks, that is the direction the stack has taken now: there is no conditional question type anymore, branches are part of a normal question (see 025620c and #3123 (comment)).

I looked at how MS Forms does it. There, each option of a choice question jumps forward to a later question, a section or the end of the form, and "nested" comes from chaining those jumps. Two things from your comment are not covered by this stack yet:

  1. A later question that depends on an earlier answer ("last question is only shown when the first question is selected"). In this stack a follow-up question always appears directly below its question. For a question that should stay where it is, I'd rather add a display condition on the question itself ("show only if question 1 is …"), reusing the condition types and matching we have now, than copy MS Forms' jumps to single questions. A jump from question 1 over questions 2–4 is the same as "show only if question 1 is not X" on questions 2–4, and the condition sits on the question it affects.
  2. Nested conditions: follow-up questions are one level deep for now. Allowing branches on follow-up questions, up to a depth of 2 (numbered 1.2.1), is a small step on top of this model, because a follow-up is a normal question.

Both would come as follow-up PRs after this stack, so the stack stays reviewable. Section navigation ("after this section go to …", "go to section" per option, "end of the form") belongs to the sections work in #3664.

Does that cover what you had in mind, or do you need jumps to single questions?

@AndyScherzinger
AndyScherzinger force-pushed the feat/conditional-questions-1-base branch 2 times, most recently from ce2e434 to 06bd420 Compare November 9, 2026 11:40
@AndyScherzinger
AndyScherzinger marked this pull request as draft November 9, 2026 21:37
@AndyScherzinger AndyScherzinger added 2. developing Work in progress and removed 3. to review Waiting for reviews labels Nov 9, 2026
@AndyScherzinger

Copy link
Copy Markdown
Member Author

@Chartman123 @timedin-de @mickenordin before I push the next round, a heads-up on a scope change for this stack. It is larger than a fixup, so I would rather hear objections now.

1. Conditions move onto each answer

Today the rules sit in a panel below the question ("Follow-up questions of …", one card per branch with its own condition editor). Google Forms and Microsoft Forms both define the rule in the row of the answer, and I will follow that:

  • Radio, dropdown, checkboxes: a select at the end of each option row ("No follow-up", an existing follow-up group, "New follow-up group").
  • Linear scale: one row per value with the same select.
  • Date, date and time, time: one row per range ("between … and … → group").
  • Checkbox combinations ("at least Red and Blue") stay a rule row below the options, because they span several rows.

Several answers can point to the same group. The stored format (extraSettings.branches) and the API do not change; the select is a different view of the same data.

2. Fewer trigger types

Follow-up questions stay on radio, dropdown, checkboxes, linear scale, date, date and time, and time. I will drop them on short text, long text, color and file:

Reason: these types have no fixed set of answers, so they do not fit "on each answer". Google Forms does not branch on them, and as far as I can tell Microsoft Forms does not either. It also takes the condition types from twelve to four and removes regex evaluation on submit.

What this does and does not remove:

  • Text, color and file questions still work as follow-up questions. Only their use as a trigger goes.
  • @mickenordin: the text (equals, contains, regex), color and file triggers from the original Conditional Logic #3123 are the part that is really gone from this stack.
  • @timedin-de: the scale conditions "not equal", "range", "at least" and "at most" are replaced by one select per value. "At least 4" on a 1–5 scale is then 4 and 5 pointing at the same group. Several branches active at once, ranking as a follow-up type, the transitions and cloning all stay.

The removed code stays in the branch history of #3836–#3838.

3. Stricter validation on save

  • An option in a condition must belong to the question, a scale value must lie within the scale, and a date or time limit must use the question's format.
  • For radio, dropdown and linear scale an answer can lead to at most one follow-up group.
  • Deleting an option removes it from the conditions.
  • Follow-ups are not offered on date and time questions in range mode.

4. What comes after this stack

  • Sections with "go to section" per answer, building on feat: introduce Section as a new question type #3664. That is the actual Google and Microsoft Forms model.
  • A "show only if" rule on any later question, referencing an earlier answer. @timedin-de, that is my answer to "last question is only shown when the first is selected": the question keeps its place in the form. Chains of such rules also give nesting; I will ask you then whether that is enough, or whether indented follow-ups of follow-ups are still needed.
  • Text and number conditions can come back there, for "show only if" rules.

All PRs of the stack are drafts until the rework is pushed. I will also rebase onto main, since #3865 landed.

If you disagree with dropping the text, color and file triggers, or with the per-answer editor, please say so here before I push.

AndyScherzinger and others added 4 commits November 11, 2026 17:20
The check for answers to unknown questions compares the question ids
strictly, as the validation of each question already does, and names the
id as it was sent.

Option ids are compared as canonical integers with the ids of the
options of the question. Answers to questions that allow the answer
"Other" are an option id of the question or an answer "Other", and
storing an answer uses the same check for the answer "Other" as the
validation.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
As in Microsoft and Google Forms, an answer can lead to a later question
or to the end of the form, while the form stays a flat list of
questions. Radio and dropdown questions store a target per option and
for the answer "Other" in extraSettings.goTo. The other question types
with jumps (short and long text, file, color, ranking, grid) store one
target after the question in extraSettings.next. A question type has
either goTo or next, never both: sending the other one is rejected as
invalid extraSettings, and an answer without a jump leads to the
following question. A target is the id of a question of the same form or
"submit", and an option id belongs to the question; ids of questions of
other forms and of options that are not options of the question are
rejected, while ids of deleted questions of the form are accepted.

The path of a respondent starts at the first question and follows these
targets. Only the first value of an answer decides, which keeps the walk
linear in the size of a submission, and answers to questions the submit
view does not show, such as questions without a title, do not count, so
they cannot skip required questions. A target that is not a later
question of the form, for example after deleting it, counts as the
following question. Answers to the questions off the path are ignored,
also when a form changed while it was answered, and required only
applies on the path. An answer "Other" needs a text, as it decides the
path.

Jumps never go back. Updating, reordering and copying a question remove
the jumps to the question itself or to an earlier question; the request
still succeeds with its usual status and a payload of the same shape,
and the response header X-Forms-Adjusted lists the questions concerned
as removed-jumps. A reorder returns the new extraSettings of these
questions in its own response type QuestionOrder. Copying or importing a
form maps the targets and option ids and drops such jumps as well,
without the header, together with jumps whose target or option was not
copied, jumps per answer with an invalid structure, and goTo or next on
a question type without it. Exports keep the option ids for that.

tests/fixtures/jump-paths.json holds the path cases, shared with the
frontend.

Based on the conditional questions of #3123 by Micke Nordin and
TimedIn.

Co-Authored-By: Micke Nordin <kano@sunet.se>
Co-Authored-By: TimedIn <git@timedin.net>
Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
"Go to based on answer" in the question menu shows a select at the end
of each option row of radio and dropdown questions, and of the row
"Other": the next question, a later question or the end of the form. The
other question types with jumps get the select "After question N" below
them instead. The accessible names of the selects of the answers include
the question number as well. Below a width of 480px the select of an
option moves onto its own line. A target that was deleted or moved
before the question is shown as going to the next question. Unchecking
"Go to" closes the menu and asks before it removes the jumps of the
question. The warnings let jumps of questions the submit view does not
show lead nowhere, and count a jump of the answer "Other" as broken
while "Other" is off, as a reorder removes it as well.

Changes are kept in the question and saved as complete lists. Extra
settings the server adjusted replace the local ones unless they changed
in the meantime, and deleting an option drops its jumps once the server
confirms the deletion. playwright/e2e/jump-paths.spec.ts runs the path
cases shared with the server against the frontend walk.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
The submit view shows the questions on the path of the current answers
and announces when questions are added or skipped. Answers to skipped
questions are kept while the form is open, but not submitted.

Assisted-by: Claude Code:claude-opus-5-5
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
@AndyScherzinger
AndyScherzinger force-pushed the feat/conditional-questions-1-base branch from 3a6a749 to e71a212 Compare November 11, 2026 16:21

@Chartman123 Chartman123 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First review, mainly for the backend. Added a few comments to discuss :)


// Delete current answers
$this->answerMapper->deleteBySubmission($submissionId);
$this->answerMapper->deleteBySubmission($submissionId, array_column(SubmissionService::getQuestionsOnPath($questions, $answers), 'id'));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we delete only answers to questions on the path and not all answers like we do now?

Comment thread lib/Db/AnswerMapper.php
* @param list<int>|null $questionIds only the answers to these questions, all answers if null
*/
public function deleteBySubmission(int $submissionId): void {
public function deleteBySubmission(int $submissionId, ?array $questionIds = null): void {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment on ApiController

Comment on lines +916 to +921
if (in_array($questionType, Constants::QUESTION_TYPES_WITH_NEXT, true)) {
$allowed['next'] = ['integer', 'string'];
}
if (in_array($questionType, Constants::QUESTION_TYPES_WITH_GOTO, true)) {
$allowed['goTo'] = ['array'];
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why introduce a new way of testing here and not integrate it in the match above? would of course need a reorganizing in Constants.php as well, but would make it clearer there.

Comment thread lib/Constants.php
'shuffleOptions' => ['boolean'],
];

public const JUMP_TARGET_SUBMIT = 'submit';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reorder constants to better fitting sections in this file :)

Comment thread lib/Constants.php

public const JUMP_TARGET_SUBMIT = 'submit';

public const QUESTION_TYPES_WITH_GOTO = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, also add allowed extraSettings directly to the existing constants for file types (see other comment)

Comment thread lib/Constants.php
self::ANSWER_TYPE_MULTIPLEUNIQUE,
];

public const QUESTION_TYPES_WITH_NEXT = [

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, also add allowed extraSettings directly to the existing constants for file types (see other comment)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this stay in the Questions folder? Would then of course also be valid for AnswerInput, which also is not directly a question component...

I know, both are closely related, so also fine if they stay here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3. to review Waiting for reviews enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support conditional logic

3 participants