Replace YUI tooltips with Tippy.js (#6408)

Co-authored-by: Alexander Brandes <brandes.alexander@web.de>
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 <timjacomb1+github@gmail.com>
Co-authored-by: Tim Jacomb <21194782+timja@users.noreply.github.com>
Co-authored-by: Alexander Brandes <mc.cache@web.de>
Co-authored-by: Daniel Beck <daniel-beck@users.noreply.github.com>
Co-authored-by: Basil Crow <me@basilcrow.com>
Co-authored-by: Tim Jacomb <timjacomb1@gmail.com>
This commit is contained in:
Jan Faracik 2022-11-25 22:23:09 +00:00 committed by GitHub
parent 86786b035c
commit 221ff946b3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 309 additions and 220 deletions

View File

@ -85,7 +85,7 @@ public class IconSet {
// 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 class IconSet {
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("<svg", "<svg tooltip=\"" + Functions.htmlAttributeEscape(tooltip) + "\"");
}
if (!htmlTooltip.isEmpty()) {
symbol = symbol.replaceAll("<svg", "<svg data-html-tooltip=\"" + Functions.htmlAttributeEscape(htmlTooltip) + "\"");
}
if (!id.isEmpty()) {
symbol = symbol.replaceAll("<svg", "<svg id=\"" + Functions.htmlAttributeEscape(id) + "\"");
}
@ -124,10 +128,14 @@ public class IconSet {
symbol = symbol.replaceAll("(<title>).*(</title>)", "$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("<svg", "<svg tooltip=\"" + Functions.htmlAttributeEscape(tooltip) + "\"");
}
if (!htmlTooltip.isEmpty()) {
symbol = symbol.replaceAll("<svg", "<svg data-html-tooltip=\"" + Functions.htmlAttributeEscape(htmlTooltip) + "\"");
}
if (!id.isEmpty()) {
symbol = symbol.replaceAll("<svg", "<svg id=\"" + Functions.htmlAttributeEscape(id) + "\"");
}

View File

@ -23,4 +23,4 @@ THE SOFTWARE.
-->
<?jelly escape-by-default='true'?>
<l:icon class="icon-lock icon-sm" tooltip="${%Keep this build forever}:&lt;br/&gt;${h.xmlEscape(build.whyKeepLog)}" xmlns:l="/lib/layout"/>
<l:icon class="icon-lock icon-sm" tooltip="${%Keep this build forever}:\n${build.whyKeepLog}" xmlns:l="/lib/layout"/>

View File

@ -25,6 +25,6 @@ THE SOFTWARE.
<?jelly escape-by-default='true'?>
<j:if xmlns:j="jelly:core" xmlns:l="/lib/layout" test="${it.isTagged()}">
<a href="${rootURL}/${it.run.url}tagBuild/">
<l:icon class="icon-save icon-sm" tooltip="${h.escape(it.tooltip)}"/>
<l:icon class="icon-save icon-sm" tooltip="${it.tooltip}"/>
</a>
</j:if>

View File

@ -39,7 +39,7 @@ THE SOFTWARE.
</j:otherwise>
</j:choose>
<j:set var="isQueued" value="${app.queue.contains(job)}"/>
<a id="${id}" tooltip="${h.htmlAttributeEscape(title)}" class="jenkins-table__button jenkins-!-build-color" href="${href}">
<a id="${id}" tooltip="${title}" class="jenkins-table__button jenkins-!-build-color" href="${href}">
<span class="${isQueued ? 'pulse-animation': ''}">
<l:icon src="symbol-play" />
</span>

View File

@ -38,7 +38,9 @@ THE SOFTWARE.
<td class="build-row-cell">
<div class="pane build-name">
<div class="build-icon">
<a class="build-status-link" href="${link}console"><l:icon alt="${build.iconColor.description} &gt; ${%Console Output}" class="${build.buildStatusIconClassName} icon-sm" tooltip="${build.iconColor.description} &gt; ${%Console Output}"/></a>
<a class="build-status-link" href="${link}console" tooltip="${build.iconColor.description} > ${%Console Output}">
<l:icon class="${build.buildStatusIconClassName} icon-sm" />
</a>
</div>
<a class="model-link inside build-link display-name" update-parent-class=".build-row" href="${link}">${build.displayName}</a>
</div>

View File

@ -58,7 +58,7 @@ THE SOFTWARE.
</j:choose>
<j:if test="${!item.params.isEmpty()}">
<div style="float:right;margin-right:10px;">
<a href="#" tooltip="Build Parameters:${h.escape(item.params)}"><l:icon class="icon-notepad icon-sm" /></a>
<a href="#" tooltip="Build Parameters:${item.params}"><l:icon class="icon-notepad icon-sm" /></a>
</div>
</j:if>
</div>

View File

@ -32,7 +32,7 @@ THE SOFTWARE.
</st:attribute>
</st:documentation>
<st:adjunct includes="lib.form.repeatable.repeatable"/>
<button tooltip="${h.xmlEscape(attrs.value ?: '%Delete')}" class="repeatable-delete danger" type="button">
<button tooltip="${attrs.value ?: '%Delete'}" class="repeatable-delete danger" type="button">
<l:icon src="symbol-close" />
</button>
</j:jelly>

View File

@ -38,12 +38,40 @@ THE SOFTWARE.
<x:element name="${useTdElement!=null?'td':'div'}">
<x:attribute name="data">${buildHealth.score}</x:attribute>
<x:attribute name="class">jenkins-table__cell--tight jenkins-table__icon healthReport</x:attribute>
<j:if test="${!empty(healthReports)}">
<x:attribute name="data-html-tooltip">
<div class="jenkins-tooltip--table-wrapper">
<table class="jenkins-table">
<thead>
<tr>
<th class="jenkins-!-padding-left-0" align="center">W</th>
<th align="left">${%Description}</th>
<th align="right">%</th>
</tr>
</thead>
<tbody>
<j:forEach var="rpt" items="${healthReports}">
<tr>
<td align="left" class="jenkins-table__cell--tight jenkins-table__icon">
<div class="jenkins-table__cell__button-wrapper">
<l:icon src="symbol-weather-${buildHealth.iconClassName}" />
</div>
</td>
<td align="left">${rpt.localizableDescription}</td>
<td align="right">${rpt.score}</td>
</tr>
</j:forEach>
</tbody>
</table>
</div>
</x:attribute>
</j:if>
<j:if test="${buildHealth!=null}">
<div class="jenkins-table__cell__button-wrapper">
<j:choose>
<j:when test="${!empty(healthReports)}">
<a class="build-health-link jenkins-table__button" href="${empty(link)?'#':link}" style="${attrs.style}">
<l:icon src="symbol-weather-${buildHealth.iconClassName}" />
<l:icon src="symbol-weather-${buildHealth.iconClassName}" />
</a>
</j:when>
<j:otherwise>
@ -52,31 +80,5 @@ THE SOFTWARE.
</j:choose>
</div>
</j:if>
<j:if test="${!empty(healthReports)}">
<div class="jenkins-tooltip healthReportDetails">
<table class="jenkins-table">
<thead>
<tr>
<th class="jenkins-!-padding-left-0" align="center">W</th>
<th align="left">${%Description}</th>
<th align="right">%</th>
</tr>
</thead>
<tbody>
<j:forEach var="rpt" items="${healthReports}">
<tr>
<td align="left" class="jenkins-table__cell--tight jenkins-table__icon">
<div class="jenkins-table__cell__button-wrapper">
<l:icon src="symbol-weather-${buildHealth.iconClassName}" />
</div>
</td>
<td align="left">${rpt.localizableDescription}</td>
<td align="right">${rpt.score}</td>
</tr>
</j:forEach>
</tbody>
</table>
</div>
</j:if>
</x:element>
</j:jelly>

View File

@ -74,7 +74,7 @@ THE SOFTWARE.
<j:set var="stuck" value="${item.isStuck()}"/>
<j:choose>
<j:when test="${h.hasPermission(item.task,item.task.READ)}">
<a href="${rootURL}/${item.task.url}" class="model-link inside tl-tr" tooltip="${h.escape(item.causesDescription)}${h.escape(item.why)}${h.escape(item.params)}&lt;br&gt;${%WaitingFor(item.inQueueForString)}">
<a href="${rootURL}/${item.task.url}" class="model-link inside tl-tr" tooltip="${item.causesDescription} ${item.why} ${item.params} \n ${%WaitingFor(item.inQueueForString)}">
<l:breakable value="${item.task.fullDisplayName}"/>
</a>
<!-- TODO include estimated number as in BuildHistoryWidget/entries.jelly if possible -->

View File

@ -19,7 +19,7 @@
<j:if test="${attrs.iconSize != null}">
<j:set var="iconSize" value="icon-${iconSize}"/>
</j:if>
<l:icon tooltip="${h.htmlAttributeEscape(tooltip)}"
<l:icon tooltip="${tooltip}"
class="${class} ${iconSize}"
src="symbol-help-circle" />
</j:jelly>

View File

@ -43,12 +43,13 @@ THE SOFTWARE.
</st:attribute>
<st:attribute name="onclick" deprecated="true">onclick handler. Deprecated; assign an ID and look up the element that way to attach event handlers.</st:attribute>
<st:attribute name="title" deprecated="true">title, deprecated use tooltip instead, but beware of its support for HTML</st:attribute>
<st:attribute name="title" deprecated="true">title, deprecated use tooltip instead, or htmlTooltip if you intend to pass HTML.</st:attribute>
<st:attribute name="style">style</st:attribute>
<st:attribute name="tooltip">
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).</st:attribute>
Adds a tooltip to the icon, ignores HTML except 'br' tags (but '\n' should be preferred for line breaks).</st:attribute>
<st:attribute name="htmlTooltip">
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.</st:attribute>
<st:attribute name="alt">alt, adds invisible text suitable for screen-readers for symbols, sets the alt attribute for normal images</st:attribute>
</st:documentation>
@ -71,6 +72,7 @@ THE SOFTWARE.
<j:arg value="${iconSrc.substring(7)}" />
<j:arg value="${alt ?: ''}" />
<j:arg value="${attrs.tooltip ?: ''}" />
<j:arg value="${attrs.htmlTooltip ?: ''}" />
<j:arg value="${attrs.class ?: iconMetadata.classSpec ?: ''}" />
<j:arg value="${h.extractPluginNameFromIconSrc(iconSrc)}" />
<j:arg value="${attrs.id ?: ''}" />
@ -96,7 +98,7 @@ THE SOFTWARE.
<j:otherwise>
<img class="${attrs.class}" src="${iconSrc}" style="${imgStyle}" title="${attrs.title}" height="${attrs.height}"
alt="${attrs.alt}" width="${attrs.width}" onclick="${attrs.onclick}" tooltip="${attrs.tooltip}" id="${attrs.id}" />
alt="${attrs.alt}" width="${attrs.width}" onclick="${attrs.onclick}" tooltip="${attrs.tooltip}" data-html-tooltip="${attrs.htmlTooltip}" id="${attrs.id}" />
</j:otherwise>
</j:choose>
</j:otherwise>

