OnlineTester: An Arcane Adventure. Part 4: More Widgets, AJAX requests

On the menu for the final part of the series are three widgets that make up the meat of the application, i.e. the part with which the students will be interacting the most.

Reconnecting

The widgets mirror the organization of the model classes. Let's recall their layout:

OTApplication -1---1> OTTest -1---*> OTAufgabe -1---*> OTFrage

The application handles one test, which is made of several exercises, each of which can contain multiple questions. Accordingly we have:

OTApplication -1---*> OTTestWidget -1---*> OTAufgabeWidget -1---*> OTFrageWidget

The application handles many test widgets (one for every student), which is made of several exercise widgets, each of which can contain multiplie question widgets.
Every widget uses its contents method to present the model data in a suitable way and has callbacks and/or action methods for handling user input as necessary.
In the previous post, we stopped at the application view method fragebogen, which built the current user's test widget into the current page:

fragebogen [ 
  <category: 'views'>
  ^ [ :e | ... e build: ( self getTestWidgetFor: self user ) ...
]
getTestWidgetFor: aNameString [ 
  ^ ... OTTestWidget for: aNameString on: self test ...
]

Growing the widget tree

As described above, our test widgets need to keep track of a few things, so the class definition starts like this:

Iliad.Widget subclass: OTTestWidget [ 
  | username test aufgaben |
  OTTestWidget class [ 
    for: aNameString on: anOTTest [ 
      ^ self new test: anOTTest; username: aNameString
    ]
  ]
  test [ ^ test ]
  test: anOTTest [ 
    test := anOTTest.
    aufgaben := test aufgaben collect: [ :a | OTAufgabeWidget on: a ].
  ]
  username [ ^ username ]
  username: aNameString [ username := aNameString ]
  aufgaben [ ^ aufgaben ]

When the test widget is initialized, the test object itself has been fully initialized and won't change anymore, so we can collect the required exercise widgets right when we assign the test.
The exercise widgets follow this pattern:

Iliad.Widget subclass: OTAufgabeWidget [ 
  | aufgabe fragen |
  OTAufgabeWidget class [ 
    on: anOTAufgabe [ 
      ^ self new aufgabe: anOTAufgabe
    ]
  ]
  aufgabe [ ^ aufgabe ]
  aufgabe: anOTAufgabe [ 
    aufgabe := anOTAufgabe.
    fragen := aufgabe fragen collect: [ :f | OTFrageWidget on: f ].
  ]
  fragen [ ^ fragen ]

The question widgets are a bit different, since they don't maintain links, but "real" state:

Iliad.Widget subclass: OTFrageWidget [ 
  | frage antwort done |
  OTFrageWidget class [ 
    on: anOTFrage [ 
      ^ self new frage: anOTFrage
    ]
  ]
  frage [ ^ frage ]
  frage: anOTFrage [ frage := anOTFrage ]
  antwort [ ^ antwort ]
  beDone [ done := true ]
  isDone [ ^ done == true ]

The question widgets reference the original question and keep track of two things: The answer that is supplied by the student, and whether the widget has actually been used once. This enables us to see which questions were left at their default answer and which ones were actually acted upon.

Walking the contents

Now that the data is hooked up, let's stroll through the contents methods of our widgets.

Iliad.Widget subclass: OTTestWidget [ 
  contents [ 
    ^ [ :e | 
      e div id: 'titel'; h1: test name; h2: test datum.
      e div 
        id: 'bearbeiter'; html: 'Name: '; text: self username; break; 
        html: 'Klasse: '; html: test klasse.
      e div class: 'oben'; html: test oben.
      self buildTabsOn: e.
      e div class: 'unten'; html: test unten.
    ]
  ]

Basically, we put an "official" header on the page and display some text above and below that is intended to be always visible. Building the embedded exercise widgets is farmed out to a separate method:

  buildTabsOn: anElement [ |bar body|
    body := anElement div id: 'aufgaben'.
    bar := body unorderedList.
    ( 1 to: aufgaben size ) do: [ :i |
      bar listItem anchor 
        href: '#tab-' , i displayString; 
        html: i displayString
    ].
    aufgaben doWithIndex: [ :a :i |
      body div id: 'tab-' , i displayString; build: a
    ].
    anElement script: '$(function() { $("#aufgaben").tabs(); });'.
  ]

First we prepare links to each exercise div, then we render the contents of all exercises. The corresponding parts are linked via the identical contents of the links' href-attributes and the exercise divs' id-attributes. The one-liner script at the end works jQuery magic to produce the nice looking tabs from the otherwise quite ugly (but functional) display.
The exercise contents are trivial:

Iliad.Widget subclass: OTAufgabeWidget [ 
  contents [ 
    ^ [ :e | |d|
      d := e div class: 'aufgabe'.
      d div class: 'vorspann'; html: aufgabe text.
      fragen do: [ :f | d build: f ].
    ]
  ]

Every exercise shows the introductory text and then delegates to its questions. The question widgets are a bit more involved:

Iliad.Widget subclass: OTFrageWidget [ 
  contents [ 
    ^ [ :e | |form group|
      group := self session nextId.
      e div class: 'frage'; html: frage text.
      form := e form class: 'antwort'.
      frage antworten doWithIndex: [ :a :i| |id b|
        id := self session nextId.
        b := form radioButton
          id: id; name: group;
          action: [ self postAnswer: i to: frage ].
        i = self selection ifTrue: [ b beSelected ].
        form label for: id; html: a.
        form space.
      ].
      form span class: 'wert'; html: frage wert printString , ' P.'.
      e div class: 'break'.
    ]
  ]

We use unique ids to give the radio buttons a button group behavior, i.e. only one of them can be selected at any time. The radio buttons themselves are a tiny target for a nervous mouse, so we use the label tag to give the student something easier to hit: Clicking on the label, shifts the input focus to the associated (once again via generated unique id) input element, causing the radio button to be selected.
The pre-selection of the default element happens via the following method, which uses the answer stored in the widget or the default for the question (which in turn might come from its associated exercise or even from that one's associated test).

  selection [ 
    antwort ifNil: [ antwort := frage vorgabe ].
    ^ antwort
  ]

And here we also have (all of) the action we need to take. You did notice the action: block of the radioButtons, did you?

  postAnswer: anInteger to: aFrage [ 
    antwort := anInteger.
    self beDone.
    self markDirty.
 ]

When the callback gets evaluated, the value associated with the radio button is stored into the widgets antwort instance variable and the widget is marked "dirty", which notifies the backend of a change in that widget, causing it to be redrawn upon the next opportunity.

The missing link

"When the callback gets evaluated", he said, glossing over the missing submit button of the form wrapping the radio button group. I had a reason, it's called "for the sake of presentation".
Adding a submit button to every single question form would be possible, but look ugly. Also, students would be prone to forget clicking on that one, too. Wrapping everything in a single form would would get you out of that frying pan, but put you into the fire of potentially losing all your work when the browser window is closed before all of the answers are transmitted in one giant burst to the server.
We want the answers to reach the server as soon as possible, which means that we are going to set up some javascript to hook into the click event and use an asynchronous request to the server to transmit the answer. This has the additional advantage that we can add a "teacher module" later on which shows the current "state of the tests".
So how is this done? Looking through the jQuery docs, you'll end up with something like (untested, from memory)

$( "div.aufgabe input:radio" ).change( 
  function() {
    Iliad.evaluateFormAction(jQuery(this).closest("form"));
  }
);

If this tiny piece of code is run after all of the exercises have been put on the page, it will make every radiobutton inside our exercise divs call the form submit action of its enclosing form. Assuming you searched carefully enough, you might have read the excellent tutorials over at learningjquery.com, where exactly this solution is discussed and found to be insufficient in case of dynamically added elements.
Which is exactly the situation that we are in. Remember the markDirty call in our action method, after the form is submitted, Iliad knows that this question widget needs to be refreshed, which in Iliad means that its XHTML code is replaced with new code reflecting the changed state. So we have an element that is added after the script above has been run, and the newly added radio buttons won't submit any more updates to the server.
But lucky for us, the tutorial (we did mention it was excellent) gives a very nice solution to this problem, called event bubbling. The javascript code is more elaborate but has the advantage that the event handling is now delegated to an element that will not be replaced once it has been added to the page. In our case, the exercise divs fit the bill: The tab mechanism just changes the visibility, but does not add or remove them from the DOM tree.

Iliad.Widget subclass: OTTestWidget [ 
  contents [ ...
     self buildTabsOn: e.
     e script: '
       $( "div.aufgabe" ).change( 
         function(event) {
           var target = $(event.target);
           if ( target.is( "input:radio" ) ) {
             Iliad.evaluateFormAction(jQuery(target).closest("form"));
           }
         }
       );
     '.
     e div class: 'unten'; html: test unten. ... 

And now you have it. The student clicks on the label of a radio button, the input focus is shifted to the radio button, the choice is made, the change event is caught in the question's exercise div, the question's form is submitted, the widget state is updated, the widget is re-rendered and if the student has done her homework, the answer might even be correct.

Coda

That's it, folks. Surprisingly few lines for a nice and fast online test. Standing on the shoulders of giants makes really does give an advantage.

Since you actually finished the series, I suppose you actually did administer the test successfully. :-)

I guess this is among the first real-world uses of both gst and Iliad; thanks for that, it's both pleasant to hear and a good data point! And Nico gets both the kudos for Iliad, and many thanks for the indirect contributions to making GNU Smalltalk itself better.

Paolo

Thank you very much Paolo!

Nico

User login