From b55eb6b512fff0c6bb98ad5acb82ceefd6eae89d Mon Sep 17 00:00:00 2001 From: Tim Jacomb <21194782+timja@users.noreply.github.com> Date: Thu, 25 Nov 2021 08:45:31 +0000 Subject: [PATCH] Inline form element path (#5926) --- .../FormElementPathPageDecorator.java | 20 ++ .../FormElementPathPageDecorator/footer.jelly | 6 + .../formelementpath/form-element-path.js | 196 ++++++++++++++++++ .../lib/form/repeatable/repeatable.js | 7 +- 4 files changed, 227 insertions(+), 2 deletions(-) create mode 100644 core/src/main/java/jenkins/formelementpath/FormElementPathPageDecorator.java create mode 100644 core/src/main/resources/jenkins/formelementpath/FormElementPathPageDecorator/footer.jelly create mode 100644 core/src/main/resources/jenkins/formelementpath/form-element-path.js diff --git a/core/src/main/java/jenkins/formelementpath/FormElementPathPageDecorator.java b/core/src/main/java/jenkins/formelementpath/FormElementPathPageDecorator.java new file mode 100644 index 000000000000..07efd8f0ddd1 --- /dev/null +++ b/core/src/main/java/jenkins/formelementpath/FormElementPathPageDecorator.java @@ -0,0 +1,20 @@ +package jenkins.formelementpath; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import hudson.Extension; +import hudson.Main; +import hudson.model.PageDecorator; +import jenkins.util.SystemProperties; + +@Extension +public class FormElementPathPageDecorator extends PageDecorator { + + @SuppressFBWarnings("MS_SHOULD_BE_FINAL") + private static /*almost final */ boolean ENABLED = Main.isUnitTest || + SystemProperties.getBoolean(FormElementPathPageDecorator.class.getName() + ".enabled"); + + public boolean isEnabled() { + return ENABLED; + } + +} diff --git a/core/src/main/resources/jenkins/formelementpath/FormElementPathPageDecorator/footer.jelly b/core/src/main/resources/jenkins/formelementpath/FormElementPathPageDecorator/footer.jelly new file mode 100644 index 000000000000..1b92a93950fe --- /dev/null +++ b/core/src/main/resources/jenkins/formelementpath/FormElementPathPageDecorator/footer.jelly @@ -0,0 +1,6 @@ + + + + + + diff --git a/core/src/main/resources/jenkins/formelementpath/form-element-path.js b/core/src/main/resources/jenkins/formelementpath/form-element-path.js new file mode 100644 index 000000000000..0bba9171438e --- /dev/null +++ b/core/src/main/resources/jenkins/formelementpath/form-element-path.js @@ -0,0 +1,196 @@ +/** + * Adds a 'path' attribute to form elements in the DOM. + * This is useful for providing stable selectors for UI testing. + * + * Instead of selecting by xpath with something like div/span/input[text() = 'Name'] + * You can use the path attribute: /org-jenkinsci-plugins-workflow-libs-FolderLibraries/libraries/name + */ +document.addEventListener("DOMContentLoaded", function(){ + // most of this is copied from hudson-behaviour.js + function buildFormTree(form) { + form.formDom = {}; // root object + + var doms = []; // DOMs that we added 'formDom' for. + doms.push(form); + + function addProperty(parent, name, value) { + name = shortenName(name); + if (parent[name] != null) { + if (parent[name].push == null) // is this array? + parent[name] = [parent[name]]; + parent[name].push(value); + } else { + parent[name] = value; + } + } + + // find the grouping parent node, which will have @name. + // then return the corresponding object in the map + function findParent(e) { + var p = findFormParent(e, form); + if (p == null) return {}; + + var m = p.formDom; + if (m == null) { + // this is a new grouping node + doms.push(p); + p.formDom = m = {}; + addProperty(findParent(p), p.getAttribute("name"), p); + } + return m; + } + + var jsonElement = null; + + for (var i = 0; i < form.elements.length; i++) { + var e = form.elements[i]; + if (e.name == "json") { + jsonElement = e; + continue; + } + if (e.tagName == "FIELDSET") + continue; + if (e.tagName == "SELECT" && e.multiple) { + addProperty(findParent(e), e.name, e); + continue; + } + + var p; + var type = e.getAttribute("type"); + if (type == null) type = ""; + switch (type.toLowerCase()) { + case "button": + var element + // modern buttons aren't wrapped in spans + if (e.classList.contains('jenkins-button')) { + element = e + } else { + p = findParent(e); + element = e.parentNode.parentNode; // YUI's surrounding that has interesting classes + } + var name = null; + ["repeatable-add", "repeatable-delete", "hetero-list-add", "expand-button", "advanced-button", "apply-button", "validate-button"] + .forEach(function (clazz) { + if (element.classList.contains(clazz)) { + name = clazz; + } + }); + if (name == null) { + if (name == null) { + element = element.parentNode.previousSibling; + if (element != null && element.classList && element.classList.contains('repeatable-insertion-point')) { + name = "hetero-list-add"; + } + } + } + if (name != null) { + addProperty(p, name, e); + } + break; + case "submit": + break; + case "checkbox": + case "radio": + p = findParent(e); + if (e.groupingNode) { + e.formDom = {}; + } + addProperty(p, e.name, e); + break; + case "file": + // to support structured form submission with file uploads, + // rename form field names to unique ones, and leave this name mapping information + // in JSON. this behavior is backward incompatible, so only do + // this when + p = findParent(e); + if (e.getAttribute("jsonAware") != null) { + var on = e.getAttribute("originalName"); + if (on != null) { + addProperty(p, on, e); + } else { + addProperty(p, e.name, e); + } + } + break; + // otherwise fall through + default: + p = findParent(e); + addProperty(p, e.name, e); + break; + } + } + + function annotate(e, path) { + e.setAttribute("path", path); + var o = e.formDom || {}; + for (var key in o) { + var v = o[key]; + + function child(v, i) { + var suffix = null; + var newKey = key; + if (v.parentNode.className && v.parentNode.className.indexOf("one-each") > -1 && v.parentNode.className.indexOf("honor-order") > -1) { + suffix = v.getAttribute("descriptorId").split(".").pop() + } else if (v.getAttribute("type") == "radio") { + suffix = v.value + while (newKey.substring(0, 8) == 'removeme') + newKey = newKey.substring(newKey.indexOf('_', 8) + 1); + } else if (v.getAttribute("suffix") != null) { + suffix = v.getAttribute("suffix") + } else { + if (i > 0) + suffix = i; + } + if (suffix == null) suffix = ""; + else suffix = '[' + suffix + ']'; + + annotate(v, path + "/" + newKey + suffix); + } + + if (v instanceof Array) { + var i = 0; + v.forEach(function (v) { + child(v, i++) + }) + } else { + child(v, 0) + } + } + + } + + annotate(form, ""); + + // clean up + for (i = 0; i < doms.length; i++) + doms[i].formDom = null; + + return true; + } + + function applyAll() { + document.querySelectorAll("FORM").forEach(function (e) { + buildFormTree(e); + }) + } + + /* JavaScript sometimes re-arranges the DOM and doesn't call layout callback + * known cases: YUI buttons, CodeMirror. + * We run apply twice to work around this, once immediately so that most cases work and the tests don't need to wait, + * and once to catch the edge cases. + */ + function hardenedApplyAll () { + applyAll(); + + setTimeout(function () { + applyAll(); + }, 1000); + } + + hardenedApplyAll(); + + layoutUpdateCallback.add(hardenedApplyAll) + + // expose this globally so that Selenium can call it + window.recomputeFormElementPath = hardenedApplyAll; +}); diff --git a/core/src/main/resources/lib/form/repeatable/repeatable.js b/core/src/main/resources/lib/form/repeatable/repeatable.js index 1bd4efddf698..3260051d1795 100644 --- a/core/src/main/resources/lib/form/repeatable/repeatable.js +++ b/core/src/main/resources/lib/form/repeatable/repeatable.js @@ -121,8 +121,10 @@ var repeatableSupport = { a.onComplete.subscribe(function() { var p = n.parentNode; p.removeChild(n); - if (p.tag) + if (p.tag) { p.tag.update(); + } + layoutUpdateCallback.call(); }); a.animate(); }, @@ -145,6 +147,7 @@ var repeatableSupport = { updateOptionalBlock(input, false); } } + layoutUpdateCallback.call(); } }; @@ -185,7 +188,7 @@ Behaviour.specify("INPUT.repeatable-delete", 'repeatable', 0, function(e) { e = be = null; // avoid memory leak }); - // radio buttons in repeatable content +// radio buttons in repeatable content // Needs to run before the radioBlock behavior so that names are already unique. Behaviour.specify("DIV.repeated-chunk", 'repeatable', -200, function(d) { var inputs = d.getElementsByTagName('INPUT');