From 221ff946b372e0654be77b1035039506c03c922e Mon Sep 17 00:00:00 2001 From: Jan Faracik <43062514+janfaracik@users.noreply.github.com> Date: Fri, 25 Nov 2022 22:23:09 +0000 Subject: [PATCH] Replace YUI tooltips with Tippy.js (#6408) Co-authored-by: Alexander Brandes Co-authored-by: Yaroslav <91559310+yaroslavafenkin@users.noreply.github.com> Co-authored-by: Daniel Beck <1831569+daniel-beck@users.noreply.github.com> Co-authored-by: Kevin Guerroudj <91883215+Kevin-CB@users.noreply.github.com> Co-authored-by: Tim Jacomb Co-authored-by: Tim Jacomb <21194782+timja@users.noreply.github.com> Co-authored-by: Alexander Brandes Co-authored-by: Daniel Beck Co-authored-by: Basil Crow Co-authored-by: Tim Jacomb --- .../java/org/jenkins/ui/icon/IconSet.java | 14 +- .../model/Run/KeepLogBuildBadge/badge.jelly | 2 +- .../scm/AbstractScmTagAction/badge.jelly | 2 +- .../views/BuildButtonColumn/column.jelly | 2 +- .../hudson/widgets/HistoryWidget/entry.jelly | 4 +- .../HistoryPageFilter/queue-items.jelly | 2 +- .../lib/form/repeatableDeleteButton.jelly | 2 +- .../resources/lib/hudson/buildHealth.jelly | 56 ++++---- .../src/main/resources/lib/hudson/queue.jelly | 2 +- .../main/resources/lib/layout/helpIcon.jelly | 2 +- core/src/main/resources/lib/layout/icon.jelly | 12 +- .../main/resources/lib/layout/svgIcon.jelly | 2 +- .../ui/icon/IconSetJenkins68805Test.java | 4 +- .../java/org/jenkins/ui/icon/IconSetTest.java | 12 +- .../src/test/java/hudson/model/QueueTest.java | 15 +-- test/src/test/java/hudson/model/RunTest.java | 4 +- .../hudson/scm/AbstractScmTagActionTest.java | 18 --- .../jenkins/security/Security2776Test.java | 34 +++-- .../jenkins/security/Security2779Test.java | 9 +- .../jenkins/security/Security2780Test.java | 4 +- .../test/java/lib/form/RepeatableTest.java | 2 +- .../src/test/java/lib/layout/SvgIconTest.java | 8 +- war/package.json | 1 + war/src/main/js/app.js | 2 + war/src/main/js/components/tooltips/index.js | 124 ++++++++++++++++++ war/src/main/less/abstracts/theme.less | 6 +- war/src/main/less/base/layout-commons.less | 1 - war/src/main/less/modules/table.less | 8 +- war/src/main/less/modules/tooltips.less | 63 +++++++-- .../main/webapp/scripts/hudson-behavior.js | 92 ------------- war/src/main/webapp/scripts/prototype.js | 3 +- war/yarn.lock | 17 +++ 32 files changed, 309 insertions(+), 220 deletions(-) create mode 100644 war/src/main/js/components/tooltips/index.js diff --git a/core/src/main/java/org/jenkins/ui/icon/IconSet.java b/core/src/main/java/org/jenkins/ui/icon/IconSet.java index 20dadf367f0d..2d3253f1a036 100644 --- a/core/src/main/java/org/jenkins/ui/icon/IconSet.java +++ b/core/src/main/java/org/jenkins/ui/icon/IconSet.java @@ -85,7 +85,7 @@ private static String prependTitleIfRequired(String icon, String title) { // for Jelly @Restricted(NoExternalUse.class) - public static String getSymbol(String name, String title, String tooltip, String classes, String pluginName, String id) { + public static String getSymbol(String name, String title, String tooltip, String htmlTooltip, String classes, String pluginName, String id) { String translatedName = cleanName(name); String identifier = Util.fixEmpty(pluginName) == null ? "core" : pluginName; @@ -95,10 +95,14 @@ public static String getSymbol(String name, String title, String tooltip, String String symbol = symbolsForLookup.get(translatedName); symbol = symbol.replaceAll("(class=\").*?(\")", "$1$2"); symbol = symbol.replaceAll("(tooltip=\").*?(\")", ""); + symbol = symbol.replaceAll("(data-html-tooltip=\").*?(\")", ""); symbol = symbol.replaceAll("(id=\").*?(\")", ""); - if (!tooltip.isEmpty()) { + if (!tooltip.isEmpty() && htmlTooltip.isEmpty()) { symbol = symbol.replaceAll(").*()", "$1$2"); symbol = symbol.replaceAll("(class=\").*?(\")", "$1$2"); symbol = symbol.replaceAll("(tooltip=\").*?(\")", "$1$2"); + symbol = symbol.replaceAll("(data-html-tooltip=\").*?(\")", "$1$2"); symbol = symbol.replaceAll("(id=\").*?(\")", ""); - if (!tooltip.isEmpty()) { + if (!tooltip.isEmpty() && htmlTooltip.isEmpty()) { symbol = symbol.replaceAll(" - + diff --git a/core/src/main/resources/hudson/scm/AbstractScmTagAction/badge.jelly b/core/src/main/resources/hudson/scm/AbstractScmTagAction/badge.jelly index 05010a6bbd1f..2cc2b6391ee0 100644 --- a/core/src/main/resources/hudson/scm/AbstractScmTagAction/badge.jelly +++ b/core/src/main/resources/hudson/scm/AbstractScmTagAction/badge.jelly @@ -25,6 +25,6 @@ THE SOFTWARE. - + \ No newline at end of file diff --git a/core/src/main/resources/hudson/views/BuildButtonColumn/column.jelly b/core/src/main/resources/hudson/views/BuildButtonColumn/column.jelly index 3d63a564d9ac..df10b841083c 100644 --- a/core/src/main/resources/hudson/views/BuildButtonColumn/column.jelly +++ b/core/src/main/resources/hudson/views/BuildButtonColumn/column.jelly @@ -39,7 +39,7 @@ THE SOFTWARE. - + diff --git a/core/src/main/resources/hudson/widgets/HistoryWidget/entry.jelly b/core/src/main/resources/hudson/widgets/HistoryWidget/entry.jelly index 5ed47248ad6d..5d92cd6b4c93 100644 --- a/core/src/main/resources/hudson/widgets/HistoryWidget/entry.jelly +++ b/core/src/main/resources/hudson/widgets/HistoryWidget/entry.jelly @@ -38,7 +38,9 @@ THE SOFTWARE. diff --git a/core/src/main/resources/jenkins/widgets/HistoryPageFilter/queue-items.jelly b/core/src/main/resources/jenkins/widgets/HistoryPageFilter/queue-items.jelly index e12d49057457..498bc31767ff 100644 --- a/core/src/main/resources/jenkins/widgets/HistoryPageFilter/queue-items.jelly +++ b/core/src/main/resources/jenkins/widgets/HistoryPageFilter/queue-items.jelly @@ -58,7 +58,7 @@ THE SOFTWARE.
- +
diff --git a/core/src/main/resources/lib/form/repeatableDeleteButton.jelly b/core/src/main/resources/lib/form/repeatableDeleteButton.jelly index f18caa153b59..2a1a97df4b22 100644 --- a/core/src/main/resources/lib/form/repeatableDeleteButton.jelly +++ b/core/src/main/resources/lib/form/repeatableDeleteButton.jelly @@ -32,7 +32,7 @@ THE SOFTWARE. - diff --git a/core/src/main/resources/lib/hudson/buildHealth.jelly b/core/src/main/resources/lib/hudson/buildHealth.jelly index 7f1fc6d3eade..ffee70f05fb8 100644 --- a/core/src/main/resources/lib/hudson/buildHealth.jelly +++ b/core/src/main/resources/lib/hudson/buildHealth.jelly @@ -38,12 +38,40 @@ THE SOFTWARE. ${buildHealth.score} jenkins-table__cell--tight jenkins-table__icon healthReport + + +
+ + + + + + + + + + + + + + + + + +
W${%Description}%
+
+ +
+
${rpt.localizableDescription}${rpt.score}
+
+
+
- + @@ -52,31 +80,5 @@ THE SOFTWARE.
- -
- - - - - - - - - - - - - - - - - -
W${%Description}%
-
- -
-
${rpt.localizableDescription}${rpt.score}
-
-
diff --git a/core/src/main/resources/lib/hudson/queue.jelly b/core/src/main/resources/lib/hudson/queue.jelly index 0c266710a738..1b6d66d2eefa 100644 --- a/core/src/main/resources/lib/hudson/queue.jelly +++ b/core/src/main/resources/lib/hudson/queue.jelly @@ -74,7 +74,7 @@ THE SOFTWARE. - + diff --git a/core/src/main/resources/lib/layout/helpIcon.jelly b/core/src/main/resources/lib/layout/helpIcon.jelly index b7b1dc03baea..729fd61ce3de 100644 --- a/core/src/main/resources/lib/layout/helpIcon.jelly +++ b/core/src/main/resources/lib/layout/helpIcon.jelly @@ -19,7 +19,7 @@ - diff --git a/core/src/main/resources/lib/layout/icon.jelly b/core/src/main/resources/lib/layout/icon.jelly index a1e1083ba975..2b519fff5e02 100644 --- a/core/src/main/resources/lib/layout/icon.jelly +++ b/core/src/main/resources/lib/layout/icon.jelly @@ -43,12 +43,13 @@ THE SOFTWARE. onclick handler. Deprecated; assign an ID and look up the element that way to attach event handlers. - title, deprecated use tooltip instead, but beware of its support for HTML + title, deprecated use tooltip instead, or htmlTooltip if you intend to pass HTML. style - tooltip (supports HTML for PNG and symbol icons). - Make sure to call h.htmlAttributeEscape on all user-specified parts of the value to prevent cross-site scripting. - Icons based on classic (non-symbol) SVG do not support HTML tooltips due to how SECURITY-1955 was fixed in Jenkins 2.252 and 2.235.4, but since such icons can be upgraded to symbols, it is important to still escape user-specified parts of the text (resulting in double escaping while the icon is based on classic SVG). + Adds a tooltip to the icon, ignores HTML except 'br' tags (but '\n' should be preferred for line breaks). + + Tooltip but with HTML support. Make sure to call h.htmlAttributeEscape on all user-specified parts of the value to prevent cross-site scripting. + Use 'tooltip' if you don't need to pass HTML. alt, adds invisible text suitable for screen-readers for symbols, sets the alt attribute for normal images @@ -71,6 +72,7 @@ THE SOFTWARE. + @@ -96,7 +98,7 @@ THE SOFTWARE. ${attrs.alt} + alt="${attrs.alt}" width="${attrs.width}" onclick="${attrs.onclick}" tooltip="${attrs.tooltip}" data-html-tooltip="${attrs.htmlTooltip}" id="${attrs.id}" /> diff --git a/core/src/main/resources/lib/layout/svgIcon.jelly b/core/src/main/resources/lib/layout/svgIcon.jelly index bbf5210824c1..ee58d4bb7dd2 100644 --- a/core/src/main/resources/lib/layout/svgIcon.jelly +++ b/core/src/main/resources/lib/layout/svgIcon.jelly @@ -42,7 +42,7 @@ style="${attrs.style}" onclick="${attrs.onclick}" id="${attrs.id}" - tooltip="${attrs.tooltip != null ? h.xmlEscape(attrs.tooltip) : null}"> + tooltip="${attrs.tooltip != null ? attrs.tooltip : null}"> diff --git a/core/src/test/java/org/jenkins/ui/icon/IconSetJenkins68805Test.java b/core/src/test/java/org/jenkins/ui/icon/IconSetJenkins68805Test.java index 19dd378b0d6e..ab6db4dd8c84 100644 --- a/core/src/test/java/org/jenkins/ui/icon/IconSetJenkins68805Test.java +++ b/core/src/test/java/org/jenkins/ui/icon/IconSetJenkins68805Test.java @@ -19,12 +19,12 @@ public class IconSetJenkins68805Test { @Issue("JENKINS-68805") void getSymbol_notSettingTooltipDoesntAddTooltipAttribute_evenWithAmpersand() { // cache a symbol with tooltip containing ampersand: - String symbolWithTooltip = IconSet.getSymbol("download", "Title", "With&Ampersand", "class1 class2", "", "id"); + String symbolWithTooltip = IconSet.getSymbol("download", "Title", "With&Ampersand", "", "class1 class2", "", "id"); assertThat(symbolWithTooltip, containsString("tooltip")); assertThat(symbolWithTooltip, containsString("With&")); // Same symbol, no tooltip - String symbolWithoutTooltip = IconSet.getSymbol("download", "Title", "", "class1 class2", "", "id"); + String symbolWithoutTooltip = IconSet.getSymbol("download", "Title", "", "", "class1 class2", "", "id"); assertThat(symbolWithoutTooltip, not(containsString("tooltip"))); } diff --git a/core/src/test/java/org/jenkins/ui/icon/IconSetTest.java b/core/src/test/java/org/jenkins/ui/icon/IconSetTest.java index f8d75c03cb11..cce22685cdac 100644 --- a/core/src/test/java/org/jenkins/ui/icon/IconSetTest.java +++ b/core/src/test/java/org/jenkins/ui/icon/IconSetTest.java @@ -21,7 +21,7 @@ void testIconSetSize() { @Test void getSymbol() { - String symbol = IconSet.getSymbol("download", "Title", "Tooltip", "class1 class2", "", "id"); + String symbol = IconSet.getSymbol("download", "Title", "Tooltip", "", "class1 class2", "", "id"); assertThat(symbol, containsString("Title")); assertThat(symbol, containsString("tooltip=\"Tooltip\"")); @@ -31,8 +31,8 @@ void getSymbol() { @Test void getSymbol_cachedSymbolDoesntReturnAttributes() { - IconSet.getSymbol("download", "Title", "Tooltip", "class1 class2", "", "id"); - String symbol = IconSet.getSymbol("download", "", "", "", "", ""); + IconSet.getSymbol("download", "Title", "Tooltip", "", "class1 class2", "", "id"); + String symbol = IconSet.getSymbol("download", "", "", "", "", "", ""); assertThat(symbol, not(containsString("Title"))); assertThat(symbol, not(containsString("tooltip=\"Tooltip\""))); @@ -43,8 +43,8 @@ void getSymbol_cachedSymbolDoesntReturnAttributes() { @Test void getSymbol_cachedSymbolAllowsSettingAllAttributes() { - IconSet.getSymbol("download", "Title", "Tooltip", "class1 class2", "", "id"); - String symbol = IconSet.getSymbol("download", "Title2", "Tooltip2", "class3 class4", "", "id2"); + IconSet.getSymbol("download", "Title", "Tooltip", "", "class1 class2", "", "id"); + String symbol = IconSet.getSymbol("download", "Title2", "Tooltip2", "", "class3 class4", "", "id2"); assertThat(symbol, not(containsString("Title"))); assertThat(symbol, not(containsString("tooltip=\"Tooltip\""))); @@ -62,7 +62,7 @@ void getSymbol_cachedSymbolAllowsSettingAllAttributes() { */ @Test void getSymbol_notSettingTooltipDoesntAddTooltipAttribute() { - String symbol = IconSet.getSymbol("download", "Title", "", "class1 class2", "", "id"); + String symbol = IconSet.getSymbol("download", "Title", "", "", "class1 class2", "", "id"); assertThat(symbol, not(containsString("tooltip"))); } diff --git a/test/src/test/java/hudson/model/QueueTest.java b/test/src/test/java/hudson/model/QueueTest.java index b78ce5a6dfd1..ce3e70dfb815 100644 --- a/test/src/test/java/hudson/model/QueueTest.java +++ b/test/src/test/java/hudson/model/QueueTest.java @@ -46,12 +46,10 @@ import com.gargoylesoftware.htmlunit.HttpMethod; import com.gargoylesoftware.htmlunit.Page; +import com.gargoylesoftware.htmlunit.ScriptResult; import com.gargoylesoftware.htmlunit.WebRequest; import com.gargoylesoftware.htmlunit.html.DomElement; import com.gargoylesoftware.htmlunit.html.DomNode; -import com.gargoylesoftware.htmlunit.html.DomNodeList; -import com.gargoylesoftware.htmlunit.html.HtmlAnchor; -import com.gargoylesoftware.htmlunit.html.HtmlElement; import com.gargoylesoftware.htmlunit.html.HtmlFileInput; import com.gargoylesoftware.htmlunit.html.HtmlForm; import com.gargoylesoftware.htmlunit.html.HtmlFormUtil; @@ -1272,14 +1270,11 @@ private String buildAndExtractTooltipAttribute() throws Exception { HtmlPage page = wc.goTo(""); - DomElement buildQueue = page.getElementById("buildQueue"); - DomNodeList anchors = buildQueue.getElementsByTagName("a"); - HtmlAnchor anchorWithTooltip = (HtmlAnchor) anchors.stream() - .filter(a -> a.getAttribute("tooltip") != null && !a.getAttribute("tooltip").isEmpty()) - .findFirst().orElseThrow(IllegalStateException::new); + page.executeJavaScript("document.querySelector('#buildQueue a[tooltip]:not([tooltip=\"\"])')._tippy.show()"); + wc.waitForBackgroundJavaScript(1000); + ScriptResult result = page.executeJavaScript("document.querySelector('.tippy-content').innerHTML;"); - String tooltip = anchorWithTooltip.getAttribute("tooltip"); - return tooltip; + return result.getJavaScriptResult().toString(); } public static class BrokenAffinityKeyProject extends Project implements TopLevelItem { diff --git a/test/src/test/java/hudson/model/RunTest.java b/test/src/test/java/hudson/model/RunTest.java index d828c73b2964..c5200c7aef53 100644 --- a/test/src/test/java/hudson/model/RunTest.java +++ b/test/src/test/java/hudson/model/RunTest.java @@ -157,9 +157,9 @@ private void ensureXssIsPrevented(FreeStyleProject upProject, String validationP HtmlPage htmlPage = wc.goTo(upProject.getUrl()); // trigger the tooltip display - htmlPage.executeJavaScript("document.querySelector('#buildHistory table .build-badge img').dispatchEvent(new Event('mouseover'));"); + htmlPage.executeJavaScript("document.querySelector('#buildHistory table .build-badge img')._tippy.show()"); wc.waitForBackgroundJavaScript(500); - ScriptResult result = htmlPage.executeJavaScript("document.querySelector('#tt').innerHTML;"); + ScriptResult result = htmlPage.executeJavaScript("document.querySelector('.tippy-content').innerHTML;"); Object jsResult = result.getJavaScriptResult(); assertThat(jsResult, instanceOf(String.class)); String jsResultString = (String) jsResult; diff --git a/test/src/test/java/hudson/scm/AbstractScmTagActionTest.java b/test/src/test/java/hudson/scm/AbstractScmTagActionTest.java index b9fecd1223ff..298aa8878e4e 100644 --- a/test/src/test/java/hudson/scm/AbstractScmTagActionTest.java +++ b/test/src/test/java/hudson/scm/AbstractScmTagActionTest.java @@ -24,10 +24,6 @@ package hudson.scm; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.containsString; -import static org.hamcrest.Matchers.not; -import static org.hamcrest.Matchers.startsWith; import static org.junit.Assert.assertEquals; import com.gargoylesoftware.htmlunit.html.DomElement; @@ -45,7 +41,6 @@ import java.io.File; import org.junit.Rule; import org.junit.Test; -import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.JenkinsRule; public class AbstractScmTagActionTest { @@ -66,19 +61,6 @@ public void regularTextDisplayedCorrectly() throws Exception { assertEquals(tagToKeep, tooltip); } - @Test - @Issue("SECURITY-1537") - public void preventXssInTagAction() throws Exception { - FreeStyleProject p = j.createFreeStyleProject(); - p.setScm(new FakeSCM("XSS")); - - j.buildAndAssertSuccess(p); - - String tooltip = buildAndExtractTooltipAttribute(p); - assertThat(tooltip, not(containsString("<"))); - assertThat(tooltip, startsWith("<")); - } - private String buildAndExtractTooltipAttribute(FreeStyleProject p) throws Exception { JenkinsRule.WebClient wc = j.createWebClient(); diff --git a/test/src/test/java/jenkins/security/Security2776Test.java b/test/src/test/java/jenkins/security/Security2776Test.java index 007950497c4d..dabdb8e8aa09 100644 --- a/test/src/test/java/jenkins/security/Security2776Test.java +++ b/test/src/test/java/jenkins/security/Security2776Test.java @@ -1,12 +1,12 @@ package jenkins.security; -import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import com.gargoylesoftware.htmlunit.ScriptResult; import com.gargoylesoftware.htmlunit.html.HtmlPage; -import hudson.Util; +import hudson.Functions; import hudson.model.InvisibleAction; import hudson.model.UnprotectedRootAction; import java.io.IOException; @@ -25,29 +25,35 @@ public class Security2776Test { @Test public void escapedTooltipIsEscaped() throws Exception { - assertExpectedBehaviorForTooltip("#symbol-icons .unsafe svg", _getUnsafeTooltip(), true); - assertExpectedBehaviorForTooltip("#symbol-icons .safe svg", _getSafeTooltip(), false); - assertExpectedBehaviorForTooltip("#png-icons .unsafe img", _getUnsafeTooltip(), true); - assertExpectedBehaviorForTooltip("#png-icons .safe img", _getSafeTooltip(), false); + assertExpectedBehaviorForTooltip("#symbol-icons .unsafe svg", + "<img src=\"x\" onerror=\"alert(1)\">"); + assertExpectedBehaviorForTooltip("#symbol-icons .safe svg", + Functions.htmlAttributeEscape(_getSafeTooltip())); + assertExpectedBehaviorForTooltip("#png-icons .unsafe img", + "<img src=\"x\" onerror=\"alert(1)\">"); + assertExpectedBehaviorForTooltip("#png-icons .safe img", + Functions.htmlAttributeEscape(_getSafeTooltip())); // Outlier after the fix for SECURITY-1955 - assertExpectedBehaviorForTooltip("#svgIcons .unsafe svg", _getSafeTooltip(), false); - assertExpectedBehaviorForTooltip("#svgIcons .safe svg", Util.xmlEscape(_getSafeTooltip()), false); + assertExpectedBehaviorForTooltip("#svgIcons .unsafe svg", + "<img src=\"x\" onerror=\"alert(1)\">"); + assertExpectedBehaviorForTooltip("#svgIcons .safe svg", + "&lt;img src=&quot;x&quot; onerror=&quot;alert(1)&quot;&gt;"); } - private void assertExpectedBehaviorForTooltip(String selector, String expectedTooltipContent, boolean alertExpected) throws IOException, SAXException { + private void assertExpectedBehaviorForTooltip(String selector, String expectedResult) throws IOException, SAXException { final AtomicBoolean alerts = new AtomicBoolean(); final JenkinsRule.WebClient wc = j.createWebClient(); wc.setAlertHandler((p, s) -> alerts.set(true)); final HtmlPage page = wc.goTo(URL_NAME); - page.executeJavaScript("document.querySelector('" + selector + "').dispatchEvent(new Event('mouseover'));"); + page.executeJavaScript("document.querySelector('" + selector + "')._tippy.show()"); wc.waitForBackgroundJavaScript(2000L); - ScriptResult result = page.executeJavaScript("document.querySelector('#tt').innerHTML;"); + ScriptResult result = page.executeJavaScript("document.querySelector('.tippy-content').innerHTML;"); Object jsResult = result.getJavaScriptResult(); assertThat(jsResult, instanceOf(String.class)); String jsResultString = (String) jsResult; - assertThat(jsResultString, containsString(expectedTooltipContent)); - Assert.assertEquals(alertExpected ? "Alert expected" : "No alert expected", alertExpected, alerts.get()); + assertThat(jsResultString, is(expectedResult)); + Assert.assertFalse("No alert expected", alerts.get()); } private static String _getUnsafeTooltip() { @@ -55,7 +61,7 @@ private static String _getUnsafeTooltip() { } private static String _getSafeTooltip() { - return Util.xmlEscape(_getUnsafeTooltip()); + return Functions.htmlAttributeEscape(_getUnsafeTooltip()); } @TestExtension diff --git a/test/src/test/java/jenkins/security/Security2779Test.java b/test/src/test/java/jenkins/security/Security2779Test.java index d981ff8b3b30..34ee491bb902 100644 --- a/test/src/test/java/jenkins/security/Security2779Test.java +++ b/test/src/test/java/jenkins/security/Security2779Test.java @@ -37,22 +37,19 @@ private void noCrossSiteScriptingInHelp(String selector) throws Exception { final JenkinsRule.WebClient webClient = j.createWebClient(); webClient.setAlertHandler((AlertHandler) (p, s) -> alerts.addAndGet(1)); final HtmlPage page = webClient.goTo(URL_NAME); - final ScriptResult eventScript = page.executeJavaScript("document.querySelector('" + selector + "').dispatchEvent(new Event('mouseover'))"); - final Object eventResult = eventScript.getJavaScriptResult(); - assertThat(eventResult, instanceOf(boolean.class)); - Assert.assertTrue((boolean) eventResult); + page.executeJavaScript("document.querySelector('" + selector + "')._tippy.show()"); webClient.waitForBackgroundJavaScript(2000); // Assertion includes the selector for easier diagnosis Assert.assertEquals("Alert with selector '" + selector + "'", 0, alerts.get()); - final ScriptResult innerHtmlScript = page.executeJavaScript("document.querySelector('#tt').innerHTML"); + final ScriptResult innerHtmlScript = page.executeJavaScript("document.querySelector('.tippy-content').innerHTML"); Object jsResult = innerHtmlScript.getJavaScriptResult(); assertThat(jsResult, instanceOf(String.class)); String jsResultString = (String) jsResult; // assert leading space to identify unintentional double-escaping (&lt;) as test failure assertThat("tooltip does not contain dangerous HTML", jsResultString, not(containsString(" alertTriggered.set(true)); HtmlPage page = wc.goTo(""); - page.executeJavaScript("document.querySelector('a.jenkins-table__button').dispatchEvent(new Event('mouseover'));"); + page.executeJavaScript("document.querySelector('a.jenkins-table__button')._tippy.show()"); wc.waitForBackgroundJavaScript(2000L); - ScriptResult result = page.executeJavaScript("document.querySelector('#tt').innerHTML;"); + ScriptResult result = page.executeJavaScript("document.querySelector('.tippy-content').innerHTML;"); Object jsResult = result.getJavaScriptResult(); assertThat(jsResult, instanceOf(String.class)); String jsResultString = (String) jsResult; diff --git a/test/src/test/java/lib/form/RepeatableTest.java b/test/src/test/java/lib/form/RepeatableTest.java index 1536a0d34559..ab8adc62f10a 100644 --- a/test/src/test/java/lib/form/RepeatableTest.java +++ b/test/src/test/java/lib/form/RepeatableTest.java @@ -639,7 +639,7 @@ private static HtmlButton getHtmlButton(HtmlForm form, String buttonCaption, boo */ private static List getButtonsList(HtmlForm form, String buttonCaption) { return form.getByXPath( - String.format("//button[text() = '%s'] | //button[@title = '%s']", buttonCaption, buttonCaption + String.format("//button[text() = '%s'] | //button[@tooltip = '%s']", buttonCaption, buttonCaption, buttonCaption ) ); } diff --git a/test/src/test/java/lib/layout/SvgIconTest.java b/test/src/test/java/lib/layout/SvgIconTest.java index c4f4219a456b..1e6ac6dd6b2d 100644 --- a/test/src/test/java/lib/layout/SvgIconTest.java +++ b/test/src/test/java/lib/layout/SvgIconTest.java @@ -66,9 +66,7 @@ public void onlyQuotesAreEscaped() throws Exception { String pristineTooltip = "Special tooltip with double quotes \", simple quotes ', and html characters <>&."; - // Escaped twice, once per new h.xmlEscape then once per Jelly. - // But as the tooltip lib interprets HTML, it's fine, the tooltip displays the original values without interpreting them - String expectedTooltip = "Special tooltip with double quotes ", simple quotes ', and html characters &lt;&gt;&amp;."; + String expectedTooltip = "Special tooltip with double quotes ", simple quotes ', and html characters <>&."; testRootAction.tooltipContent = pristineTooltip; HtmlPage p = j.createWebClient().goTo(testRootAction.getUrlName()); @@ -106,9 +104,9 @@ private void ensureXssIsPrevented(TestRootAction testRootAction, String validati String jsControlString = (String) jsControlResult; assertThat("The title attribute is not populated", jsControlString, containsString(validationPart)); - page.executeJavaScript("document.querySelector('#test-panel svg').dispatchEvent(new Event('mouseover'));"); + page.executeJavaScript("document.querySelector('#test-panel svg')._tippy.show()"); wc.waitForBackgroundJavaScript(1000); - ScriptResult result = page.executeJavaScript("document.querySelector('#tt').innerHTML;"); + ScriptResult result = page.executeJavaScript("document.querySelector('.tippy-content').innerHTML;"); Object jsResult = result.getJavaScriptResult(); assertThat(jsResult, instanceOf(String.class)); String jsResultString = (String) jsResult; diff --git a/war/package.json b/war/package.json index 8ab27f9dc712..cd080eb57cdf 100644 --- a/war/package.json +++ b/war/package.json @@ -58,6 +58,7 @@ "postcss-less": "6.0.0", "sortablejs": "1.15.0", "stylelint-checkstyle-reporter": "0.2.0", + "tippy.js": "^6.3.7", "window-handle": "1.0.1" }, "browserslist": [ diff --git a/war/src/main/js/app.js b/war/src/main/js/app.js index 65c4e0f33ff0..afdb0d1a7b84 100644 --- a/war/src/main/js/app.js +++ b/war/src/main/js/app.js @@ -1,3 +1,5 @@ import Notifications from "@/components/notifications"; +import Tooltips from "@/components/tooltips"; Notifications.init(); +Tooltips.init(); diff --git a/war/src/main/js/components/tooltips/index.js b/war/src/main/js/components/tooltips/index.js new file mode 100644 index 000000000000..88233044c65d --- /dev/null +++ b/war/src/main/js/components/tooltips/index.js @@ -0,0 +1,124 @@ +import tippy from "tippy.js"; +import behaviorShim from "@/util/behavior-shim"; + +const TOOLTIP_BASE = { + arrow: false, + theme: "tooltip", + animation: "tooltip", + appendTo: document.body, +}; + +let tooltipInstances = []; +const globalPlugin = { + fn(instance) { + return { + onCreate() { + tooltipInstances = tooltipInstances.concat(instance); + }, + onDestroy() { + tooltipInstances = tooltipInstances.filter((i) => i !== instance); + }, + }; + }, +}; + +tippy.setDefaultProps({ + plugins: [globalPlugin], +}); + +/** + * Registers tooltips for the page + * If called again, destroys existing tooltips and registers them again (useful for progressive rendering) + * @param {HTMLElement} container - Registers the tooltips for the given container + */ +function registerTooltips(container) { + if (!container) { + container = document; + } + + tooltipInstances.forEach((instance) => { + if (instance.props.container === container) { + instance.destroy(); + } + }); + + tippy( + container.querySelectorAll( + '[tooltip]:not([tooltip=""]):not([data-html-tooltip])' + ), + Object.assign( + { + content: (element) => + element.getAttribute("tooltip").replace(/|\\n/g, "\n"), + container: container, + onCreate(instance) { + instance.reference.setAttribute("title", instance.props.content); + }, + onShow(instance) { + instance.reference.removeAttribute("title"); + }, + onHidden(instance) { + instance.reference.setAttribute("title", instance.props.content); + }, + }, + TOOLTIP_BASE + ) + ); + + tippy( + container.querySelectorAll("[data-html-tooltip]"), + Object.assign( + { + content: (element) => element.getAttribute("data-html-tooltip"), + allowHTML: true, + container: container, + onCreate(instance) { + instance.props.interactive = + instance.reference.getAttribute("data-tooltip-interactive") === + "true"; + }, + }, + TOOLTIP_BASE + ) + ); +} + +/** + * Displays a tooltip for three seconds on the provided element after interaction + * @param {string} text - The tooltip text + * @param {HTMLElement} element - The element to show the tooltip + */ +function hoverNotification(text, element) { + const tooltip = tippy( + element, + Object.assign( + { + trigger: "hover", + offset: [0, 0], + content: text, + onShow(instance) { + setTimeout(() => { + instance.hide(); + }, 3000); + }, + }, + TOOLTIP_BASE + ) + ); + tooltip.show(); +} + +function init() { + behaviorShim.specify( + "[tooltip], [data-html-tooltip]", + "-tooltip-", + 1000, + function () { + registerTooltips(null); + } + ); + + window.hoverNotification = hoverNotification; +} + +export default { init }; diff --git a/war/src/main/less/abstracts/theme.less b/war/src/main/less/abstracts/theme.less index 0d038a10834a..c3c56a12b217 100644 --- a/war/src/main/less/abstracts/theme.less +++ b/war/src/main/less/abstracts/theme.less @@ -162,9 +162,9 @@ --link-font-weight: 600; // Tooltips - --tooltip-background-color: var(--background); - --tooltip-foreground-color: var(--text-color); - --tooltip-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.05), 0 2px 2px rgba(0, 0, 0, 0.05), 0 10px 20px rgba(0, 0, 0, 0.2); + --tooltip-backdrop-filter: contrast(0.6) brightness(2.4) saturate(2) blur(15px); + --tooltip-color: var(--text-color); + --tooltip-box-shadow: 0 0 8px 2px rgba(0, 0, 30, 0.05), 0 0 1px 1px rgba(0, 0, 20, 0.025), 0 10px 20px rgba(0, 0, 20, 0.15); // Dark link --link-dark-color: var(--text-color); diff --git a/war/src/main/less/base/layout-commons.less b/war/src/main/less/base/layout-commons.less index 3f14cff424d7..b969db8c6839 100644 --- a/war/src/main/less/base/layout-commons.less +++ b/war/src/main/less/base/layout-commons.less @@ -1,7 +1,6 @@ @import url("../abstracts/theme.less"); html { - position: relative; height: 100%; box-sizing: border-box; } diff --git a/war/src/main/less/modules/table.less b/war/src/main/less/modules/table.less index 4f834471d7dd..e5755089d352 100644 --- a/war/src/main/less/modules/table.less +++ b/war/src/main/less/modules/table.less @@ -49,16 +49,22 @@ width: 24px; } } + + svg { + vertical-align: middle; + width: 0.8rem; + height: 0.8rem; + } } } } & > tbody { & > tr { - background: var(--table-body-background); color: var(--table-body-foreground); & > td { + background: var(--table-body-background); vertical-align: middle; padding: var(--table-padding) 0 var(--table-padding) var(--table-padding); diff --git a/war/src/main/less/modules/tooltips.less b/war/src/main/less/modules/tooltips.less index 86b8bd25d70c..9b1535d91832 100644 --- a/war/src/main/less/modules/tooltips.less +++ b/war/src/main/less/modules/tooltips.less @@ -1,12 +1,53 @@ -.jenkins-tooltip { - position: absolute; - padding: 5px 10px; - border-radius: 10px; - background: var(--tooltip-background-color); - box-shadow: var(--tooltip-shadow); - color: var(--tooltip-foreground-color); - font-size: 0.8rem; - z-index: 1001 !important; - overflow: hidden; - max-width: none !important; +.tippy-box[data-theme~="tooltip"] { + color: var(--tooltip-color); + padding: 0.45rem 0.8rem; + border-radius: 0.66rem; + box-shadow: var(--tooltip-box-shadow); + font-weight: 550; + font-size: 0.75rem; + line-height: 1.6; + max-width: ~"min(50vw, 1000px)" !important; + white-space: pre-line; + z-index: 0; + backdrop-filter: var(--tooltip-backdrop-filter); + + .tippy-content { + padding: 0; + } + + // We style tables as they have additional margin/border radius when in tooltips + .jenkins-tooltip--table-wrapper { + background-color: rgba(black, 0.05); + margin: -0.45rem -0.8rem; + border-radius: 0.6rem; + } + + .jenkins-table { + --table-background: transparent; + --table-border-radius: 8px; + + margin: 0; + width: 450px; + } +} + +.tippy-box[data-animation="tooltip"][data-state="hidden"] { + opacity: 0; + transform: scale(0.995); + + &[data-placement^="top"] { + transform-origin: bottom; + transform: translateY(2px) scale(0.995); + } + + &[data-placement^="bottom"] { + transform-origin: top; + transform: translateY(-2px) scale(0.995); + } +} + +// Workaround for NG Warnings which supports modern Tippy tooltips and a custom solution, +// hide the custom solution +.jenkins-table .healthReportDetails { + display: none !important; } diff --git a/war/src/main/webapp/scripts/hudson-behavior.js b/war/src/main/webapp/scripts/hudson-behavior.js index ac4e7ab5da3e..ee337bb10763 100644 --- a/war/src/main/webapp/scripts/hudson-behavior.js +++ b/war/src/main/webapp/scripts/hudson-behavior.js @@ -539,9 +539,6 @@ function fireEvent(element, event) { } } -// shared tooltip object -var tooltip; - // Behavior rules //======================================================== // using tag names in CSS selector makes the processing faster @@ -1120,10 +1117,6 @@ function rowvgStartEachRow(recursive, f) { (function () { var p = 20; - Behaviour.specify("BODY", "body", ++p, function () { - tooltip = new YAHOO.widget.Tooltip("tt", { context: [], zindex: 999 }); - }); - Behaviour.specify("TABLE.sortable", "table-sortable", ++p, function (e) { // sortable table e.sortable = new Sortable.Sortable(e); @@ -1419,12 +1412,6 @@ function rowvgStartEachRow(recursive, f) { form = null; // memory leak prevention }); - // hook up tooltip. - // add nodismiss="" if you'd like to display the tooltip forever as long as the mouse is on the element. - Behaviour.specify("[tooltip]", "-tooltip-", ++p, function (e) { - applyTooltip(e, e.getAttribute("tooltip")); - }); - Behaviour.specify( "INPUT.submit-button", "input-submit-button", @@ -1802,36 +1789,6 @@ var hudsonRules = {}; // legacy name // now empty, but plugins can stuff things in here later: Behaviour.register(hudsonRules); -function applyTooltip(e, text) { - // copied from YAHOO.widget.Tooltip.prototype.configContext to efficiently add a new element - // event registration via YAHOO.util.Event.addListener leaks memory, so do it by ourselves here - e.onmouseover = function (ev) { - var delay = this.getAttribute("nodismiss") != null ? 99999999 : 5000; - tooltip.cfg.setProperty("autodismissdelay", delay); - return tooltip.onContextMouseOver.call( - this, - YAHOO.util.Event.getEvent(ev), - tooltip - ); - }; - e.onmousemove = function (ev) { - return tooltip.onContextMouseMove.call( - this, - YAHOO.util.Event.getEvent(ev), - tooltip - ); - }; - e.onmouseout = function (ev) { - return tooltip.onContextMouseOut.call( - this, - YAHOO.util.Event.getEvent(ev), - tooltip - ); - }; - e.title = text; - e = null; // avoid memory leak -} - var Path = { tail: function (p) { var idx = p.lastIndexOf("/"); @@ -2514,55 +2471,6 @@ function buildFormTree(form) { } } -var hoverNotification = (function () { - var msgBox; - var body; - - // animation effect that automatically hide the message box - var effect = function (overlay, dur) { - var o = YAHOO.widget.ContainerEffect.FADE(overlay, dur); - o.animateInCompleteEvent.subscribe(function () { - window.setTimeout(function () { - msgBox.hide(); - }, 1500); - }); - return o; - }; - - function init() { - if (msgBox != null) return; // already initialized - - var div = document.createElement("DIV"); - document.body.appendChild(div); - div.innerHTML = - "
"; - body = $("hoverNotification"); - - msgBox = new YAHOO.widget.Overlay(body, { - visible: false, - zIndex: 1000, - effect: { - effect: effect, - duration: 0.25, - }, - }); - msgBox.render(); - } - - return function (title, anchor, offset) { - if (typeof offset === "undefined") { - offset = 48; - } - init(); - body.innerHTML = title; - var xy = YAHOO.util.Dom.getXY(anchor); - xy[0] += offset; - xy[1] += anchor.offsetHeight; - msgBox.cfg.setProperty("xy", xy); - msgBox.show(); - }; -})(); - // Decrease vertical padding for checkboxes window.addEventListener("load", function () { document.querySelectorAll(".jenkins-form-item").forEach(function (element) { diff --git a/war/src/main/webapp/scripts/prototype.js b/war/src/main/webapp/scripts/prototype.js index 90bf7b042711..b73962d7af9d 100644 --- a/war/src/main/webapp/scripts/prototype.js +++ b/war/src/main/webapp/scripts/prototype.js @@ -908,7 +908,7 @@ var Enumerable = (function() { function findAll(iterator, context) { var results = []; - this.each(function(value, index) { + this.forEach(function(value, index) { if (iterator.call(context, value, index)) results.push(value); }); @@ -1069,7 +1069,6 @@ var Enumerable = (function() { detect: detect, findAll: findAll, select: findAll, - filter: findAll, grep: grep, include: include, member: include, diff --git a/war/yarn.lock b/war/yarn.lock index 013360901b28..37e6011aa6a2 100644 --- a/war/yarn.lock +++ b/war/yarn.lock @@ -1635,6 +1635,13 @@ __metadata: languageName: node linkType: hard +"@popperjs/core@npm:^2.9.0": + version: 2.11.6 + resolution: "@popperjs/core@npm:2.11.6" + checksum: 47fb328cec1924559d759b48235c78574f2d71a8a6c4c03edb6de5d7074078371633b91e39bbf3f901b32aa8af9b9d8f82834856d2f5737a23475036b16817f0 + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.24.1": version: 0.24.44 resolution: "@sinclair/typebox@npm:0.24.44" @@ -4106,6 +4113,7 @@ __metadata: stylelint: 14.15.0 stylelint-checkstyle-reporter: 0.2.0 stylelint-config-standard: 29.0.0 + tippy.js: ^6.3.7 webpack: 5.75.0 webpack-cli: 5.0.0 webpack-remove-empty-scripts: 1.0.1 @@ -6410,6 +6418,15 @@ __metadata: languageName: node linkType: hard +"tippy.js@npm:^6.3.7": + version: 6.3.7 + resolution: "tippy.js@npm:6.3.7" + dependencies: + "@popperjs/core": ^2.9.0 + checksum: cac955318a65288e8d2dca05059878b003c6e66f92c94f7810f5bc5448eb6646abdf7dacc9bd00020e2611592598d0aae3a28ec9a45349a159603c3fdddce5fb + languageName: node + linkType: hard + "to-fast-properties@npm:^2.0.0": version: 2.0.0 resolution: "to-fast-properties@npm:2.0.0"