[JENKINS-75683] re-introduce actions for Jenkins with custom action.jelly (#10678)

This commit is contained in:
Mark Waite authored and GitHub committed 2025-06-23 20:59:32 -06:00
commit 39e85a0771
8 files changed
+377 -121

No files matched your search

+41
View File
@@ -55,6 +55,7 @@ import hudson.model.PaneStatusProperties;
import hudson.model.ParameterDefinition;
import hudson.model.ParameterDefinition.ParameterDescriptor;
import hudson.model.PasswordParameterDefinition;
import hudson.model.RootAction;
import hudson.model.Run;
import hudson.model.Slave;
import hudson.model.TimeZoneProperty;
@@ -2097,6 +2098,46 @@ public class Functions {
return url.endsWith(href);
}
/**
* If the given {@code Action} is a {@link RootAction#isPrimaryAction() primary} {code RootAction}, or a parent of the current page, return {@code true}.
* Used in {@code actions.jelly} to decide if the action should shown in the main header or the hamburger.
*/
@Restricted(NoExternalUse.class)
public static boolean showInPrimaryHeader(Action action) {
// regular Actions can be injected into Jenkins via a factory.
if (action instanceof RootAction ra) {
if (ra.isPrimaryAction()) {
return true;
}
}
String path = Stapler.getCurrentRequest2().getPathInfo();
if (path == null || path.equals("/")) {
// we are in the root page so there is nothing current
return false;
}
String actionPath = action.getUrlName();
if (actionPath == null) {
// we are not a primary action and can not ever be current when we have no URL
return false;
}
// RootActions are not expected to start with a `/` but some do and some do, and not all Actions will be RootActions
// but path will always start with a "/"
if (!actionPath.startsWith("/")) {
actionPath = '/' + actionPath;
}
// the action /foo should not match /foobar but should match /foo/bar
if (!actionPath.endsWith("/")) {
actionPath = actionPath + '/';
}
if (!path.endsWith("/")) {
path = path + '/';
}
return path.startsWith(actionPath);
}
/**
* @deprecated From JEXL expressions ({@code ${…}}) in {@code *.jelly} files
* you can use {@code [obj]} syntax to construct an {@code Object[]}
+17 -1
View File
@@ -3,8 +3,10 @@ package jenkins.views;
import hudson.ExtensionComponent;
import hudson.ExtensionList;
import hudson.ExtensionPoint;
import hudson.Functions;
import hudson.model.Action;
import hudson.model.RootAction;
import java.io.IOException;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
@@ -82,10 +84,24 @@ public abstract class Header implements ExtensionPoint {
return Jenkins.get()
.getActions()
.stream()
.filter(e -> e.getIconFileName() != null || (e instanceof IconSpec is && is.getIconClassName() != null))
.filter(e -> e.getIconFileName() != null || (e instanceof IconSpec is && is.getIconClassName() != null) || hasLegacyView(e))
.sorted(Comparator.comparingDouble(
a -> rootActionsOrdinal.getOrDefault(a.getClass().getName(), Double.MAX_VALUE)
).reversed())
.toList();
}
/**
* Jenkins will show actions with a custom action.jelly even if its getIconFileName returned {@code null}.
* @param action the action to check if it has a custom view.
* @return {@code true} iff the action has an {@code action.jelly}
*/
private static boolean hasLegacyView(Action action) {
try {
return Functions.hasView(action, "action");
} catch (IOException ignored) {
// can not load the view so ignore for the header
return false;
}
}
}
@@ -8,7 +8,7 @@
<!-- first render all primary actions (except the user login )-->
<j:forEach var="action" items="${allActions}">
<j:set var="isCurrent" value="${h.hyperlinkMatchesCurrentPage(action.urlName)}" />
<j:set var="isPrimary" value="${action.isPrimaryAction()}"/>
<j:set var="isPrimary" value="${h.showInPrimaryHeader(action)}"/>
<j:if test="${isCurrent or isPrimary}">
<!-- do not show the user avatar -->
@@ -23,7 +23,7 @@
<j:set var="hamburgerEntries">
<j:forEach var="action" items="${allActions}">
<j:set var="isCurrent" value="${h.hyperlinkMatchesCurrentPage(action.urlName)}" />
<j:set var="isPrimary" value="${action.isPrimaryAction()}"/>
<j:set var="isPrimary" value="${h.showInPrimaryHeader(action)}"/>
<j:if test="${!isCurrent and !isPrimary}">
<h:secondaryAction action="${action}"/>
</j:if>
@@ -14,59 +14,73 @@
</st:documentation>
<j:set var="jumplist">
<st:include it="${action}" page="jumplist.jelly" optional="true" />
</j:set>
<x:comment>rendering primary action: ${action.class.name}</x:comment>
<j:if test="${jumplist.length() == 0}">
<j:set var="tooltip">
<st:include it="${action}" page="tooltip.jelly" optional="true" />
</j:set>
</j:if>
<j:choose>
<j:when test="${h.hasView(action, 'action')}">
<j:scope>
<!-- Root actions expect to be in the context of Jenkins (as it) -->
<j:set var="it" value="${app}"/>
<j:set var="isInPrimaryHeader" value="true"/>
<st:include page="action.jelly" from="${action}" optional="true"/>
</j:scope>
</j:when>
<j:otherwise>
<j:set var="icon" value="${action.iconClassName != null ? action.iconClassName : action.iconFileName}"/>
<j:if test="${icon != null}">
<j:set var="jumplist">
<st:include it="${action}" page="jumplist.jelly" optional="true" />
</j:set>
<x:comment>rendering primary action: ${action.class.name}</x:comment>
<j:if test="${jumplist.length() == 0}">
<j:set var="tooltip">
<st:include it="${action}" page="tooltip.jelly" optional="true" />
</j:set>
</j:if>
<j:set var="badge" value="${action.badge}" />
<j:if test="${jumplist.length() == 0 and tooltip.length() == 0}">
<j:set var="tooltip">
<div style="text-align: center;">${action.displayName}</div>
<j:if test="${badge != null}">
<div style="text-align: center; color: var(--text-color-secondary)">${badge.tooltip}</div>
<j:set var="badge" value="${action.badge}" />
<j:if test="${jumplist.length() == 0 and tooltip.length() == 0}">
<j:set var="tooltip">
<div style="text-align: center;">${action.displayName}</div>
<j:if test="${badge != null}">
<div style="text-align: center; color: var(--text-color-secondary)">${badge.tooltip}</div>
</j:if>
</j:set>
</j:if>
<j:set var="interactive" value="${jumplist.length() gt 0}" />
<x:element name="${action.urlName == null ? 'button' : 'a'}">
<x:attribute name="data-dropdown">${interactive}</x:attribute>
<x:attribute name="id">root-action-${action.class.simpleName}</x:attribute>
<x:attribute name="href">${h.getActionUrl(app.url, action)}</x:attribute>
<j:if test="${interactive}">
<x:attribute name="data-tippy-offset">[0, 10]</x:attribute>
</j:if>
<j:if test="${!interactive}">
<x:attribute name="data-html-tooltip" escapeText="false"><j:out value="${tooltip}" /></x:attribute>
</j:if>
<x:attribute name="data-tooltip-interactive">${interactive}</x:attribute>
<x:attribute name="data-tippy-animation">tooltip</x:attribute>
<x:attribute name="data-tippy-theme">${interactive ? 'dropdown' : 'tooltip'}</x:attribute>
<x:attribute name="data-tippy-trigger">mouseenter focus</x:attribute>
<x:attribute name="data-tippy-touch">${interactive}</x:attribute>
<x:attribute name="data-type">header-action</x:attribute>
<x:attribute name="draggable">false</x:attribute>
<x:attribute name="class">jenkins-button ${isCurrent ? '' : 'jenkins-button--tertiary'}</x:attribute>
<l:icon src="${icon}" class="${action.class.name == 'jenkins.model.navigation.UserAction' ? 'jenkins-avatar' : ''}"/>
<span class="jenkins-visually-hidden" data-type="action-label">${action.displayName}</span>
<j:if test="${badge != null}">
<span class="jenkins-badge jenkins-!-${badge.severity}-color" />
</j:if>
</x:element>
<j:if test="${interactive}">
<template>
<div class="jenkins-dropdown">
<j:out value="${jumplist}" />
</div>
</template>
</j:if>
</j:if>
</j:set>
</j:if>
<j:set var="interactive" value="${jumplist.length() gt 0}" />
<j:set var="icon" value="${action.iconClassName != null ? action.iconClassName : action.iconFileName}"/>
<x:element name="${action.urlName == null ? 'button' : 'a'}">
<x:attribute name="data-dropdown">${interactive}</x:attribute>
<x:attribute name="id">root-action-${action.class.simpleName}</x:attribute>
<x:attribute name="href">${h.getActionUrl(app.url, action)}</x:attribute>
<j:if test="${interactive}">
<x:attribute name="data-tippy-offset">[0, 10]</x:attribute>
</j:if>
<j:if test="${!interactive}">
<x:attribute name="data-html-tooltip" escapeText="false"><j:out value="${tooltip}" /></x:attribute>
</j:if>
<x:attribute name="data-tooltip-interactive">${interactive}</x:attribute>
<x:attribute name="data-tippy-animation">tooltip</x:attribute>
<x:attribute name="data-tippy-theme">${interactive ? 'dropdown' : 'tooltip'}</x:attribute>
<x:attribute name="data-tippy-trigger">mouseenter focus</x:attribute>
<x:attribute name="data-tippy-touch">${interactive}</x:attribute>
<x:attribute name="data-type">header-action</x:attribute>
<x:attribute name="draggable">false</x:attribute>
<x:attribute name="class">jenkins-button ${isCurrent ? '' : 'jenkins-button--tertiary'}</x:attribute>
<l:icon src="${icon}" class="${action.class.name == 'jenkins.model.navigation.UserAction' ? 'jenkins-avatar' : ''}"/>
<span class="jenkins-visually-hidden" data-type="action-label">${action.displayName}</span>
<j:if test="${badge != null}">
<span class="jenkins-badge jenkins-!-${badge.severity}-color" />
</j:if>
</x:element>
<j:if test="${interactive}">
<template>
<div class="jenkins-dropdown">
<j:out value="${jumplist}" />
</div>
</template>
</j:if>
</j:otherwise>
</j:choose>
</j:jelly>
@@ -10,13 +10,27 @@
</st:attribute>
</st:documentation>
<j:set var="badge" value="${action.badge}"/>
<j:set var="badgeClazz" value="${badge != null ? 'jenkins-badge jenkins-!-${badge.severity}-color' : ''}"/>
<dd:item icon="${action.iconClassName != null ? action.iconClassName : action.iconFileName}"
text="${action.displayName}"
href="${h.getActionUrl(app.url, action)}"
clazz="${badgeClazz}">
</dd:item>
<j:choose>
<j:when test="${h.hasView(action, 'action')}">
<j:scope>
<!-- Root actions expect to be in the context of Jenkins (as it) -->
<j:set var="it" value="${app}"/>
<j:set var="isInSecondaryHeader" value="true"/>
<st:include page="action.jelly" from="${action}" optional="true"/>
</j:scope>
</j:when>
<j:otherwise>
<j:set var="icon" value="${action.iconClassName != null ? action.iconClassName : action.iconFileName}"/>
<j:if test="${icon != null}">
<j:set var="badge" value="${action.badge}"/>
<j:set var="badgeClazz" value="${badge != null ? 'jenkins-badge jenkins-!-${badge.severity}-color' : ''}"/>
<dd:item icon="${action.iconClassName != null ? action.iconClassName : action.iconFileName}"
text="${action.displayName}"
href="${h.getActionUrl(app.url, action)}"
clazz="${badgeClazz}">
</dd:item>
</j:if>
</j:otherwise>
</j:choose>
</j:jelly>
+113 -1
View File
@@ -23,7 +23,7 @@ THE SOFTWARE.
-->
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define" xmlns:l="/lib/layout">
<j:jelly xmlns:j="jelly:core" xmlns:st="jelly:stapler" xmlns:d="jelly:define" xmlns:l="/lib/layout" xmlns:x="jelly:xml" xmlns:dd="/lib/layout/dropdowns">
<st:documentation> <![CDATA[
This tag inside <l:tasks> tag renders the left navigation bar of Jenkins.
Each <task> tag gets an icon and link.
@@ -133,9 +133,11 @@ THE SOFTWARE.
</j:when>
<j:set var="match" value="${isCurrent or hasMatchingChild}" />
<!--
<j:if test="${attrs.permission != null}">
<j:set var="hasPermission" value="${h.hasPermission(it, attrs.permission)}" />
</j:if>
-->
<!--
The icon attribute can be an icon class spec, or can be the url of an icon image.
@@ -147,6 +149,116 @@ THE SOFTWARE.
icon class spec from the icon metadata, otherwise we create an icon element instance using
icon attribute value as an icon "src" (Vs class spec).
-->
<j:when test="${isInPrimaryHeader}">
<j:scope>
<!-- we are rendering inside the modern header this is for compatibility, so we do not need to worry about jumplists which was introduced in the new header -->
<j:if test="${enabled != false}"> <!-- no rendering at all for disabled tasks in the header -->
<!-- there was no custom action and we have no icon so this is intended to be hidden -->
<j:if test="${icon != null}">
<j:set var="isCurrent" value="${h.hyperlinkMatchesCurrentPage(action.urlName)}" />
<j:set var="jumplist">
<j:if test="${match}">
<j:scope>
<j:set var="isInPrimaryHeader" value="false" />
<d:invokeBody />
</j:scope>
</j:if>
</j:set>
<j:if test="${jumplist.length() == 0}">
<j:set var="tooltip">
<div style="text-align: center;">${title}</div>
<j:if test="${badge != null}">
<div style="text-align: center; color: var(--text-color-secondary)">${badge.tooltip}</div>
</j:if>
</j:set>
</j:if>
<j:set var="interactive" value="${jumplist.length() gt 0}" />
<x:element name="${action.urlName == null ? 'button' : 'a'}">
<x:attribute name="data-dropdown">${interactive}</x:attribute>
<x:attribute name="id">root-action-${action.class.simpleName}</x:attribute>
<j:if test="${requiresConfirmation}">
<x:attribute name="href">#</x:attribute>
<x:attribute name="data-url">${href}</x:attribute>
<x:attribute name="data-post">${post}</x:attribute>
<x:attribute name="data-message">${confirmationMessage}</x:attribute>
<x:attribute name="data-title">${title}</x:attribute>
<x:attribute name="data-destructive">${destructive}</x:attribute>
</j:if>
<j:if test="${!requiresConfirmation}">
<x:attribute name="href">${href}</x:attribute>
</j:if>
<j:if test="${interactive}">
<x:attribute name="data-tippy-offset">[0, 10]</x:attribute>
</j:if>
<j:if test="${!interactive}">
<x:attribute name="data-html-tooltip" escapeText="false"><j:out value="${tooltip}" /></x:attribute>
</j:if>
<x:attribute name="data-tooltip-interactive">${interactive}</x:attribute>
<x:attribute name="data-tippy-animation">tooltip</x:attribute>
<x:attribute name="data-tippy-theme">${interactive ? 'dropdown' : 'tooltip'}</x:attribute>
<x:attribute name="data-tippy-trigger">mouseenter focus</x:attribute>
<x:attribute name="data-tippy-touch">${interactive}</x:attribute>
<x:attribute name="data-type">header-action</x:attribute>
<x:attribute name="draggable">false</x:attribute>
<x:attribute name="class">jenkins-button ${isCurrent ? '' : 'jenkins-button--tertiary'} ${requiresConfirmation ? 'confirmation-link' : ''}</x:attribute>
<l:icon src="${icon}" class="''"/>
<span class="jenkins-visually-hidden" data-type="action-label">${title}</span>
<j:if test="${badge != null}">
<span class="jenkins-badge jenkins-!-${badge.severity}-color" />
</j:if>
</x:element>
<j:if test="${interactive}">
<template>
<div class="jenkins-dropdown">
<!-- add the action title back into the dropdown -->
<dd:header text="${title}"/>
<!-- TODO we are missing some styling the icon and text have no space inbetween -->
<dd:custom>
<div class="tasks">
<j:out value="${jumplist}" />
</div>
</dd:custom>
</div>
</template>
</j:if>
</j:if>
</j:if>
</j:scope>
</j:when>
<j:when test="${isInSecondaryHeader}">
<!-- no extra menus...just some small stuff.... -->
<j:choose>
<j:when test="${requiresConfirmation}">
<dd:custom>
<l:confirmationLink class="jenkins-dropdown__item" href="${href}" post="${post}" message="${confirmationMessage ?: ''}" title="${title}">
<div class="jenkins-dropdown__item__icon">
<l:icon src="${icon}" />
</div>
${title}
<l:badge badge="${attrs.badge}" class="jenkins-dropdown__item__badge"/>
</l:confirmationLink>
</dd:custom>
</j:when>
<j:otherwise>
<j:set var="badgeClazz" value="${badge == null ? '' : 'jenkins-badge jenkins-!-${badge.severity}-color'}"/>
<dd:item icon="${action.iconClassName != null ? action.iconClassName : action.iconFileName}"
text="${action.displayName}"
href="${href}"
clazz="${badgeClazz}">
</dd:item>
</j:otherwise>
</j:choose>
</j:when>
<!-- regular sidebar not the new header -->
<j:when test="${enabled==false}">
<div class="task disabled">
<span class="task-link-wrapper">
@@ -31,12 +31,14 @@ import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
import hudson.console.ConsoleAnnotatorFactory.RootAction;
import hudson.model.Action;
import hudson.model.Computer;
import hudson.model.Item;
@@ -58,6 +60,9 @@ import java.util.regex.Pattern;
import jenkins.model.Jenkins;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import org.jvnet.hudson.test.Issue;
import org.kohsuke.stapler.Ancestor;
import org.kohsuke.stapler.Stapler;
@@ -309,6 +314,13 @@ class FunctionsTest {
return action;
}
private static RootAction createMockRootAction(String uri, boolean primary) {
RootAction action = mock(RootAction.class);
when(action.getUrlName()).thenReturn(uri);
when(action.isPrimaryAction()).thenReturn(primary);
return action;
}
private static StaplerRequest2 createMockRequest(String contextPath) {
StaplerRequest2 req = mock(StaplerRequest2.class);
when(req.getContextPath()).thenReturn(contextPath);
@@ -741,4 +753,47 @@ class FunctionsTest {
assertEquals("http://acme.com/icon.svg", Functions.guessIcon("http://acme.com/icon.svg", "/jenkins"));
assertEquals("https://acme.com/icon.svg", Functions.guessIcon("https://acme.com/icon.svg", "/jenkins"));
}
@ParameterizedTest
@ValueSource(strings = {"/myAction", "/myAction/", "/myAction/subPath"})
void showInPrimaryHeader_shouldReturnTrueForCurrentAction(String path) {
StaplerRequest2 req = mock(StaplerRequest2.class);
when(req.getPathInfo()).thenReturn(path);
Action action = createMockAction("myAction");
try (MockedStatic<Stapler> mocked = mockStatic(Stapler.class)) {
mocked.when(Stapler::getCurrentRequest2).thenReturn(req);
assertTrue(Functions.showInPrimaryHeader(action));
}
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {"/myAction", "/myAction/", "/myAction/subPath"})
void showInPrimaryHeader_shouldReturnTrueForPrimaryActions(String path) {
StaplerRequest2 req = mock(StaplerRequest2.class);
when(req.getPathInfo()).thenReturn(path);
RootAction action = createMockRootAction("anything", true);
try (MockedStatic<Stapler> mocked = mockStatic(Stapler.class)) {
mocked.when(Stapler::getCurrentRequest2).thenReturn(req);
assertTrue(Functions.showInPrimaryHeader(action));
}
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {"/myAction", "/myAction/", "/myAction/subPath"})
void showInPrimaryHeader_shouldReturnFalseForNonPrimaryNonCurrentAction(String path) {
StaplerRequest2 req = mock(StaplerRequest2.class);
when(req.getPathInfo()).thenReturn(path);
Action action = createMockRootAction("myAct", false);
try (MockedStatic<Stapler> mocked = mockStatic(Stapler.class)) {
mocked.when(Stapler::getCurrentRequest2).thenReturn(req);
assertFalse(Functions.showInPrimaryHeader(action));
}
}
}
+61 -57
View File
@@ -4,6 +4,7 @@
$background-outset: 0.7rem;
#tasks,
.tasks,
.subtasks {
display: flex;
flex-direction: column;
@@ -44,68 +45,71 @@ $background-outset: 0.7rem;
}
}
#tasks .task {
margin: 0 calc($background-outset * -1);
}
#tasks,
.tasks {
.task {
margin: 0 calc($background-outset * -1);
#tasks .task .task-link {
@include mixins.item;
.task-link {
@include mixins.item;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0.5rem $background-outset;
gap: 0.65rem;
width: 100%;
font-size: var(--font-size-sm);
color: var(--text-color);
margin: 0;
transition: opacity var(--standard-transition);
.task-icon-link {
display: inline-flex;
svg,
img {
width: 1.375rem !important;
height: 1.375rem !important;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0.5rem $background-outset;
gap: 0.65rem;
width: 100%;
font-size: var(--font-size-sm);
color: var(--text-color);
margin: 0;
transition: opacity var(--standard-transition);
* {
transition: stroke-width var(--standard-transition);
.task-icon-link {
display: inline-flex;
svg,
img {
width: 1.375rem !important;
height: 1.375rem !important;
color: var(--text-color);
* {
transition: stroke-width var(--standard-transition);
}
}
}
.task-icon-badge {
margin-left: auto;
}
.task-link-text {
display: contents;
word-break: word-break;
}
&--active {
font-weight: 450;
cursor: default;
svg * {
stroke-width: 35px;
}
&::before {
background-color: var(--item-background--active) !important;
}
&::after {
box-shadow: none !important;
}
}
&:not(:hover, &:active, &:focus, &--active) {
&::before {
border-color: transparent;
}
}
}
}
.task-icon-badge {
margin-left: auto;
}
.task-link-text {
display: contents;
word-break: word-break;
}
&--active {
font-weight: 450;
cursor: default;
svg * {
stroke-width: 35px;
}
&::before {
background-color: var(--item-background--active) !important;
}
&::after {
box-shadow: none !important;
}
}
&:not(:hover, &:active, &:focus, &--active) {
&::before {
border-color: transparent;
}
}
}