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');