survey-transform.js

import {
  DEFAULT_ROUTING,
  ROUTING_RULE,
  ROUTING_RULE_ELSE,
} from "./constants.js";
import { stripHTML } from "./utils.js";

const authorDestinationToId = ({ pageId, sectionId, logical }) =>
  pageId || sectionId || logical;

const traverseTree = (tree, λ) => {
  if (!tree || typeof tree !== "object") {
    return;
  }
  λ(tree);
  Object.keys(tree).forEach((key) => traverseTree(tree[key], λ));
};

const buildIdHashIndex = (questionnaire) => {
  const index = new Map();
  traverseTree(questionnaire, (obj) => {
    if (obj.id) index.set(obj.id, obj);
  });
  return index;
};

const parseAuthorRouting = (routing, idIndex) =>
  routing
    ? [
        ...routing.rules.map((rule) => ({
          destinationId: authorDestinationToId(rule.destination),
          conditionDescription: parseExpressionGroup(
            rule.expressionGroup,
            idIndex
          ),
          type: ROUTING_RULE,
        })),
        {
          destinationId: authorDestinationToId(routing.else),
          type: ROUTING_RULE_ELSE,
        },
      ]
    : [{ destinationId: "NextPage", type: DEFAULT_ROUTING }];

// ----
// Utilities for parsing expression groups

const parseRightHandSide = (right, idIndex) => {
  switch (right.type) {
    case "SelectedOptions":
      return right.optionIds
        .map(
          (id) =>
            `<span class="option">${
              stripHTML(idIndex.get(id)?.label?.trim()) || "<ANSWER NOT FOUND>"
            }</span>`
        )
        .join(", ");
    case "Custom":
      return right.customValue.number;
    default:
      return "<UNKNOWN TYPE>";
  }
};

const parseLeftHandSide = (left, idIndex) => {
  const answer = idIndex.get(left.answerId) || {};
  const { qCode, label, questionPageId } = answer;
  const questionPage = idIndex.get(questionPageId);
  return `<abbr title="${
    stripHTML(qCode ? label || questionPage?.title : questionPage?.title) ||
    "Unknown"
  }">${stripHTML(qCode || label || "[UNKNOWN ANSWER]")}</abbr>`;
};

const parseCondition = (condition) => {
  switch (condition) {
    case "OneOf":
      return "one of";
    case "AnyOf":
      return "any of";
    case "AllOf":
      return "all of";
    case "Equal":
      return "equal to";
    case "Unanswered":
      return "unanswered";
    default:
      return "[unknown condition]";
  }
};

const parseExpression = ({ condition, left, right }, idIndex) =>
  `${parseLeftHandSide(
    left,
    idIndex
  )} is <span class="condition">${parseCondition(condition)}</span> ${
    condition === "Unanswered" ? "" : parseRightHandSide(right, idIndex)
  }`;

const parseExpressionGroup = ({ operator, expressions }, idIndex) =>
  expressions
    .map((expr) => parseExpression(expr, idIndex))
    .join(
      ` <span class="operator">${operator?.toUpperCase() || "AND"}</span> `
    );

// ----

export default (questionnaire) => {
  const idIndex = buildIdHashIndex(questionnaire);

  const questions = questionnaire.sections
    .flatMap(({ id, folders }) =>
      folders.flatMap(({ pages }) =>
        pages.map((page) => ({ ...page, sectionId: id }))
      )
    )
    .filter((page) => page.pageType !== "CalculatedSummaryPage");

  const data = [
    {
      id: "EndOfQuestionnaire",
      alias: "End",
      title: "End of Questionnaire",
    },
  ];
  const links = [];
  const questionIdIndex = {};

  // Iterate through questions backwards - ensures that indicies of destination questions
  // are already known when we get to their sources, enabling easy lookup
  for (let index = questions.length - 1; index >= 0; index--) {
    const {
      id,
      alias,
      sectionId,
      title = "Untitled question",
      routing,
      skipConditions,
    } = questions[index];

    // Build a hash index of id -> index in data array so we can find the page's position quickly later
    questionIdIndex[sectionId] = index;
    questionIdIndex[id] = index;

    const skip = skipConditions || [];

    // Build data which will be found to SVG elements in D3 - needs to know alias (short name)
    // and title of question (shown on mouseover)
    data.unshift({
      id,
      alias: stripHTML(alias),
      title: stripHTML(title),
      skip,
    });

    const logicalDestinationToIndex = {
      NextPage: index + 1,
      EndOfQuestionnaire: questions.length - 1,
    };

    // Generate link information for D3 - needs source + target indices to refer to
    // position within data array, hence we map from author ID to data index here
    // link object also contains type info (routing rule, default routing, etc.)
    // & readable explanation for the condition which needs to be met for the rule to activate
    parseAuthorRouting(routing, idIndex).forEach(
      ({ destinationId, type, conditionDescription }) => {
        links.push({
          source: index,
          target:
            logicalDestinationToIndex[destinationId] ||
            questionIdIndex[destinationId],
          type,
          conditionDescription,
        });
      }
    );
  }

  return { data, links };
};

Author: Thomas Baggaley

Created: 2021-03-23 Tue 15:45