enketo-core
Version:
Extensible Enketo form engine
596 lines (553 loc) • 25.2 kB
JavaScript
/**
* Updates itemsets.
*
* @module itemset
*/
import dialog from 'enketo/dialog';
import { t } from 'enketo/translator';
import { parseFunctionFromExpression } from './utils';
import {
getChild,
getSiblingElement,
elementDataStore as data,
} from './dom-utils';
import events from './event';
/**
* @typedef {import('./form').Form} Form
*/
/**
* This function tries to determine whether an XPath expression for a nodeset from an external instance is static.
* Hopefully in the future it can do this properly, but for now it considers any expression
* it determines to have a non-numeric (position) predicate to be dynamic.
* This function relies on external instances themselves to be static.
*
* Known issues:
*
* - 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.
* - The check for a reference to an instance does not handle [non-`instance`] absolute or relative path expressions.
* - 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).
* - At least the numeric predicate does not account for whitespace.
*
* @static
* @param {string} expr - XPath expression to analyze
* @return {boolean} Whether expression contains a predicate
*/
function isStaticItemsetFromSecondaryInstance(expr) {
const refersToInstance = /^\s*instance\(.+\)/.test(expr);
if (!refersToInstance) {
return false;
}
const containsPredicate = /\[.+\]/.test(expr);
if (!containsPredicate) {
return true;
}
const containsNumericPredicate = /\[\d+\]/.test(expr);
return containsNumericPredicate;
}
export { isStaticItemsetFromSecondaryInstance };
export default {
/**
* @type {Form}
*/
// @ts-expect-error - this will be populated during form init, but assigning
// its type here improves intellisense.
form: null,
init() {
if (!this.form) {
throw new Error(
'Itemset module not correctly instantiated with form property.'
);
}
if (!this.form.features.itemset) {
this.update = () => {
// Form noop
};
return;
}
this.update();
},
/**
* @param {UpdatedDataNodes} [updated] - The object containing info on updated data nodes.
*/
update(updated = {}) {
const that = this;
const fragmentsCache = {};
let nodes;
if (updated.relevantPath) {
// Questions that are descendants of a group:
nodes = this.form
.getRelatedNodes('data-items-path', '.itemset-template')
.get()
.filter(
(template) =>
template.querySelector(
`[type="checkbox"][name^="${updated.relevantPath}/"]`
) || // checkboxes, ancestor relevant
template.querySelector(
`[type="radio"][data-name^="${updated.relevantPath}/"]`
) || // radiobuttons, ancestor relevant
template.parentElement.matches(
`select[name^="${updated.relevantPath}/"]`
) || // select minimal, ancestor relevant
template.parentElement.parentElement.querySelector(
`input[list][name^="${updated.relevantPath}/"]`
) || // autocomplete, ancestor relevant
template.querySelector(
`[type="checkbox"][name="${updated.relevantPath}"]`
) || // checkboxes, self relevant
template.querySelector(
`[type="radio"][data-name="${updated.relevantPath}"]`
) || // radiobuttons, self relevant
template.parentElement.matches(
`select[name="${updated.relevantPath}"]`
) || // select minimal, self relevant
template.parentElement.parentElement.querySelector(
`input[list][name="${updated.relevantPath}"]`
) // autocomplete, self relevant
);
// TODO: missing case: static shared itemlist in repeat
} else {
nodes = this.form
.getRelatedNodes(
'data-items-path',
'.itemset-template',
updated
)
.get();
}
if (nodes.length === 0) {
return;
}
const alerts = [];
nodes.forEach((template) => {
// Nodes are in document order, so we discard any nodes in questions/groups that have a disabled parent
if (template.closest('.disabled')) {
return;
}
const templateParent = template.parentElement;
const isShared =
// Shared itemset datalists and their related DOM elements were
// previously reparented directly under `repeat-info`. They're
// now reparented to a container within `repeat-info` to fix a
// bug when two or more such itemsets are present in the same
// repeat.
//
// The original check for this condition was tightly coupled to
// the previous structure, leading to errors even after the root
// cause had been fixed. This has been revised to check for a
// class explicitly describing the condition it's checking.
//
// TODO (2023-08-16): This continues to add to the view's role
// as a (the) source of truth about both form state and form
// definition. While expedient, it must be acknowledged as
// additional technical debt.
templateParent.classList.contains(
'repeat-shared-datalist-itemset'
) ||
// It's currently unclear whether there are other cases this
// would still handle. It's currently preserved in case its
// removal might cause unknown regressions. See
// https://en.wiktionary.org/wiki/Chesterton%27s_fence
templateParent.parentElement.matches('.or-repeat-info');
const inputAttributes = {};
const newItems = {};
const prevItems = data.get(template, 'items') || {};
const templateNodeName = template.nodeName.toLowerCase();
const list = template.parentElement.matches('select, datalist')
? template.parentElement
: null;
let input;
if (templateNodeName === 'label') {
const optionInput = getChild(template, 'input');
[].slice.call(optionInput.attributes).forEach((attr) => {
inputAttributes[attr.name] = attr.value;
});
// If this is a ranking widget:
input = optionInput.classList.contains('ignore')
? getSiblingElement(
optionInput.closest('.option-wrapper'),
'input.rank'
)
: optionInput;
} else if (list && list.nodeName.toLowerCase() === 'select') {
input = list;
if (input.matches('[readonly]')) {
inputAttributes.disabled = 'disabled';
}
} else if (list && list.nodeName.toLowerCase() === 'datalist') {
if (isShared) {
// only the first input, is that okay?
input = that.form.view.html.querySelector(
`input[name="${list.dataset.name}"]`
);
} else {
input = getSiblingElement(list, 'input:not(.widget)');
}
}
const labelsContainer = getSiblingElement(
template.closest('label, select, datalist'),
'.itemset-labels'
);
const itemsXpath = template.dataset.itemsPath;
let { labelType } = labelsContainer.dataset;
let { labelRef } = labelsContainer.dataset;
// TODO: if translate() becomes official, move determination of labelType to enketo-xslt
// and set labelRef correct in enketo-xslt
const matches = parseFunctionFromExpression(labelRef, 'translate');
if (matches.length) {
labelRef = matches[0][1][0];
labelType = 'langs';
}
const { valueRef } = labelsContainer.dataset;
// Shared datalists are under .or-repeat-info. Context is not relevant as these are static lists (without relative nodes).
const context = that.form.input.getName(input);
/*
* Determining the index is expensive, so we only do this when the itemset is inside a cloned repeat and not shared.
* It can be safely set to 0 for other branches.
*/
const index = !isShared ? that.form.input.getIndex(input) : 0;
const safeToTryNative = true;
// Caching has no advantage here. This is a very quick query
// (natively).
// TODO: ^ this is definitely not true when adding
// multiple count-controlled repeats where the result can be
// expected to be the same for each.
const instanceItems = this.form.model.evaluate(
itemsXpath,
'nodes',
context,
index,
safeToTryNative
);
// This property allows for more efficient 'itemschanged' detection
newItems.length = instanceItems.length;
// TODO: This may cause problems for large itemsets. Use md5 instead?
newItems.text = instanceItems
.map((item) => item.textContent)
.join('');
if (
newItems.length === prevItems.length &&
newItems.text === prevItems.text
) {
return;
}
data.put(template, 'items', newItems);
/**
* Remove current items before rebuilding a new itemset from scratch.
*/
// the current <option> and <input> elements
// datalist will catch the shared datalists inside .or-repeat-info
const question = template.closest('.question, datalist');
[...question.querySelectorAll(templateNodeName)]
.filter((el) => el !== template)
.forEach((el) => el.remove());
// labels for current <option> elements
const next = question.nextElementSibling;
// next is a somewhat fragile match for option-translations belonging to a shared datalist in
// .or-repeat-info if there are multiple shared datalists.
const optionsTranslations =
next && next.matches('.or-option-translations')
? next
: question.querySelector('.or-option-translations');
if (optionsTranslations) {
[...optionsTranslations.children].forEach((child) =>
child.remove()
);
}
let optionsFragment = document.createDocumentFragment();
let optionsTranslationsFragment = document.createDocumentFragment();
let translations = [];
const cacheKey = `${context}:${itemsXpath}`;
if (fragmentsCache[cacheKey]) {
// important: leave cache intact by cloning
optionsFragment =
fragmentsCache[cacheKey].optionsFragment.cloneNode(true);
optionsTranslationsFragment =
fragmentsCache[
cacheKey
].optionsTranslationsFragment.cloneNode(true);
} else {
instanceItems.forEach((item) => {
/*
* Note: $labelRefs could either be
* - a single itext reference
* - a collection of labels with different lang attributes
* - a single label
*/
const labels = that.getNodesFromItem(labelRef, item);
if (!labels || !labels.length) {
translations = [
{ language: '', label: 'error', active: true },
];
} else {
switch (labelType) {
case 'itext':
// Search in the special .itemset-labels created in enketo-transformer for labels with itext ref.
translations = [
...labelsContainer.querySelectorAll(
`[data-itext-id="${labels[0].textContent}"]`
),
].map((label) => {
const language = label.getAttribute('lang');
const type = label.nodeName;
const src =
label.getAttribute(
'data-offline-src'
) || label.getAttribute('src');
const contentNodes = [...label.childNodes];
const active =
label.classList.contains('active');
const { alt } = label;
return {
language,
type,
contentNodes,
active,
src,
alt,
};
});
break;
case 'langs':
translations = labels.map((label) => {
const lang = label.getAttribute('lang');
// Two falsy values should set active to true.
const active =
(!lang &&
!that.form.langs.currentLanguage) ||
lang ===
that.form.langs.currentLanguage;
return {
language: lang,
type: 'span',
contentNodes: [...label.childNodes],
active,
};
});
break;
default:
translations = [
{
language: '',
type: 'span',
contentNodes:
labels && labels.length
? [...labels[0].childNodes]
: [],
active: true,
},
];
}
}
// Obtain the value of the secondary instance item found.
const value = that.getNodeFromItem(
valueRef,
item
).textContent;
/**
* #510 Show warning if select_multiple value has spaces
*/
const multiple =
(inputAttributes['data-type-xml'] === 'select' &&
inputAttributes.type === 'checkbox') ||
(list && list.multiple);
if (multiple && value.indexOf(' ') > -1) {
alerts[alerts.length] = t(
'alert.valuehasspaces.multiple',
{ value }
);
}
if (templateNodeName === 'label') {
optionsFragment.appendChild(
that.createInput(
inputAttributes,
translations,
value
)
);
} else if (templateNodeName === 'option') {
let activeLabelContentNodes = [];
if (translations.length > 1) {
translations.forEach((translation) => {
if (translation.active) {
activeLabelContentNodes =
translation.contentNodes;
}
optionsTranslationsFragment.appendChild(
that.createOptionTranslation(
translation,
value
)
);
});
} else {
activeLabelContentNodes =
translations[0].contentNodes;
}
optionsFragment.appendChild(
that.createOption(
inputAttributes,
activeLabelContentNodes,
value
)
);
}
});
// Do not cache radio button questions inside a repeat because each set (in each repeat) should maintain unique name attribute
if (
isStaticItemsetFromSecondaryInstance(itemsXpath) &&
!(input.type === 'radio' && input.closest('.or-repeat'))
) {
fragmentsCache[cacheKey] = {
optionsFragment: optionsFragment.cloneNode(true),
optionsTranslationsFragment:
optionsTranslationsFragment.cloneNode(true),
};
}
}
template.parentNode.appendChild(optionsFragment);
if (optionsTranslations) {
optionsTranslations.appendChild(optionsTranslationsFragment);
}
/**
* Attempt to populate inputs with current value in model (except for ranking input)
* Note that if the current value is not empty and the new itemset does not
* include (an) item(s) with this/se value(s), this will clear/update the model and
* this will trigger a dataupdate event. This may call this update function again.
*/
// It is not necessary to do this for default values in static itemsets because setAllVals takes care of this.
let currentValue = that.form.model.node(context, index).getVal();
if (currentValue !== '') {
if (input.classList.contains('rank')) {
currentValue = '';
}
that.form.input.setVal(input, currentValue, events.Change());
}
if (list || input.classList.contains('rank')) {
input.dispatchEvent(events.ChangeOption());
}
});
if (alerts.length > 0) {
/**
* We're assuming the enketo-core-consuming app has a dialog that supports some basic HTML rendering
*/
dialog.alert(alerts.join('<br>'));
}
},
/**
* Minimal XPath evaluation helper that queries from a single item context.
*
* @param {string} expr - The XPath expression
* @param {string} context - context path
* @param {boolean} single - whether to only return a single (first) node
* @return {Array<Element>} found nodes
*/
getNodesFromItem(expr, context, single) {
if (!expr) {
throw new Error(
'Error: could not query instance item, no expression provided'
);
}
const type = single ? 9 : 7;
const evaluateFnName =
typeof this.form.model.xml.evaluate !== 'undefined'
? 'evaluate'
: 'jsEvaluate';
const result = this.form.model.xml[evaluateFnName](
expr,
context,
this.form.model.getNsResolver(),
type,
null
);
const response = [];
if (!single) {
for (let j = 0; j < result.snapshotLength; j++) {
response.push(result.snapshotItem(j));
}
} else {
response.push(result.singleNodeValue);
}
return response;
},
/**
* @param {string} expr - XPath expression
* @param {string} context - evalation context path
* @return {Element|null} found nodes
*/
getNodeFromItem(expr, context) {
const nodes = this.getNodesFromItem(expr, context, true);
return nodes.length ? nodes[0] : null;
},
/**
* Creates a HTML option element
*
* @param {object} attributes - attributes to add to option
* @param {Array<Element>} labelContentNodes - label content nodes
* @param {string} value - option value
* @return {Element} created option
*/
createOption(attributes, labelContentNodes, value) {
const option = document.createElement('option');
Object.getOwnPropertyNames(attributes).forEach((attr) => {
option.setAttribute(attr, attributes[attr]);
});
option.textContent = labelContentNodes
.map((node) => node.textContent)
.join('');
option.value = value;
return option;
},
/**
* Creates an option translation <span> element
*
* @param {object} translation - translation object
* @param {string} [translation.type] - type of element to create, defaults to span
* @param {Array<Node>} [translation.content] - array of translation content nodes
* @param {string} value - option value
* @return {Element} created element
*/
createOptionTranslation(translation, value) {
const el = document.createElement(translation.type || 'span');
if (translation.contentNodes) {
if (!['IMG', 'AUDIO', 'VIDEO'].includes(el.tagName)) {
el.classList.add('option-label');
}
translation.contentNodes.forEach((node) =>
el.appendChild(node.cloneNode(true))
);
}
el.classList.toggle('active', translation.active);
if (translation.language) {
el.lang = translation.language;
}
el.dataset.optionValue = value;
if (translation.src) {
el.src = translation.src;
el.alt = translation.alt;
}
return el;
},
/**
* Creates an input HTML element
*
* @param {object} attributes - attributes to add to input
* @param {Array<object>} translations - translation to add
* @param {string} value - option value
* @return {Element} label element (wrapper)
*/
createInput(attributes, translations, value) {
const that = this;
const label = document.createElement('label');
const input = document.createElement('input');
Object.getOwnPropertyNames(attributes).forEach((attr) => {
input.setAttribute(attr, attributes[attr]);
});
input.value = value;
label.appendChild(input);
translations.forEach((translation) => {
label.appendChild(that.createOptionTranslation(translation, value));
});
return label;
},
};