Steven Pemberton, CWI Amsterdam
Version: 2022-01-28.
Lists are everywhere:
We're going to develop a list manager app in XForms.
We'll start very simply, the data will just be a list of items:
<list name="Shopping"> <item>Bananas</item> <item>Apples</item> <item>Milk</item> <item>Yoghurt</item> </list>
We can either put the data directly in the application:
<instance> <list name="Shopping" xmlns=""> <item>Bananas</item> <item>Apples</item> <item>Milk</item> <item>Yoghurt</item> </list> </instance>
or in a file, which we include:
<instance resource="list.xml"/>
We can display this easily:
<group> <label><output ref="@name"/></label> <repeat ref="item"> <output ref="."/> </repeat> </group>
Which, with suitable CSS, looks like this:
We want to be able to edit the list, so we'll change the outputs into inputs:
<group> <label><output ref="@name"/></label> <repeat ref="item"> <input ref="."/> </repeat> </group>
which gives:
We also want to be able to add new items. There are several ways to do this,
but the simplest is to hit [return
] when on an item to add a new
item underneath.
When you hit return
in an input, it receives the event
DOMActivate
. We just need to respond to that. Here's a first
version:
<group> <label><output ref="@name"/></label> <repeat ref="item"> <input ref="."> <action ev:event="DOMActivate"> <insert ref="."/> </action> </input> </repeat> </group>
The response to the event is to insert a new element.
An insert
takes a list of values, and with no other attributes,
duplicates the last element of the list, appending it after the list. In this
case the list we have selected consists of just the single item we are looking
at, so duplicates it and appends it after it. Like this (try it):
However, we don't want to duplicate the current element, but insert a blank element.
So we create an instance containing a blank element:
<instance id="blank"> <list xmlns=""> <item/> </list> </instance>
and alter the insert
to this:
<insert ref="." origin="instance('blank')/item"/>
which still inserts after the current element, but inserts the item from
origin
instead.
Giving this (try it):
By the way, the box around the items is only the default styling for an input element: we can change that if we want with a bit of CSS. While we're doing it, we'll change the output for the name of the list into an input as well:
When you add a new item, you want of course to be positioned on it in order
to type it in. We do that with a setfocus
action, which moves the
focus in this case to the newly-created element:
<group> <label><output ref="@name"/></label> <repeat ref="item"> <input ref="." id="I"> <action ev:event="DOMActivate"> <insert ref="." origin="instance('blank')"/> <setfocus control="I"/> </action> </input> </repeat> </group>
(as of this writing, there is a bug in the implementation being used for these examples so that this only works if you hit return more than once).
Next thing we need to be able to do us delete items. Again, there are several ways to do it. What we'll do here is add a trigger before every item, that if activated deletes the current element:
<repeat ref="item"> <trigger label="X"> <action ev:event="DOMActivate"> <delete ref="."/> </action> <hint>Delete</hint> </trigger> <input ref="." id="I"> <action ev:event="DOMActivate"> <insert ref="." origin="instance('blank')"/> <setfocus control="I"/> </action> </input> </repeat>
We can add appearance="minimal"
to the trigger so that it
doesn't look like a button, but still acts the same way:
<trigger appearance="minimal" label="X"> <action ev:event="DOMActivate"> <delete ref="."/> </action> <hint>Delete</hint> </trigger>
One last thing we should do is prevent the very last item being deleted, because if that happens, you can't add any more. A simple approach is to only let the delete work if there is more than one item:
<delete ref="." if="count(../item) > 1"/>
What we can do though, is instead of deleting the last entry, blank it out:
<setvalue ref="." if="count(../item)=1"/>
There's no point in creating a new list if we can't save it, so we'll do just that.
We define a submission for the data, which says where to put it and how:
<submission method="put" resource="/saves/list.xml" replace="none"/>
The replace="none"
has the effect of ignoring anything returned
from the server.
Now we add a control to the application to save the data:
<submit label="save"/>
It would be good to let the user know if the data needs saving or not.
We'll keep track of that by recording whether any data has been changed. First a value to record it:
<instance id="changed"> <changed xmlns="">no</changed> <instance>
There are three ways the data can change:
These three cases generate different events.
In the insertion and deletion cases the events are dispatched to the
instance containing the relevant item. So we'll add an id
to that
instance:
<instance id="list" resource="list.xml"/>
and then listen for events being sent to it. If either is received, we set
changed
to yes:
<action ev:event="xforms-insert" ev:listener="list"> <setvalue ref="instance('changed')">yes</setvalue> </action> <action ev:event="xforms-delete" ev:listener="list"> <setvalue ref="instance('changed')">yes</setvalue> </action>
(Actually, an action
with only one action under it this can be
shortened if you want, by putting the ev:
attributes on the
contained action:
<setvalue ref="instance('changed')" ev:event="xforms-insert" ev:listener="list">yes</setvalue> <setvalue ref="instance('changed')" ev:event="xforms-delete" ev:listener="list">yes</setvalue>
)
For the case of an item being edited, the event
xforms-value-changed
is sent to any control using the value. In
our case, that is the input
that already has an
id
:
<setvalue ref="instance('changed')" ev:event="xforms-value-changed" ev:listener="I">yes</setvalue>
but you can also put it within the input
element, just as we
did for DOMActivate
, to give the same effect:
<input ref="." id="I"> <setvalue ref="instance('changed')" ev:event="xforms-value-changed">yes</setvalue> <action ev:event="DOMActivate"> <insert ref="." origin="instance('blank')"/> <setfocus control="I"/> </action> </input>
So now we have captured the fact that the data has been changed. Now to use that information.
First we say that the information is only relevant if the value is
yes:
<bind ref="instance('changed')" relevant=". = 'yes'"/>
If we now attach a reference to it to the submit
button, the
button will only appear when the data is changed:
<submit ref="instance('changed')" label="save"/>
Try it, make a change:
(Actually I did one other thing, I added an attribute to the
input
elements:
<input ref="." id="I" incremental="true">
This makes changes to the value as you type them, rather than waiting until you move away from the input)
One other thing we need to do is mark the data as unchanged once the data has been saved.
After the submission of the data has been successful, the event
xforms-submit-done
is sent to the submission
element.
So we listen for that, and reset the changed
value accordingly:
<submission method="put" resource="/saves/list.xml" replace="none"> <setvalue ref="instance('changed')" ev:event="xforms-submit-done">no</setvalue> </submission>
Now that we know when the data needs to be saved, we can even arrange for it to be automatically saved at regular intervals.
At every point where we set changed
to yes
, if its
value is no
then we set a timer first. When the timer goes off, if
changed
is still yes
(that is, if the user hasn't
saved the data in the meantime), we save the data ourselves.
Rather than repeat this code everywhere we need it, we will gather it in one place, and dispatch an event to make it happen.
So in the three places where we have something like this:
<setvalue ref="instance('changed')" ev:event="some event">yes</setvalue>
we will now use
<dispatch name="CHANGED" ev:event="some event" targetid="M"/>
and put the id
of M
on the
model
element:
<model id="M" xmlns="http://www.w3.org/2002/xforms">
Now we have to catch and respond to the new event, by setting the timer
(delay="5000"
means 5 seconds), and then setting
changed
to yes
:
<action ev:event="CHANGED"> <dispatch name="TIMER" delay="5000" if="instance('changed')='no'"/> <setvalue ref="instance('changed')">yes</setvalue> </action>
and then catch and respond to the timer when it goes off, by saving the data if necessary:
<action ev:event="TIMER"> <send if="instance('changed')='yes'"/> </action>
(send
just initiates the default submission).
See it in action here - try changing a value, and waiting the 5 seconds for
it to be saved - you'll see the save
button appear on the change,
and disappear after the save:
Having saved the data, then it is really a good idea to restore it when the application restarts.
Ideally we would initialise the instance data with:
<instance resource="/saves/list.xml"/>
which would be fine, except for the first time we run the application, when we won't have saved anything yet.
So what we do is initialise the data for the first time the application gets run:
<instance id="list"> <list name="list" xmlns=""> <item>your data here</item> </list> </instance>
(we could put the default in a file as well if preferred and load it from
there), and then on start up, try to load the saved data: this
submission
element says where to get the data, and use it to
replace the instance called list
. If the data doesn't exist, an
xforms-submit-error
will be dispatched, which we can catch, and
ignore (and so the instance stays with the initial data):
<submission id="init" resource="/saves/list.xml" replace="instance" instance="list"> <action ev:event="xforms-submit-error" ev:propagate="stop" ev:defaultAction="cancel"/> </submission>
On start-up we cause the submission to be processed:
<action ev:event="xforms-ready"> <send submission="init"/> </action>
So we have a fairly comprehensive list application, that allows you to edit, add, and delete items, which get saved automatically at intervals, and automatically reloaded on start-up.
In part 2, we will add to this.