Qaid
ARTICLE

Branching Forms with `visibleIf`

Show a quest question only when earlier answers call for it: every operator, how they combine, and the rules the editor enforces.

Qaid Team
TL;DR

A question’s visibleIf rule is checked in the browser after every answer, and may only look at earlier questions. Four tests (equals, notEquals, in, answered) combine with allOf and anyOf. A hidden question never blocks Next, and its answer is left out when the form is sent.

A clinic asks “Do you have any allergies?” and only then “What are you allergic to?”. That second question carries a visibleIf rule. The embed checks every rule after each answer, so the right questions appear with no round trip to a server.

A question with no rule always shows.

Show a question only after a given answer

In the dashboard, open the quest, open the later question, and click Show only if… under Visibility. Pick the earlier question, a test and a value. The steps are in Build a quest in the dashboard.

In JSON, the same rule is one object on the question:

{
  "id": "allergens",
  "type": "multiple-choice",
  "label": "What are you allergic to?",
  "multiple": true,
  "options": [
    { "value": "medication", "label": "Medication" },
    { "value": "food", "label": "Food" },
    { "value": "insects", "label": "Insect stings" }
  ],
  "visibleIf": { "questionId": "has_allergies", "equals": "yes" }
}

allergens shows only once has_allergies is answered “yes”.

The same rule on a shop’s order survey, where “What went wrong?” waits for a “Yes”:

Loading the film…

The tests

JSON Editor label True when Before the earlier question is answered
{ "questionId": "a", "equals": "x" } is The answer is x false
{ "questionId": "a", "notEquals": "x" } is not There is an answer, and it is not x false
{ "questionId": "a", "in": ["x", "y"] } is one of (choices only) The answer is any of the listed values false
{ "questionId": "a", "answered": true } is answered There is any answer false
{ "questionId": "a", "answered": false } is not answered There is no answer true

An empty string, blank text, an empty list and no answer at all all count as unanswered. Comparisons are exact: "Yes" does not match "yes".

notEquals stays false until the earlier question has an answer. A “Why not?” follow-up therefore waits for the answer rather than showing up early. To show it before the answer too, pair it with answered: false inside an anyOf, below.

Combine conditions

allOf is true when every rule inside it is true. anyOf is true when at least one is.

"visibleIf": {
  "allOf": [
    { "questionId": "smoker", "equals": "yes" },
    {
      "anyOf": [
        { "questionId": "wants_to_quit", "in": ["yes", "maybe"] },
        { "questionId": "quit_attempts", "answered": true }
      ]
    }
  ]
}

This asks about a quit programme only for a current smoker who wants to quit or has tried before.

In the editor, + Add condition and Match All (AND) or Any (OR) build one level. A rule that nests one inside the other, like the one above, shows as Complex condition; edit it on the JSON tab.

Branch on a multi-select

With "multiple": true, the answer is a list. The tests read it like this:

  1. equals: "food" is true when “food” is one of the picks, among others or alone.
  2. in: ["food", "insects"] is true when any pick is in the list.
  3. notEquals: "food" is true when “food” was not picked.
  4. answered: true needs at least one pick.

So { "questionId": "allergens", "equals": "medication" } reveals a medication follow-up whether the visitor ticked one box or three.

Chain follow-ups

A follow-up can gate its own follow-up, as deep as you like, as long as each rule looks back. Each rule only needs to name the question just before it:

[
  {
    "id": "has_allergies",
    "type": "multiple-choice",
    "label": "Do you have any allergies?",
    "options": [
      { "value": "yes", "label": "Yes" },
      { "value": "no", "label": "No" }
    ]
  },
  {
    "id": "allergens",
    "type": "multiple-choice",
    "label": "What are you allergic to?",
    "multiple": true,
    "options": [
      { "value": "medication", "label": "Medication" },
      { "value": "food", "label": "Food" }
    ],
    "visibleIf": { "questionId": "has_allergies", "equals": "yes" }
  },
  {
    "id": "medication_severity",
    "type": "range",
    "label": "How bad was your worst reaction to a medication?",
    "min": 1,
    "max": 10,
    "unit": "/10",
    "visibleIf": { "questionId": "allergens", "equals": "medication" }
  }
]

Rules are checked in question order, and a hidden question counts as unanswered. A visitor who picked “medication”, went back and answered “no” loses allergens, and with it the question about the reaction. This needs quests-embed 1.5.3 or later. On an older version, repeat each earlier condition in an allOf.

Loading the film…

Branch on a number

Range and currency answers are numbers, so compare them with numbers: "equals": 10, not "equals": "10". The editor rejects the string. There is no greater-than test; to split on a score, list the values with in:

"visibleIf": { "questionId": "score", "in": [0, 1, 2, 3, 4, 5, 6] }

A range slider always holds a value, starting at its defaultValue or min. So a rule on a range question is tested against that value from the moment the slider shows, even before the visitor moves it.

Rules the editor rejects

Saving in the dashboard checks every rule. These fail with a message beside the tabs:

  1. A rule on the first question. It has nothing earlier to look at.
  2. A questionId that points at a later question, or at the question itself.
  3. A rule with two tests in one object, such as equals and in together. Use allOf.
  4. An empty allOf, anyOf or in.
  5. A string value against a range or currency question, or a number against any other type.

Moving a question up or down in the visual editor removes rules that would now point forward. Deleting a question removes the rules that pointed at it.

The embed does not run these checks. A hand-written questionnaire with a forward reference still draws, and that rule reads the later question as unanswered.

Hidden answers are left out

An answer is saved as soon as it is given. If a visitor answers a follow-up, goes back, and changes the answer that revealed it, the follow-up is hidden. When the form is sent, its answer is cleared from the response and left out of onComplete. The embed still holds it, so if the visitor flips the answer back, the follow-up shows filled in. Like the chain above, this needs 1.5.3 or later.

A response that is never sent keeps what was saved along the way, hidden answers included.

A hidden question never blocks the form, even when it is required.

Test rules in code

The package exports the functions the embed uses:

import { getVisibleQuestions, getVisibleAnswers, evaluateRule } from "@qaiddev/quests-embed";

const visible = getVisibleQuestions(questionnaire, { smoker: "yes" });
const sent = getVisibleAnswers(questionnaire, answers);
const show = evaluateRule({ questionId: "smoker", equals: "yes" }, { smoker: "yes" });

getVisibleAnswers keeps only the answers to questions that show, which is what the embed sends.

Neither throws. A rule that names an unknown question treats it as unanswered.

On a live embed, quest.goToStep("wants_to_quit") jumps to a question. It returns true when it moved and false when the question is hidden or unknown. Called before the quest has loaded, it returns false but remembers the request, and jumps once the quest is ready if that question is showing.

Back to all articles