XLS form- Repeat select_one question until "A" is selected

Here's how we do this style of thing in my organization:

  1. You have a repeat group.
  2. You have an exit condition. In this case, your exit condition is "the user selects 'A'."
    3a. If the user does not select 'A', you have a note on the next screen (still within the same repeat group) that says "there is more data remaining to enter. please proceed forward to the next screen and select 'add group'."
    4b. If the user does select 'A', the next screen will be a note saying "you have finished entering data. proceed forward and select 'do not add'."
  3. If the user accidentally selects "add", you can have an "error screen" at the beginning of the next repeat which will be activated only if 'A' was selected in the previous repeat. By "error screen", I mean a group consisting of two things: (i) a note type question that is marked as required. Then you also have (ii) an acknowledge type question or a select_one with a single response. The note will instruct the user to press down on the answer option and select "delete group". I suppose you could actually implement this with just (ii) and an impossible constraint set on that question.

I hope this makes sense. Please let me know if any part of this explanation is not clear.

1 Like

Basically you have 2 options:

  1. An (infinite) repeat group. This requires the user to explicitly hit "Add Group"; there's currently no way to add a group programmatically, and for reasons described earlier I'm not even sure this feature would be feasible to add... But as described by @Joseph_E_Flack_IV there's a lot you can do with this approach to prompt and guide the user.

  2. Manually define a (fixed) number of desired groups in the form, and use relevant='...' show/hide logic to display each depending on the answer to the previous. This will be tedious to define for 600+ potential groups, and you still have a fixed maximum.

Sorry, I know neither of them ideal for your situation :slightly_frowning_face: , but I think the first approach probably best fits your actual usecase.

1 Like

Just to add a note that i think this is probably a common need among some users of ODK, essentially an infinite repeat loop until a certain "break" option is selected.
From my limited knowledge of other programming languages, infinite loops with a kind of "break" option is one of the basics I've learned.
Is there actually any feasibility of this being a feature of an ODK form? @Xiphware do you know?
@Joseph_E_Flack_IV your option is kind of what I've done in the past. I'm not sure if others also use something similar? Or if I've missed something more obvious?
For me, it was needed to do something like stock management at both a warehouse and in a rural pharmacy. When you're doing inventory counts you often can't predefine how many items you want to count. And without the infinite loop it makes the form a bit clunky for the user.
Hoping I haven't misunderstood, but happy to be educated a little further on this use case, it's really interesting!
Janna

1 Like

I can certainly understand the usecase. However, you have to bear in mind that XForms is not in actuality an (iterative) 'program' running through a series of commands - like Java, C, Basic, ... - but rather a form definition language that describes the layout and behavior of an interactive form. That Collect presents you with a sequence of questions, which you step through one by one till you reach the end of the form - is merely a consequence of how Collect goes about presenting the entire form document. Other XForm interfaces, eg Enketo, presents you with the entire form at once, wherein you can answer questions anywhere and in any order you like. So in this context, a 'repeat {...} until X=Y' doesn't really make sense anymore - I could jump in and out of this loop answering questions all over the place! :slight_smile:

