Sunday, October 9, 2005

The OPS Blog Sample Application, Part III

Introduction

In this installment, we examine the new "comment preview" feature of the OPS Blog sample application.

OPS Blog Application Comment Preview Form

Most blog applications' comment pages work this way: you see the text of the blog post, followed by a series of existing comments to the article and a text area which you use to enter a new comment. When you are done writing your comment, you have two possibilities: preview or directly post the comment. The purpose of the preview is to allow you to see what your comment will look like once posted, without actually submitting it. This is useful because most comment editors are not WYSIWYG: they often allow you to enter some special markup, usually inspired by HTML, to format text, add hyperlinks, etc. Surprisingly, you still find blog applications without this feature, which is annoying as you may end up posting garbage if you do not master the particular blog application's formatting markup.

Here is the good news: with the advent of Ajax, used by the OPS XForms engine, the preview functionality doesn't actually need a "Preview" button that reloads entirely the comments page: you can very easily implement a dynamic preview updated as the user types. How does this work?

The Basics

In XForms, you bind controls to nodes of an XML document called an XForms instance. For example, the following XForms instance can contain the name of an author and the actual text of the comment:

  <comment>
      <author>Erik B.</author>
      <text>This blog post rocks!</text>
  </comment>

The author and text nodes are easily bound to XForms controls:

  <xforms:input ref="author" incremental="true"/>
  <xforms:textarea ref="text" incremental="true"/>

Now from there, displaying the comment outside the text area as it is entered simply consists in binding some xforms:output controls to the same nodes:

  <p>
      Comment written by: <xforms:output ref="author"/>
  </p>  
  <p>
      Text of the comment: <xforms:output ref="text"/>
  </p>

Presto! As you type, the text of the comment appears within HTML paragraphs. Note that to achieve this, we had to use the attribute incremental="true" on xforms:input and xforms:textarea, in order to ensure that the XForms engine frequently perform updates as the user types. Without this attribute, the xforms:output controls would only update upon the user changing focus between controls.

Handling Markup

This is not the end of the story. Writing plain text comments is good, but we want to be able to use some markup too. First, we need to choose a format. The easiest way to do this is to choose a very small subset of HTML, using for example the b, i and a tags. What happens if the user types those in the text area? Unfortunately, the output will actually contain the tags as well, and no HTML formatting will take place. This happens because the xforms:output control, by default, outputs plain text, including opening and closing brackets.

How do you get the xforms:output control to output HTML then? There is currently no standard to achieve this in XForms. OPS has chosen to use the appearance="xxforms:html" attribute:

  <p>
      Text of the comment: <xforms:output ref="text"/ appearance="xforms:xhtml"/>
  </p>

Type HTML markup in the text area, and your text will appear formatted with that markup in the preview section!

Adding an XML Service

There is a drawback to this: the user can write any markup he wants, even possibly dangerous one involving for example Javascript, which would then appear in other user's browsers when they view the comment. We would like to filter markup to make sure that we only accept a safe subset of HTML. We also would like to mark the text area invalid when there is a problem. Validation could be done using an xforms:bind with a constraint attribute containing an XPath 2.0 regular expression function. Another solution is to delegate the validation of comments to server-side code. How do you do this? With the replace="instance" feature of the xforms:submission element.

Using the xforms:submission element this way allows you to use XForms to call XML services. An XML service is an online services that you call by sending it a request containing XML, and from which you receive an XML response. This is a powerful yet easy to use feature of XForms. What kind of service do we need for comment preview? One that takes a raw comment containing user markup, and returns an indication of whether the comment is valid or not and possibly, a cleaned-up version of the comment with extra markup added or filtered. We can do this with two XForms instances, and an adequate xforms:submission element:

  <xforms:instance id="comment-request">  
      <comment>
          <text/>
      </comment>
  </xforms:instance>
  <xforms:instance id="comment-response">
      <comment>
          <text/>
      </comment>
  </xforms:instance>
  <xforms:submission id="format-comment-submission" method="post" action="/blog/format-comment" ref="instance('comment-request')" replace="instance" instance="comment-response"/>

How do we use the above submission? We simply detect xforms-value-change events sent by the text area control. Once this happens, we execute an event handler (declaratively, as always with XForms) which sends the format-comment-submission submission. Nodes of the comment-request instance are bound to the text area control, and nodes in the comment-response instance are bound to the xforms:output displaying HTML:

  <xforms:textarea ref="comment/text" incremental="true">  
      <xforms:action ev:event="xforms-value-changed">
          <xforms:send submission="format-comment-submission"/>
      </xforms:action>
  </xforms:textarea>

On the server, we must now react to the /blog/format-comment action. To do this, we write an XPL file responding to that path:

  <page path-info="/blog/format-comment" view="recent-posts/format-comment.xpl"/>

The XPL file is here implemented as a page view. This way, the XPL pipeline can simply output its XML response on its data output, and the default Page Flow Epilogue takes care of serialization, the process of sending the XML back to the web browser. It would have also been possible to implement it as a model pipeline, but then it would have had to take care of XML serialization itself.

The XPL pipeline receives an XML document on its instance input, which is simply the comment-request instance submitted by the XForms engine. Then it calls an XSLT stylesheet that:

  • Parses the comment as an XML fragment using the saxon:parse() function

  • Filters out all disallowed elements and attributes

The stylesheet either fails or returns a cleaned-up version of the user comment, which the XForms engine uses to replace the comment-response instance. In case of failure, we return an error document to the caller.

Validation

We are almost done with the preview feature! The last thing we would like to do now is mark the text area as invalid if the server-side code tells us that it is invalid. We can use the xforms:bind element with a constraint attribute, containing an XPath expression, to achieve this. But what should this XPath expression contain then? For example if it contains false(), the test area is always going to be marked as invalid. If it contains true(), it will always be marked as valid (unless some other constraint makes it invalid). We need to be smart and use the XForms submission response document in comment-response. Here, we decide on a convention: if the response does not contain any text under /comment/text, then we consider that the server is telling us the comment is invalid. The constraint is expressed as follows:

  <xforms:bind nodeset="text" constraint="normalize-space(instance('comment-response')/text) != ''"/>

There is a little problem with this: if the server tells us that the text area is now invalid, we can no longer submit the comment-request instance, because an instance but be valid to be submitted! So we need to add a third XForms instance, which is used strictly for the input and text area controls:

  <xforms:instance id="main">
      <form> 
          <comment>
              <name/>
              <text/>
          </comment>
      </form>
  </xforms:instance>

When we catch value change events, we copy the value from the main instance to the comment-request instance first, using the xforms:setvalue action. The text area element now looks like this:

  <xforms:textarea ref="comment/text" incremental="true" xhtml:rows="10" xhtml:cols="80">
      <xforms:action ev:event="xforms-value-changed">
          <xforms:setvalue ref="instance('comment-request')/text" value="instance('main')/comment/text"/>
          <xforms:send submission="format-comment-submission"/>
      </xforms:action>
  </xforms:textarea>

So we end up with three XForms instance:

  • main: controls requiring user input are bound to this instance

  • comment-request: this is the XML document to send to the XML service to validate comments

  • comment-response: this is the XML document returned by the XML service that validates comments

In the general case, it makes sense to separate the request and response documents from the main XForms instance.

That is it! The code is currently available in CVS and the nightly builds, with some additions such as a "comment date" field, but the ideas above remain valid.

1 comment: