Viewing Data with XForms

Steven Pemberton, CWI Amsterdam

Version: 2019-02-05.

Introduction

I have a dataset of performances by a University choir stretching over 65 years or more. It is just a long list of concerts:

<concerts>
  <concert>...</concert>
  <concert>...</concert>
  <concert>...</concert>
   ...
</concerts>

A typical concert entry looks like this:

<concert>
   <year>1970</year>
   <month>5</month>
   <program>Duruflé − Requiem</program>
   <program>Mozart − Krönungsmesse</program>
   <where>Augustinuskerk te Amsterdam</where>
   <where>De Doelen te Rotterdam</where>
   <with>VU-orkest</with>
   <event>LP-opname: Mozart</event>
</concert>

I would like to be able to browse this data, but also easily answer questions like "How often have they performed something by Stravinsky?", "How often have they performed in the Concertgebouw", "What did they perform in 1960?", and so on.

Browsing

First to browse. We load the data:

<instance src="concerts.xml"/>

and then display it, which we'll do as a sort of table, one row per concert:

<group>
   <label>Concerts</label>
   <repeat ref="concert">
      ...
   </repeat>
</group>

For each concert, each group of entries (date, program, where, with, and event) will be displayed as a column by making the CSS display property of each group inline-block, so that the groups are displayed next to each other:

<group class="concert">
   <output class="when" value="concat(year, '-', month)"/>
   <group class="program">
      <repeat ref="program"><output class="line" ref="."/></repeat>
   </group>
   <group class="where">
      <repeat ref="where"><output class="line" ref="."/></repeat>
   </group>
   <group class="with">
      <repeat ref="with"><output class="line" ref="."/></repeat>
   </group>
   <group class="event">
      <repeat ref="event"><output class="line" ref="."/></repeat>
   </group>
</group>

Note that this doesn't require the sub-elements of the concerts to be in this order, or even adjacent; it just selects all sub-elements called program (for example) within a concert element, and displays them together.

Adding a row of titles above this using the same CSS class for the header titles ensures that they line up:

<group class="header">
   <output class="when" value="'when'"/>
   <output class="program" value="'what'"/>
   <output class="where" value="'where'"/>
   <output class="with" value="'with'"/>
   <output class="event" value="'why'"/>
</group>

which, with some suitable CSS, looks like this:

Source

We may as well fancy up the heading a little bit, and replace:

<label>Concerts</label>

with

<label>Concerts, <output value="min(concert/year)"/> - <output value="max(concert/year)"/>
</label>

Answering Questions

Nothing fancy, we'll just do a search-machine-like search on the data.

We create an instance for the search string:

<instance id="search">
   <data xmlns=""><q/></data>
</instance>

and an input control for it:

<input incremental="true" ref="instance('search')/q">
   <label>Search</label>
</input>

That's XForms 1.1. The newer XForms 2 allows you to say:

<input incremental="true" ref="instance('search')/q" label="Search"/>

The incremental attribute means that the value will be updated each time the input changes, meaning the search is done for every change.

Now the only other thing is to restrict the displayed concerts to those that match the search string.

Whenever you have a sequence of items, such as with ref="concert" above, you can select a subset of them using a filter: ref="concert[condition]", selecting only those concerts that match the condition. This is just standard XPath, since the content of the ref attribute is just an XPath expression.

If we want only the concerts from 1975, we can write:

concert[year=1975]

If we want only the concerts that contain a piece composed by Bach, we write:

concert[contains(piece, 'Bach')]

If we want the concerts where any field contains "Amsterdam", we write

concert[contains(*, 'Amsterdam')]

In fact we can even say:

concert[contains(., 'Amsterdam')]

which means "any concert that contains the string "Amsterdam" anywhere (the "." means "self").

Finally if we want the concerts that contain the search string, we write

concert[contains(., instance('search')/q)]

So we replace:

<repeat ref="concert">

with that:

<repeat ref="concert[contains(., instance('search')/q)]]">

So this says "repeat over the concerts that contain the search string".

Here is the result. You can try it – type a year, or a composer, or whatever:

Source

You may have noticed that the search string has to match exactly with the content. Let's make it so that the search is case-insensitive.

The function lower-case returns the lower-case version of its parameter, so that if q is Mozart, then lower-case(q) is mozart. (The lower-case function is from XForms 2. Previous versions use translate(q, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') to achieve similar effect).

So in the repeat over the concerts, we replace

contains(., instance('search')/q)

with

contains(lower-case(.), lower-case(instance('search')/q))

which checks if a lower-case version of the element content contains the lower-case version of the search string.

Here it is:

Source

There you have it. A useful application; about 35 lines of XForms.

Making it More General

Actually I have lots of similar files: the record of all computers I have owned, my bank accounts, the list of all talks I have given. These all have the same properties: a series of the same element, each with the same sub-elements.

I don't have to rewrite the above XForm each time I want to browse such files. I can make it general.

The trick is that in place of

<repeat ref="concert[contains(lower-case(.), lower-case(instance('search')/q))]">

to use

<repeat ref="*[contains(lower-case(.), lower-case(instance('search')/q))]">

which selects all top-level elements, regardless of what they are called. Then you do the same within each row:

   <group class="row">
      <repeat ref="*">
         <output class="field" ref="."/>
      </repeat>
   </group>

To supply the column headers we can just use the names of the elements, by taking the first row, and instead of outputting the value of the element, outputting its name:

<group class="header">
   <repeat ref="*[1]/*">
      <output class="field" value="name(.)"/>
   </repeat>
</group>

And right at the beginning, we can use the same trick to supply the top-level heading, by outputting the name of the root element:

<group>
   <label><output value="name(/*)"/></label>

Putting this together:

<group>
   <label><output value="name(/*)"/></label>
   <group>
      <input incremental="true" ref="instance('search')/q" label="Search (case-insensitive)"/>
      <output value="count(*[contains(lower-case(.), lower-case(instance('search')/q))])"/> found
   </group>
   <group class="header">
      <repeat ref="*[1]/*">
         <output class="field" value="name(.)"/>
      </repeat>
   </group>
   <repeat ref="*[contains(lower-case(.), lower-case(instance('search')/q))]">
      <group class="row">
         <repeat ref="*">
            <output class="field" ref="."/>
         </repeat>
      </group>
   </repeat>
</group>

You'll see I have also added an output saying how many search results have been found:

<output value="count(*[contains(lower-case(.), lower-case(instance('search')/q))])"/> found

Here it is in action:

Source

Since this works with many different documents, we can now add a control to load different ones. We'll add an extra field to the search instance, to hold the name of the document we want to load:

<instance id="search">
   <data xmlns=""><q/><file/></data>
</instance>

and a control to select a different document:

<select1 ref="instance('search')/file"><label></label>
   <item><label>Talks</label><value>talks.xml</value></item>
   <item><label>Computers</label><value>computers.xml</value></item>
   <action ev:event="xforms-value-changed">
      <send submission="load"/>
      <setvalue ref="instance('search')/q"/>
   </action>
</select1>

Whenever the value changes, the following submission is activated, which loads the selected file, and replaces the data instance with it:

<submission id="load" resource="{instance('search')/file}" serialization="none"
            replace="instance" instance="data"/>

For this we need to add an id to the data instance:

<instance id="data" src="talks.xml"/>

Additionally when the value changes, the search string is reset.

Here it is in action:

Source

Adding more documents just involves adding new <item> elements to the <select1> control.

Even though this version of the application is more general, it is actually the same size as the first one.

It's worth pointing out that unlike the first version, this version requires each row of the data to have the same structure: all the sub elements present, and in the same order.