Apply, Part 3: Page jumping

One of the most used features of Apply is the ability to dynamically direct a user through a document based on past answers, or even information outside of the current document.

Institution type selection from the Muslim Journeys application. Possible values: Public Library (single branch); Public Library (multiple branches); Academic Library; Community College Library; State Humanities Council.

The problem

In the very first project hosted in Apply, we were prepared for three kinds of applicants, each of which needed to see a different application. The type of institution was collected on the first page in a select box, as illustrated above.

This need had significant implications for the design of the document system. (For a full overview of the document system, see Part 2.) It implies:

Suppose a user fills out the document above, but as they look over their work at the end, they discover they have made a mistake in the institution type field on the first page: They represent a public library system with multiple branches, (the second path in the list above) but entered themselves as a single library. If they change their institution type, their document will no longer be valid, as it will be missing the special page for public library systems—we have to make them fill that out before showing them another Submit button.

It might seem like we could just collect any missing answers in a “clean up” page at the end. But this is not ideal for at least two reasons: (a) in that case, the questions would presented out of any order or context, and (b) what if one of those questions itself causes additional informaiton to be required? That can’t be determined until the user enters their actual information (regardless of whether it’s sent back with a POST or some slick AJAX) so we can’t actually put a Submit button on our “clean up” screen until that time. (Another problem is that this solution is tailored to the specific use case here, and may not be general enough for the extended purposes to which this feature has already been put to use.)

We need a better abstraction. In Apply, this problem is addressed by setting a few ground rules:

Editing an existing document. Note the URL query string, "p=41972". This instructs the system to edit the page pointed to by paging #41972, without updating the "current paging"; in this way, users can edit previous pages without clicking back through the document. If a question on this page modified page flow, 41972 would be made the current paging, and everything after "Expectations" in the page navigation would be wiped.

Instead of trying to reckon and corral missing questions in an ugly lump at the end, we define a valid, submittable document as one whose path is valid, and is currently on a terminal page. If a user needs to change their path through the document, they will be forced to click through, but questions will show up in order and in context, and none of their existing answers will be lost.

Implementation

So far so good, but we’ve only described how we want it to behave. How do we represent these page flows (which may grow to some complexity) in a manageable way?

Pages

In their description file, Template objects record the nickname of the starting page, and an array called flat_page_order which might look like this for the application we were outlining above:

starting_page: everyones_first_page
flat_page_order:
  - state_humanities_narrative
  - state_humanities_last_page # this page is terminal
  - public_library_branches # this page instructs the system to follow with "generic_narratives"
  - everyones_first_page # everybody starts here, but the one after this depends
  - generic_narratives
  - generic_event_schedule 
  - generic_uploads  # this page is terminal

To wit: Everyone starts with everyones_first_page. If, on this page, they indicate they are a public library system, the second page will be public_library_branches; this page will always be followed by generic_narratives, via a next_page directive in its description, thus jumping back into the main page flow. If they are a state humanities council, the second page will be state_humanities_narrative, followed by state_humanities_last_page, where they will have an opportunity to submit—without ever jumping back into the default page flow.

More generally: the first page is given explicitly, via starting_page. When the user clicks Proceed, one of three things happens, in decreasing order of precedence: (a) the system polls the current page for any object that would like to redirect the user; if none, the system (b) sees if the page has an explicit next_page; if not, (c) the user is presented with the next page in flat_page_order.

The flat page order gives a single representation of page flows that is amenable to both human and machine consumption. It may get long, but that’s just about the only way it can grow in complexity.

The idea here is an analogy to the text segments of a binary program: When a CPU executes instructions, it reads them from a linear/1D representation in memory, and executes mostly linearly, punctuated by “nonlinear” events, like function entry, or (eventually) program exit. No matter how complex the program grows, its internal representation can be no more complex than a list. This captures both the dynamics and ease of representation we need in our (much simpler) case.

Jumping logic

The page jumping itself is accomplished by including a snippet of Ruby in a description file, to be eval‘d at runtime. (Yes, this sort of thing should be used with caution. But we already assume our description files are tested and secure.)

jump: 
  tester: '{ |answer| answer.condition? ? "page_x" : "page_y" }'

Specifically, it will be passed to lambda thus:

tester        = eval "lambda #{question.describe(:tester, :jump)}"
next_page     = tester.call(answer)

This lambda must return either a page nickname or nil, to defer. (Preference will be given next to the Page itself, and finally to the Template’s flat_page_order. Because answers store references to their documents, and documents to their users and candidacies, this mechanism is quite powerful. If things become too complicated to contain in a single line, refactoring is a breeze:

jump:
  tester: '{ |answer| PageJumpers.my_custom_jumper(answer) }'

Wrapup

This feature has been in production since day one, and has worked for everything we’ve thrown at it. The testing cases for it were (a) a Choose Your Own Adventure novel, and (b) evil tests like the GRE where correct answers early lead to more valuable questions later. Both can be implemented handily in Apply.