View File

@ -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}">
<j:choose>
<j:when test="${attrs.href != null}">

View File

@ -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")));
}

View File

@ -21,7 +21,7 @@ public class IconSetTest {
@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("<span class=\"jenkins-visually-hidden\">Title</span>"));
assertThat(symbol, containsString("tooltip=\"Tooltip\""));
@ -31,8 +31,8 @@ public class IconSetTest {
@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("<span class=\"jenkins-visually-hidden\">Title</span>")));
assertThat(symbol, not(containsString("tooltip=\"Tooltip\"")));
@ -43,8 +43,8 @@ public class IconSetTest {
@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("<span class=\"jenkins-visually-hidden\">Title</span>")));
assertThat(symbol, not(containsString("tooltip=\"Tooltip\"")));
@ -62,7 +62,7 @@ public class IconSetTest {
*/
@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")));
}

View File

@ -46,12 +46,10 @@ import static org.junit.Assert.assertTrue;
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 @@ public class QueueTest {
HtmlPage page = wc.goTo("");
DomElement buildQueue = page.getElementById("buildQueue");
DomNodeList<HtmlElement> 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<BrokenAffinityKeyProject, BrokenAffinityKeyBuild> implements TopLevelItem {

View File

@ -157,9 +157,9 @@ public class RunTest {
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;

View File

@ -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 hudson.model.TaskListener;
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 class AbstractScmTagActionTest {
assertEquals(tagToKeep, tooltip);
}
@Test
@Issue("SECURITY-1537")
public void preventXssInTagAction() throws Exception {
FreeStyleProject p = j.createFreeStyleProject();
p.setScm(new FakeSCM("<img src='x' onerror=alert(123)>XSS"));
j.buildAndAssertSuccess(p);
String tooltip = buildAndExtractTooltipAttribute(p);
assertThat(tooltip, not(containsString("<")));
assertThat(tooltip, startsWith("&lt;"));
}
private String buildAndExtractTooltipAttribute(FreeStyleProject p) throws Exception {
JenkinsRule.WebClient wc = j.createWebClient();

View File

@ -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",
"&lt;img src=\"x\" onerror=\"alert(1)\"&gt;");
assertExpectedBehaviorForTooltip("#symbol-icons .safe svg",
Functions.htmlAttributeEscape(_getSafeTooltip()));
assertExpectedBehaviorForTooltip("#png-icons .unsafe img",
"&lt;img src=\"x\" onerror=\"alert(1)\"&gt;");
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",
"&lt;img src=\"x\" onerror=\"alert(1)\"&gt;");
assertExpectedBehaviorForTooltip("#svgIcons .safe svg",
"&amp;lt;img src=&amp;quot;x&amp;quot; onerror=&amp;quot;alert(1)&amp;quot;&amp;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 @@ public class Security2776Test {
}
private static String _getSafeTooltip() {
return Util.xmlEscape(_getUnsafeTooltip());
return Functions.htmlAttributeEscape(_getUnsafeTooltip());
}
@TestExtension

View File

@ -37,22 +37,19 @@ public class Security2779Test {
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 (&amp;lt;) as test failure
assertThat("tooltip does not contain dangerous HTML", jsResultString, not(containsString(" <img src=x")));
assertThat("tooltip contains safe text", jsResultString, containsString(" &lt;img src=x"));
assertThat("tooltip contains safe text", jsResultString, containsString("lt;img src=x"));
}
@TestExtension

View File

@ -27,9 +27,9 @@ public class Security2780Test {
AtomicBoolean alertTriggered = new AtomicBoolean(false);
wc.setAlertHandler((p, s) -> 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;

View File

@ -639,7 +639,7 @@ public class RepeatableTest {
*/
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
)
);
}

View File

@ -66,9 +66,7 @@ public class SvgIconTest {
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 &quot;, simple quotes ', and html characters &amp;lt;&amp;gt;&amp;amp;.";
String expectedTooltip = "Special tooltip with double quotes &quot;, simple quotes ', and html characters &lt;&gt;&amp;.";
testRootAction.tooltipContent = pristineTooltip;
HtmlPage p = j.createWebClient().goTo(testRootAction.getUrlName());
@ -106,9 +104,9 @@ public class SvgIconTest {
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;

View File

@ -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": [

View File

@ -1,3 +1,5 @@
import Notifications from "@/components/notifications";
import Tooltips from "@/components/tooltips";
Notifications.init();
Tooltips.init();

View File

@ -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(/<br[ /]?\/?>|\\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 };

View File

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

View File

@ -1,7 +1,6 @@
@import url("../abstracts/theme.less");
html {
position: relative;
height: 100%;
box-sizing: border-box;
}

View File

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

View File

@ -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;
}

View File

@ -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 =
"<div id=hoverNotification class='jenkins-tooltip'><div class=bd></div></div>";
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) {

View File

@ -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,

View File

@ -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"