UNPKG

enketo-core

Version:

Extensible Enketo form engine

635 lines (561 loc) 22.1 kB
/** * @module relevant * * @description Updates branches */ import config from 'enketo/config'; import events from './event'; import { closestAncestorUntil, getChild, getChildren } from './dom-utils'; /** * @typedef {import('./form').Form} Form */ /** * @typedef RelevanceState * @property {boolean} isParentNonRelevant * @property {boolean} isSelfNonRelevant * @property {string} [nonRelevantValue] */ /** @type {Map<Element, RelevanceState>} */ const relevanceState = new Map(); /** * Determines whether a model node is relevant, and not a descendant of any * non-relevant parent. For backwards-compatibility, this always returns `true` * when `config.excludeNonRelevant` is off. * * @param {Element} node */ export const isNodeRelevant = (node) => { if (!config.excludeNonRelevant) { return true; } const state = relevanceState.get(node); return !state?.isParentNonRelevant && !state?.isSelfNonRelevant; }; /** * @param {Element} element * @param {string} nonRelevantValue */ export const setNonRelevantValue = (element, nonRelevantValue) => { relevanceState.set(element, { ...relevanceState.get(element), nonRelevantValue, }); }; /** * @param {Element} element */ export const getNonRelevantValue = (element) => relevanceState.get(element); /** * Used to preserve known repeat context in a chain of computations. This helps to * identify repeat context for nodes with no view control, and improves performance * in certain cases. * * @typedef RelevantDataNodesOptions * @property {number} [repeatIndex] * @property {string} [repeatPath] */ 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( 'Branch module not correctly instantiated with form property.' ); } if (!this.form.features.relevant) { this.update = () => { // Form noop }; return; } this.update(); }, /** * @param {UpdatedDataNodes | null} [updated] - The object containing info on updated data nodes. * @param {boolean} [forceClearNonRelevant] - whether to empty the values of non-relevant nodes */ update(updated, forceClearNonRelevant = config.forceClearNonRelevant) { const nodes = this.form .getRelatedNodes('data-relevant', '', updated) .get(); this.updateNodes(nodes, forceClearNonRelevant, updated ?? {}); }, /** * @param {Array<Element>} nodes - Nodes to update * @param {boolean} [forceClearNonRelevant] - whether to empty the values of non-relevant nodes * @param {RelevantDataNodesOptions} [options] */ updateNodes(nodes, forceClearNonRelevant = false, options = {}) { let branchChange = false; const relevantCache = {}; const alreadyCovered = []; const repeatsPresent = this.form.features.repeat; const { groups, repeats } = this.form.collections; nodes.forEach((node) => { // Note that node.getAttribute('name') is not the same as p.path for repeated radiobuttons! if (alreadyCovered.includes(node.getAttribute('name'))) { return; } // Since this result is almost certainly not empty, closest() is the most efficient const branchNode = node.closest('.or-branch'); if (!branchNode) { if ( !closestAncestorUntil( node.parentsUntil(node, '#or-calculated-items', '.or') ) ) { console.error('could not find branch node for ', node); } return; } const relevant = this.form.input.getRelevant(node); const path = this.form.input.getName(node); let cacheIndex = null; const { repeatIndex } = options; let { repeatPath } = options; if (repeatsPresent && repeatPath == null) { repeatPath = this.form.nodePathToRepeatPath[path]; if (repeatPath == null) { for (const prefix of this.form.repeatPathPrefixes) { if (path.startsWith(prefix)) { repeatPath = prefix.substring(0, prefix.length - 1); break; } } this.form.nodePathToRepeatPath[path] = repeatPath ?? null; } } if (repeatPath != null) { /* * Check if the (calculate without form control) node is part of a repeat that has no instances */ const pathParts = path.split('/'); if (pathParts.length > 3 && repeatPath == null) { const parentPath = pathParts .splice(0, pathParts.length - 1) .join('/'); const parentGroups = groups .getElementsByRef(parentPath) // now remove the groups that have a repeat-info child without repeat instance siblings .filter( (group) => getChild(group, '.or-repeat') || !getChild(group, '.or-repeat-info') ); // If the parent doesn't exist in the DOM it means there is a repeat ancestor and there are no instances of that repeat. // Hence that relevant does not need to be evaluated (and would fail otherwise because the context doesn't exist). if (parentGroups.length === 0) { return; } } } /* * Determining ancestry is expensive. Using the knowledge most forms don't use repeats and * if they do, they usually don't have cloned repeats during initialization we perform first a check for .repeat.clone. * The first condition is usually false (and is a very quick one-time check) so this presents a big performance boost * (6-7 seconds of loading time on the bench6 form) */ const insideRepeat = repeatPath != null && path.startsWith(`${repeatPath}/`); const repeatParent = branchNode.closest('.or-repeat'); /** * Determines the current repeat index position for nodes with no view control. * * @see {RelevantDataNodesOptions} */ const hiddenInputRepeatIndex = repeatParent == null && typeof repeatIndex === 'number' && repeatPath != null && path.startsWith(`${repeatPath}/`) ? repeatIndex : null; /* * If the relevant is placed on a group and that group contains repeats with the same name, * but currently has 0 repeats, the context will not be available. This same logic is applied in output.js. */ let context = path; if ( (getChild(node, `.or-repeat-info[data-name="${path}"]`) && !getChild(node, `.or-repeat[name="${path}"]`)) || // Special cases below for model nodes with no visible form control: if repeat instance removed or if // no instances at all (e.g. during load with `jr:count="0"`) (insideRepeat && repeatParent == null && (options.removed || repeats.getElementByRef(repeatPath) == null)) ) { context = null; } /* * Determining the index is expensive, so we only do this when the branch is inside a cloned repeat. * It can be safely set to 0 for other branches. */ const ind = hiddenInputRepeatIndex ?? this.form.repeats.getIndex(repeatParent); /* * Caching is only possible for expressions that do not contain relative paths to nodes. * So, first do a *very* aggresive check to see if the expression contains a relative path. * This check assumes that child nodes (e.g. "mychild = 'bob'") are NEVER used in a relevant * expression, which may prove to be incorrect. */ if (relevant.indexOf('..') === -1) { if (!insideRepeat) { cacheIndex = relevant; } else { // The path is stripped of the last nodeName to record the context. // This might be dangerous, but until we find a bug, it helps in those forms where one group contains // many sibling questions that each have the same relevant. cacheIndex = `${relevant}__${path.substring( 0, path.lastIndexOf('/') )}__${ind}`; } } let result; if ( cacheIndex && typeof relevantCache[cacheIndex] !== 'undefined' ) { result = relevantCache[cacheIndex]; } else { result = this.evaluate(relevant, context, ind); relevantCache[cacheIndex] = result; } if (!insideRepeat) { alreadyCovered.push(node.getAttribute('name')); } if ( this.process(branchNode, path, result, forceClearNonRelevant, { ...options, repeatIndex: ind, repeatPath, }) === true ) { branchChange = true; } }); if (branchChange && this.form.features.pagination) { this.form.view.$.trigger('changebranch'); } }, /** * Evaluates a relevant expression (for future fancy stuff this is placed in a separate function) * * @param {string} expr - relevant XPath expression to evaluate * @param {string} contextPath - Path of the context node * @param {number} index - index of context node * @return {boolean} result of evaluation */ evaluate(expr, contextPath, index) { const result = this.form.model.evaluate( expr, 'boolean', contextPath, index ); return result; }, /** * Processes the evaluation result for a branch * * @param {Element} branchNode - branch node * @param {string} path - path of branch node * @param {boolean} result - result of relevant evaluation * @param {boolean} [forceClearNonRelevant] - whether to empty the values of non-relevant nodes * @param {RelevantDataNodesOptions} [options] */ process( branchNode, path, result, forceClearNonRelevant = false, options = {} ) { if (result === true) { return this.enable(branchNode, path, options); } return this.disable(branchNode, path, forceClearNonRelevant, options); }, /** * Checks whether branch currently has 'relevant' state * * @param {Element} branchNode - branch node * @return {boolean} whether branch is currently relevant */ selfRelevant(branchNode) { return ( !branchNode.classList.contains('disabled') && !branchNode.classList.contains('pre-init') ); }, /** * @typedef ToggleNonRelevantModleNodesOptions * @property {number} [repeatIndex] * @property {number} [repeatPath] * @property {boolean} setRelevant */ /** * @typedef {import('./nodeset').Nodeset} NodeSet */ /** * @typedef RepeatInfo * @property {number} repeatIndex * @property {string} repeatPath */ /** * @param {HTMLElement} branchNode * @param {string} path * @param {ToggleNonRelevantModleNodesOptions} options */ toggleNonRelevantModelNodes(branchNode, path, options) { if (config.excludeNonRelevant) { const { setRelevant } = options; branchNode.dataset.isNonRelevant = String(!setRelevant); const { repeatIndex, repeatPath } = options; const isRepeatChild = repeatPath && path.startsWith(`${repeatPath}/`); const hasRepeatData = isRepeatChild && repeatIndex != null; const closestRepeat = branchNode.parentNode?.closest('.or-repeat'); const checkRepeatIndex = repeatIndex == null && closestRepeat != null; /** @type {NodeSet | null} */ let nodeSet = null; /** @type {RepeatInfo | null} */ let repeatInfo = null; if (checkRepeatIndex) { const repeatIndex = this.form.input.getIndex(branchNode); nodeSet = this.form.model.node(path, repeatIndex); repeatInfo = nodeSet.getClosestRepeat(); } else if (hasRepeatData) { repeatInfo = { repeatIndex, repeatPath, }; } if (nodeSet == null) { nodeSet = this.form.model.node( path, isRepeatChild ? repeatIndex : null ); } const referencedModelNodes = new Set(nodeSet.getElements()); const modelNodes = nodeSet .getElements() .flatMap((node) => [ ...referencedModelNodes, ...node.querySelectorAll('*'), ]) .filter((node) => { const isNodeNonRelevant = !isNodeRelevant(node); return isNodeNonRelevant === setRelevant; }); if (modelNodes.length === 0) { return; } /** @type {Element[]} */ const updatedElements = []; for (const node of modelNodes) { const isLeafNode = node.children.length === 0; const isReferencedNode = referencedModelNodes.has(node); const { textContent } = node; const currentValue = isLeafNode ? textContent || (relevanceState.get(node)?.currentValue ?? textContent) : null; const isChanged = setRelevant ? textContent !== currentValue : textContent !== ''; const currentRelevanceState = relevanceState.get(node); const isParentNonRelevant = Boolean( currentRelevanceState?.isParentNonRelevant ); const isSelfNonRelevant = Boolean( currentRelevanceState?.isSelfNonRelevant ); if (setRelevant) { if ( isLeafNode && textContent !== currentValue && (isReferencedNode || !isSelfNonRelevant) ) { node.textContent = currentValue; } relevanceState.set(node, { isParentNonRelevant: isReferencedNode ? isParentNonRelevant : false, isSelfNonRelevant: isReferencedNode ? false : isSelfNonRelevant, currentValue, nonRelevantValue: isReferencedNode ? null : currentValue, }); } else { if (isLeafNode && textContent !== '') { node.textContent = ''; } relevanceState.set(node, { isParentNonRelevant: isReferencedNode ? isParentNonRelevant : true, isSelfNonRelevant: isReferencedNode ? true : isSelfNonRelevant, currentValue, nonRelevantValue: currentValue, }); } if (isLeafNode && isChanged) { updatedElements.unshift(node); } } if (updatedElements.length > 0) { this.form.model.events.dispatchEvent( events.DataUpdate({ nodes: updatedElements.map(({ nodeName }) => nodeName), ...repeatInfo, }) ); } } else { this.toggleNonRelevantModelNodes = () => { // Configured noop }; } }, /** * Enables and reveals a branch node/group * * @param {Element} branchNode - The Element to reveal and enable * @param {string} path - path of branch node * @param {RelevantDataNodesOptions} options * @return {boolean} whether the relevant changed as a result of this action */ enable(branchNode, path, options) { let change = false; if (!this.selfRelevant(branchNode)) { change = true; branchNode.classList.remove('disabled', 'pre-init'); this.toggleNonRelevantModelNodes(branchNode, path, { ...options, setRelevant: true, }); // Update calculated items, both individual question or descendants of group this.form.calc.update({ relevantPath: path, }); this.form.itemset.update({ relevantPath: path, }); // Update outputs that are children of branch this.form.output.update({ rootNode: branchNode }); this.form.widgets.enable(branchNode); this.activate(branchNode); } return change; }, /** * Disables and hides a branch node/group * * @param {Element} branchNode - The element to hide and disable * @param {string} path - path of branch node * @param {boolean} forceClearNonRelevant - whether to empty the values of non-relevant nodes * @param {RelevantDataNodesOptions} options * @return {boolean} whether the relevancy changed as a result of this action */ disable(branchNode, path, forceClearNonRelevant, options) { const neverEnabled = branchNode.classList.contains('pre-init'); let changed = false; if ( neverEnabled || this.selfRelevant(branchNode) || forceClearNonRelevant ) { changed = true; if (forceClearNonRelevant) { this.clear(branchNode, path); } this.toggleNonRelevantModelNodes(branchNode, path, { ...options, setRelevant: false, }); this.deactivate(branchNode); } return changed; }, /** * Clears values from branchnode. * This function is separated so it can be overridden in custom apps. * * @param {Element} branchNode - branch node * @param {string} path - path of branch node */ clear(branchNode, path) { // A change event ensures the model is updated // An inputupdate event is required to update widgets this.form.input.clear( branchNode, events.Change(), events.InputUpdate() ); // Update calculated items if branch is a group // We exclude question branches here because those will have been cleared already in the previous line. if (branchNode.matches('.or-group, .or-group-data')) { this.form.calc.update( { relevantPath: path, }, '', true ); } }, /** * @param {Element} branchNode - branch node * @param {boolean} bool - value to set disabled property to */ setDisabledProperty(branchNode, bool) { const type = branchNode.nodeName.toLowerCase(); if (type === 'label') { getChildren(branchNode, 'input, select, textarea').forEach( (el) => (el.disabled = bool) ); } else if (type === 'fieldset' || type === 'section') { // TODO: a <section> cannot be disabled like this branchNode.disabled = bool; } else { branchNode .querySelectorAll('fieldset, input, select, textarea') .forEach((el) => (el.disabled = bool)); } }, /** * Activates form controls. * This function is separated so it can be overridden in custom apps. * * @param {Element} branchNode - branch node */ activate(branchNode) { this.setDisabledProperty(branchNode, false); }, /** * Deactivates form controls. * This function is separated so it can be overridden in custom apps. * * @param {Element} branchNode - branch node */ deactivate(branchNode) { branchNode.classList.add('disabled'); this.form.widgets.disable(branchNode); this.setDisabledProperty(branchNode, true); }, };