How to build a decision tree in EPiServer
During multiple meetings with different clients, we noticed lately that some of our clients were interested / looking for a solution involving a complex user journey with a decision tree for their digital platform.
It is worth noticing that our clients don’t necessary use the word “decision tree” as I believe, it is sometimes more relevant to technical people. We found out that this is the most relevant expression to use based on the needs.
But what is a decision tree?
Our clients were looking for a complex set of forms on their website(s) with pre-defined fields where, based on the answer, we would redirect the user to different questions and so on.
A very simple example of a scenario can be summarised like this:
- Question 1: What kind of cuisine would you like to eat right now? Options are: Italian food or Chinese food
- The user selects Italian food
- Question 2: Would you prefer pizza or pasta?
- The user selects pizza
- Outcome: please find our selection of pizzerias in your area.
- The user selects pasta
- Outcome: please find our selection of Italian restaurants serving pasta in your area.
- The user selects pizza
- Question 2: Would you prefer pizza or pasta?
- The user selects Chinese food
- Question 2: Are you interested in rice dishes or dim sums?
- The user selects rice dishes
- Outcome: please find our selection of Chinese restaurants serving rice dishes in your area.
- The user selects dim sums
- Outcome: please find our selection of Chinese restaurants serving dim sums in your area.
- The user selects rice dishes
- Question 2: Are you interested in rice dishes or dim sums?
- The user selects Italian food
As you can see, the user journey starts with a question as an entry point and each answer redirects the user to a different set of questions, forming a tree of questions and answers, offering the ability to provide different outcomes based on the answers.
For our clients (and potentially more requests in the future) we also need to built an engine that will support dozens of answers at the first level and possibly thousands of branches.
It is implied that decision trees only allow pre-defined answers. Open-ended answers would add a level of complexity as it can become complicated to "translate" plain text into an answer. Our requirements didn’t include open-ended answers.
There are a lot of games out there using a “decision tree engine” – some of them have been known for decades like the Choose Your Own Adventure games.
We started doing some discovery about the capabilities in EPiServer to work with decision trees.
We found out that EPiServer Forms allows some kind of conditional logic where it is possible to display / hide some questions based on the answers of other questions, but unfortunately it is quite linear & wouldn’t scale well for scenarios with hundreds of branches & descendants.
But we also found out that we also have another tree we can use to “simulate” the decision tree: the content tree inside the CMS. The perks of going with that approach is that all the visual components are already there; no need to start using Dojo to build complex visual elements. It's also easy for the editors to group questions & answers using the built-in folders / nodes in the content tree.
Using references and the content properties inside EPiServer CMS, it is absolutely possible to store the data at the content tree level:
We can have one entry point (like a page) where we would set a reference to the first question / step.
As per requirement, the first question / step, needs to be a complex object possibly with a label and references to different answers.
Each answer would have a value and possibly an (optional) reference to the next question. If the property linking the answer to a possible next question is empty, we can safely assume that we are reaching one end of the tree.
The schema would look like:
Page properties |
Question / step properties |
Answer properties |
Entry point: single reference to a question.
|
Question label: text Answers: list of references to answer objects.
|
Answer label: text Next question: (optional) reference to a question object. |
Based on our analysis, we can use EPiServer pages or blocks for both questions and answers as pages and blocks are considered as complex customizable objects.
For our project we went with the following approach:
- Each question is a page.
- Each answer is a block, and the list is a ContentArea.
It's important to know that this is not the absolute truth, other data models can work just as well, we found this model to be a good fit based on our clients' ability to use the CMS.
We would add the following classes definitions:
public class DecisionTreePage : PageData
{
[AllowedTypes(AllowedTypes = new[] { typeof(QuestionPage) })]
[SelectOne(SelectionFactoryType = typeof(QuestionFactory))]
public virtual PageReference StartingPoint { get; set; }
}
Public class QuestionPage: PageData
{
public virtual string Label { get; set; }
[AllowedTypes(AllowedTypes = new[] { typeof(AnswerBlock) })]
public virtual ContentArea AnswersContainer { get; set; }
}
Public class AnswerBlock: BlockData
{
[AllowedTypes(AllowedTypes = new[] { typeof(QuestionPage) })]
public virtual PageReference NextQuestion { get; set; }
}
If you are paying attention, you will notice a selection factory decorating the starting point property. In order to avoid confusion regarding forms, questions and answers, we took the initiative to establish the following convention:
It is only possible to pick questions that are child nodes of the decision tree page. The code below shows a way to do it:
public class QuestionFactory : ISelectionFactory
{
private Injected<IContentLoader> _contentLoader;
public IEnumerable<ISelectItem> GetSelections(ExtendedMetadata metadata)
{
var currentPage = metadata.FindOwnerContent();
if (currentPage == null)
return Enumerable.Empty<ISelectItem>();
if (!(currentPage is QuestionPage))
return Enumerable.Empty<ISelectItem>();
//triggered before the page is created - need to check contentlink as well
if (ContentReference.IsNullOrEmpty(currentPage.ContentLink))
return Enumerable.Empty<ISelectItem>();
var children = _contentLoader.Service?.GetChildren<QuestionPage>(currentPage.ContentLink);
return children.Select(x => new SelectItem
{
Text = x.Name,
Value = x.ContentLink
});
}
}
And with this code, we make sure that only the child nodes will be selectable as the starting point.
Finally, we need some code to “build” our tree. It can be done using recursive functions available in the pseudo-code below:
public DecisionTreeViewModel GetTree(DecisionTreePage page)
{
if (ContentReference.IsNullOrEmpty(page.StartingPoint))
return null;
var startingPoint = _contentLoader.Get<QuestionPage>(page.StartingPoint);
var tree = GetRecursiveItems(startingPoint);
}
Public DecisionTreeViewModel GetRecursiveItems(QuestionPage question)
{
If(question == null)
return null;
var toReturn = new DecisionTreeViewModel
{
questionLabel = question.Label;
Answers = question.Answers.Select( x =>
{
var nextQuestionPage = ContentReference.IsNullOrEmpty(x.NextQuestion) ?
null :
_contentLoader.Get<QuestionPage>(x.NextQuestion);
return new {
nextQuestion = nextQuestionPage == null ? null : GetRecursiveItems(nextQuestionPage);
}
}
}
return toReturn;
}
And this is it! Once your tree view model is loaded, it is possible to move through the decision tree in a web page using the data that we collected before. It’s possible to use a SPA framework like Angular, React or Vue or even vanilla javascript to offer a very smooth user journey without reloading the page.
I thought of setting a plugin for decision trees for the content section of EPiServer, maybe on top of EPiServer Forms? if you are interested to join, please let me know in the comments 😊
This is a really useful article, good code examples. I have previously had to build a complicated form for a client and we went with episerver forms using a multi-step form because they needed some more complicated form elements (file upload, date pickers etc) but for a simple multiple choice form this is much nicer solution, maintaining a big episerver form can be a massive headache.
Good Article. We did similar thing with wizard component.
https://world.episerver.com/blogs/ram-kumar-k/dates/2020/12/guided-journey--developing-a-wizard-component-using-episerver-cms/
Thank you Sam & Ram 😊