Skip to content
This repository was archived by the owner on Nov 29, 2023. It is now read-only.

Commit 6bebe25

Browse files
Fix: multiple "static" itemset datalists in the same repeat (#995)
1 parent 75cc989 commit 6bebe25

File tree

4 files changed

+329
-12
lines changed

4 files changed

+329
-12
lines changed

src/js/itemset.js

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,16 @@ import events from './event';
2121
/**
2222
* This function tries to determine whether an XPath expression for a nodeset from an external instance is static.
2323
* Hopefully in the future it can do this properly, but for now it considers any expression
24-
* with a non-numeric (position) predicate to be dynamic.
24+
* it determines to have a non-numeric (position) predicate to be dynamic.
2525
* This function relies on external instances themselves to be static.
2626
*
27+
* Known issues:
28+
*
29+
* - Broadly, this function uses regular expressions to attempt static analysis on an XPath expression. This is prone to false positives *and* negatives, particularly concerning string sub-expressions.
30+
* - The check for a reference to an instance does not handle [non-`instance`] absolute or relative path expressions.
31+
* - The check for a reference to an instance does not account for expressions where that reference may *itself* appear as a sub-expression (e.g. in a predicate, or as a function parameter).
32+
* - At least the numeric predicate does not account for whitespace.
33+
*
2734
* @static
2835
* @param {string} expr - XPath expression to analyze
2936
* @return {boolean} Whether expression contains a predicate
@@ -132,8 +139,31 @@ export default {
132139
return;
133140
}
134141

135-
const shared =
136-
template.parentElement.parentElement.matches('.or-repeat-info');
142+
const templateParent = template.parentElement;
143+
const isShared =
144+
// Shared itemset datalists and their related DOM elements were
145+
// previously reparented directly under `repeat-info`. They're
146+
// now reparented to a container within `repeat-info` to fix a
147+
// bug when two or more such itemsets are present in the same
148+
// repeat.
149+
//
150+
// The original check for this condition was tightly coupled to
151+
// the previous structure, leading to errors even after the root
152+
// cause had been fixed. This has been revised to check for a
153+
// class explicitly describing the condition it's checking.
154+
//
155+
// TODO (2023-08-16): This continues to add to the view's role
156+
// as a (the) source of truth about both form state and form
157+
// definition. While expedient, it must be acknowledged as
158+
// additional technical debt.
159+
templateParent.classList.contains(
160+
'repeat-shared-datalist-itemset'
161+
) ||
162+
// It's currently unclear whether there are other cases this
163+
// would still handle. It's currently preserved in case its
164+
// removal might cause unknown regressions. See
165+
// https://en.wiktionary.org/wiki/Chesterton%27s_fence
166+
templateParent.parentElement.matches('.or-repeat-info');
137167
const inputAttributes = {};
138168

139169
const newItems = {};
@@ -162,7 +192,7 @@ export default {
162192
inputAttributes.disabled = 'disabled';
163193
}
164194
} else if (list && list.nodeName.toLowerCase() === 'datalist') {
165-
if (shared) {
195+
if (isShared) {
166196
// only the first input, is that okay?
167197
input = that.form.view.html.querySelector(
168198
`input[name="${list.dataset.name}"]`
@@ -195,7 +225,7 @@ export default {
195225
* Determining the index is expensive, so we only do this when the itemset is inside a cloned repeat and not shared.
196226
* It can be safely set to 0 for other branches.
197227
*/
198-
const index = !shared ? that.form.input.getIndex(input) : 0;
228+
const index = !isShared ? that.form.input.getIndex(input) : 0;
199229
const safeToTryNative = true;
200230
// Caching has no advantage here. This is a very quick query
201231
// (natively).

src/js/repeat.js

Lines changed: 127 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,104 @@
33
*
44
* Two important concepts are used:
55
* 1. The first XLST-added repeat view is cloned to serve as a template of that repeat.
6-
* 2. Each repeat series has a sibling .or-repeat-info element that stores info that is relevant to that series.
6+
* 2. Each repeat series has a sibling .or-repeat-info element that stores info that is relevant to that series. (More details below)
77
*
88
* Note that with nested repeats you may have many more series of repeats than templates, because a nested repeat
99
* may have multiple series.
1010
*
1111
* @module repeat
1212
*/
1313

14+
/**
15+
* "Repeat info" elements are used to convey and/or contain several sorts of
16+
* metadata, optimization-focused state, and interactive behavior. The following
17+
* should be regarded as non-exhaustive, but the intent is to update it as any
18+
* further usage becomes known.
19+
*
20+
* Metadata:
21+
*
22+
* - The "repeat info" element itself serves as a footer for a series of zero or
23+
* more repeat instances in the view, i.e. a marker designating where a given
24+
* series of repeat instances *does or may* precede.
25+
*
26+
* ```html
27+
* <section class="or-repeat" name="/root/repeat-name">...</section>
28+
* <div class="or-repeat" data-name="/root/repeat-name">...</div>
29+
* ```
30+
*
31+
* This element is produced by Enketo Transformer.
32+
*
33+
* - Its `data-name` attribute is the nodeset referenced by its associated
34+
* repeat instances (if any).
35+
*
36+
* This attribute is produced by Enketo Transformer.
37+
*
38+
* - Its `data-repeat-count` attribute is the repeat's `jr:count` expression, if
39+
* defined in the corresponding XForm.
40+
*
41+
* ```html
42+
* <div class="or-repeat" data-repeat-count="/data/repeat-count" ...>
43+
* ```
44+
*
45+
* This attribute is produced by Enketo Transformer.
46+
*
47+
* - Its `data-repeat-fixed` attribute, if defined in the corresponding XForm
48+
* with `jr:noAddRemove="true()"`.
49+
*
50+
* ```html
51+
* <div class="or-repeat" data-repeat-fixed ...>
52+
* ```
53+
*
54+
* This attribute is produced by Enketo Transformer.
55+
*
56+
* Optimization-focused state:
57+
*
58+
* - "Shared", "static" itemsets—when rendered as `datalist`s—along with their
59+
* associated translation definitions, and the current state of their
60+
* translated label elements. A minimal (seriously!) example:
61+
*
62+
* ```html
63+
* <div class="repeat-shared-datalist-itemset-elements" style="display: none;">
64+
* <datalist id="datarepitem0" data-name="/data/rep/item-0" class="repeat-shared-datalist-itemset">
65+
* <option class="itemset-template" value="" data-items-path="instance('items-0')/item">...</option>
66+
* <option value="items-0-0" data-value="items 0 0"></option>
67+
* </datalist>
68+
*
69+
* <span class="or-option-translations" style="display:none;" data-name="/data/rep/item-0"> </span>
70+
*
71+
* <span class="itemset-labels" data-value-ref="name" data-label-type="itext" data-label-ref="itextId" data-name="/data/rep/item-0">
72+
* <span lang="en" class="option-label active" data-itext-id="items-0-0">items-0-0</span>
73+
* </span>
74+
* </div>
75+
* ```
76+
*
77+
* The child elements are first produced by Enketo Transformer. They are then
78+
* identified (itemset.js), augmented and reparented (repeat.js) by Enketo
79+
* Core to the outer element created during form initialization.
80+
*
81+
* Interactive behavior:
82+
*
83+
* - The button used to add new repeat user-controlled instances (i.e. when
84+
* instances are not controlled by `jr:count` or `jr:noAddRemove`):
85+
*
86+
* ```html
87+
* <button type="button" class="btn btn-default add-repeat-btn">...</button>
88+
* ```
89+
*
90+
* This element is created and appended in Enketo Core, with requisite event
91+
* handler(s) for user interaction when adding repeat instances.
92+
*
93+
* Each user-controlled repeat instance's corresponding removal button is
94+
* contained by its respective repeat instance, under a `.repeat-buttons`
95+
* element (also added by Enketo Core; no other buttons are added besides the
96+
* removal button).
97+
* @typedef {HTMLDivElement} EnketoRepeatInfo
98+
* @property {`${string}or-repeat-info${string}`} className - This isn't the
99+
* best! It just ensures `EnketoRepeatInfo` is a distinct type (according to
100+
* TypeScript and its language server), rather than an indistinguishable alias
101+
* to `HTMLDivElement`.
102+
*/
103+
14104
import $ from 'jquery';
15105
import { t } from 'enketo/translator';
16106
import dialog from 'enketo/dialog';
@@ -576,29 +666,59 @@ export default {
576666
if (this.staticLists.includes(id)) {
577667
datalist.remove();
578668
} else {
579-
// Let all identical input[list] questions amongst all repeat instances use the same
580-
// datalist by moving it under repeatInfo.
581-
// It will survive removal of all repeat instances.
669+
// Let all identical input[list] questions amongst all
670+
// repeat instances use the same datalist by moving it
671+
// under repeatInfo. It will survive removal of all
672+
// repeat instances.
673+
582674
const parent = datalist.parentElement;
583675
const { name } = input;
584676

585677
const dl = parent.querySelector('datalist');
586678
const detachedList = parent.removeChild(dl);
587679
detachedList.setAttribute('data-name', name);
588-
repeatInfo.appendChild(detachedList);
589680

590681
const translations = parent.querySelector(
591682
'.or-option-translations'
592683
);
593684
const detachedTranslations =
594685
parent.removeChild(translations);
595686
detachedTranslations.setAttribute('data-name', name);
596-
repeatInfo.appendChild(detachedTranslations);
597687

598688
const labels = parent.querySelector('.itemset-labels');
599689
const detachedLabels = parent.removeChild(labels);
600690
detachedLabels.setAttribute('data-name', name);
601-
repeatInfo.appendChild(detachedLabels);
691+
692+
// Each of these supporting elements are nested in a
693+
// containing element, so any subsequent DOM queries for
694+
// their various sibling elements don't mistakenly match
695+
// those from a previous itemset in the same repeat.
696+
const sharedItemsetContainer =
697+
document.createElement('div');
698+
699+
sharedItemsetContainer.style.display = 'none';
700+
sharedItemsetContainer.append(
701+
detachedList,
702+
detachedTranslations,
703+
detachedLabels
704+
);
705+
repeatInfo.append(sharedItemsetContainer);
706+
707+
// Add explicit class which can be used to determine
708+
// this condition elsewhere. See its usage and
709+
// commentary in `itemset.js`
710+
datalist.classList.add(
711+
'repeat-shared-datalist-itemset'
712+
);
713+
// This class currently serves no functional purpose
714+
// (please do not use it for new functional purposes
715+
// either). It's included specifically so that the
716+
// resulting DOM structure has some indication of why
717+
// it's the way it is, and some way to trace back to
718+
// this code producing that structure.
719+
sharedItemsetContainer.classList.add(
720+
'repeat-shared-datalist-itemset-elements'
721+
);
602722

603723
this.staticLists.push(id);
604724
// input.classList.add( 'shared' );

0 commit comments

Comments
 (0)