@LN After a bit of digging, what might be possible, somehow (I haven't worked thru all the specifics necessary...) is leverage XForm triggers - specifically an insert action trigger - to somehow programmatically add a repeat group into a form. As defined in the W3C spec these triggers still have to be explicitly fired by the user. But maybes there's some wiggle room here to get creative and have them also activate (once) as a consequence of an XPath evaluation... Here's a concrete example - although again, this trigger has to be exlicitly fired by the user via the UI:

<repeat id="lineset" nodeset="/my:lines/my:line">
  <input ref="my:price">
    <label>Line Item</label>
  </input>
  <input ref="@name">
    <label>Name</label>
  </input>
</repeat>
        
<trigger>
  <label>Insert a new item after the current one</label>
  <action ev:event="DOMActivate">
    <insert nodeset="/my:lines/my:line" at="index('lineset')"
      position="after"/>
    <setvalue ref="/my:lines/my:line[index('lineset')]/@name"/>
    <setvalue ref="/my:lines/my:line[index('lineset')]/price">0.00</setvalue>
  </action>  
</trigger>

There's certainly sufficient expressiveness in an insert action trigger to accomplish the desired result. Its then just a matter of figuring out how to fire it (non-standardly); perhaps something like:

<trigger>
  <label>Insert a new item after the current one</label>
  <action ev:event="DOMActivate" odk:activate="/my:lines/my:line[index('lineset')-1]/@name !=''"/>
  ...
  </action>  
</trigger>

(hopefully you get my drift...)

2 Likes

To achieve something like this, you can define the repeat count in terms of your desired condition.

This indefinite-repeat XLSForm shows an example of that technique. The key part is that the count is set to

if (${count} = 0 
    or (${person}[position()=${count}]/choice != '' and ${person}[position()=${count}]/choice != 'a'), 
    ${count} + 1, 
    ${count})

edited 6/2020 to use ${} for repeat reference instead of XPath path

This says that if there are currently no repeats or your ending condition (user has selected "a") is not met, we should add one to the desired repeat count. If there are repeats already and the user has selected "a", we should keep the desired repeat count the same which means no new repeats are created. This is similar to the relevant idea @Xiphware was suggesting but it is fully dynamic. You could have any other test in there like age being greater than 50, "a" being selected AND age being greater than 50, etc.

${person}[position()=${count}]/choice uses XPath notation to identify the choice selected in the last iteration. You could also do this with indexed-repeat if you're more comfortable with it (I can never remember how it works!). The idea is that ${count} represents the number of repeats added so far and is also the index of the latest iteration.

If you think a user could go back and modify one of their answers, you'll want to use the technique described at https://github.com/opendatakit/docs/issues/726 so that if a user selects A in a previous repeat, the ones after it aren't shown.

I've also used the technique @Joseph_E_Flack_IV describes and although it may require a bit more thinking on the enumerators' part, it tends to work well.

Hopefully this addresses the use case generally enough that no specification additions are needed!

5 Likes

I stand corrected, and all the wiser for it! :blush: . I didnt realize the ODK repeat jr:count extension was in fact dynamic [I figured it was statically evaluated when the form was first rendered]. That's totally awesome!! :heart_eyes:

So yes, @dmtk41, you should be able to use @LN's repeat count trick to dynamically add additional repeat groups as needed.

[now I gotta go play with this in Enketo :thinking: ...]

2 Likes

Question: when is the repeat count re-evaluated by Collect? Every refresh cycle, eg every time you answer any question anywhere in the form?

Asking 'cause wont /indefinite-repeat/person[position()=${count}]/choice != 'a' evaluate to true for the newly created and empty repeat instance? Since person[x]/choice will be null, and null != 'a', wont it keep adding a new repeat instance every refresh? ...

1 Like

@LN 's way work perfect as my expect. Thank you all.

2 Likes

Glad it works for you, @dmtk41!

@Xiphware see https://opendatakit.github.io/xforms-spec/#creation-removal-of-repeats for the jr:count documentation. In particular, in B:

B. Using the jr:count attribute on the element. E.g. see below for the use of jr:count to automatically create 3 repeats for the above form. The value could also be a /path/to/node and clients should evaluate the number of repeats dynamically (Note: It is problematic to implement this in a truly dynamic fashion, i.e. when the value changes, to update the number of repeats).

I don't know the history of that language and it's unfortunately vague but the way it has been interpreted in JavaRosa/Collect is that jr:count is considered once after each existing repeat is filled.

This is well-suited for a UI in which the user advances through a form but I think it can also work for a UI in which all repeats are shown on the same screen. One option would be to let the user drive adding repeats with some kind of + or "add" UI element and disable that UI element if the jr:count expression evaluates to a number less than or equal to the current repeat count. It would dynamically become enabled as soon as the expression evaluated to a number greater than the current repeat count.

You are correct that if the user is not involved in adding repeats and instead the system both adds repeats and evaluates the expression when a repeat is added, there would be an infinite loop. You could probably add a null check on the node to prevent that, though, so I'm not sure there actually is an issue with a fully dynamic system.

2 Likes

Hi @LN,
In your indefinite-repeat XLSForm example, what the code should be if the END condition is "a" is selected or age <50.

It should be something like

if (${count} = 0 or 
  (/indefinite-repeat/person[position()=${count}]/choice != 'a' 
  and /indefinite-repeat/person[position()=${count}]/age >= 50), 
${count} + 1, 
${count})

We're defining a continuing condition so we need to reverse your ending condition. You want to end when "a" is selected or age < 50 so the negation is to continue as long as "a" is not selected AND age is greater than or equal to 50.

If you want to know more about why the or becomes an and, you can read up on De Morgan's laws!

2 Likes

Got it. Thank you @LN.

1 Like

Oh @LN @LN ... the face that launch'd a thousand interviews!

Thank you. This is terrific...we've just implemented it to do an end run around the ugly 'New Group?' screen.

(That screen reminds me of the irritating dog named Chester in the old Warner Bros cartoons: https://www.youtube.com/watch?v=UVNHcob3oJg "Hey, Spike...do you wanna start a new group? Do ya? Do ya? You want I should start a new group for ya?" )

We just added a question at the bottom of the repeat group to ask whether there are additional flats in the building and if yes, we go thru the loop again (without repeating the initial GPS building location capture) and if not, we drop gracefully out of the loop using Hélène's suggestion.

Many thanks!
-Dale and @marykaytrimner

2 Likes

Haha, I hear you. I always try to work around it in survey design and there's generally a way. I'm glad you were able to get to something you like!

I've filed a documentation issue at https://github.com/opendatakit/docs/issues/1042 to make these approaches easier to find.

1 Like

Wow, that works great. One remark:
When I add the full path /indefinite-repeat/..... this is not working.

However, if I set it the way you showed in the spreadsheet on google docs, it works flawless.
Now that it works, I will think if it is worth to have decrease option by the user, for exemple if he changes the previously "do you want to repeat?" question.
Thank you.

1 Like

We made some changes to the XLSForm converter a few months ago and now the XLSForm filename isn't used in the path by default anymore. Instead, /data/ is always used. Indeed, what I did in the sample form lets the form conversion fill out the group path and thus always works:

if (${count} = 0 or (${person}[position()=${count}]/choice != '' and ${person}[position()=${count}]/choice != 'a'), ${count} + 1, ${count})

Thanks for pointing that out -- I'll make an edit to the post marked as the solution to reduce confusion.

HI @LN et al.,

I'm writing to ask about a small wrinkle in this elegant example...in our form the question that triggers adding another repeat group is this: Was that the final household in this multi-family dwelling? (A response of `no' means we should add another group (household) and 'yes' means we should drop out of the repeat and get ready to finish the form; 'yes' is analogous to stopping condition 'a' from your example.)

The problem we run into is when someone MISTAKENLY says 'no', thinking they need to do an additional interview. They tap 'no' and swipe forward; the repeat counter increments and a new group is added. If they swipe back and change 'no' to 'yes' ( 'b' to 'a' ) and swipe forward again, presumably the repeat count logic does not increment the counter yet again, but it's too late...aargh...repeat count has already been incremented after the first 'b' and swipe-forward. So the form takes the interviewer on an extra faux trip thru the repeat...(at which point some of them get creative in trying to save and abort the form).

Is there a sweet way to decrement the repeat counter if the user swipes back into the bottom of the previous repeat and changes 'b' to 'a'?

Many thanks,
-Dale & @marykaytrimner

I see now that @RafaelKluender also asked about users changing the repeat question.

As far as I know, there is no way to do this programmatically. There should be conceptually but some quirks of the evaluation engine currently make them impossible:

  • When repeat count is decreased, repeat instances are not deleted. This was a deliberate choice to avoid accidental data loss. The general recommendation is to use a group inside the repeat with relevance testing against the target count to hide repeat instances that are no longer desirable. This means that if the count is adjusted up again, the data will not be lost. This approach doesn't work in this case because of the reasons below.
  • Attempting to use references to values in other repeat instances in a current repeat instance is incorrectly considered a reference cycle.
  • When a value inside of a repeat instance updates, values in other repeat instances are not recomputed. This limitation has always been there and is a performance tradeoff.
  • Relationships between relevance and calculations are incorrectly considered reference cycles.

The latter two probably should be addressed at some point but will likely not be a priority for some time. The first probably is not realistic to address.

I would train data collectors to long press on the label of a question that is in a repeat they accidentally added and then select "Remove Group". This is functionality that is kind of scary and should be used with care but with proper training I think it could solve the issue here.

Thank you very much for this context and suggestion, @LN. I think we're going to use your earlier suggestion and remove the HH loop from the form altogether and 'pull forward' the last lat/lon when needed. This appears to offer several advantages in terms of simplicity and less susceptibility to RAM problems. Sounds like we may be moving to a new phone model for our 2021 operations, as well. I'll write back and let you know how it goes. We, like others, are disappointed to not have a super stable deviceid field anymore, but we will make the best of the new solution.
Gratefully, -Dale

1 Like

This is one you'd have to take up with Google! I agree having a stable id has been very useful for Collect users. It's a pretty scary thing for any app creator to have access to, though, so it feels like the right move. Since you are going to own the devices you hand to data collectors, consider looking into solutions that will prevent app uninstalls. App id changes are only triggered by an app reinstall.

1 Like