From 7c09e8ad46c44853331ef6f84f208d8e0e58595d Mon Sep 17 00:00:00 2001 From: James Nord Date: Thu, 5 Jun 2025 18:55:40 +0100 Subject: [PATCH 1/5] Distinguish between primary and secondary actions in the header This PR distinguishes between primary and secondary Actions for the header. Primary actions are always shown, and secondary actions are by default in the Hamburger menu. When the current page is the page for the secondary action (or a parent thereof) the action is put in the main header action list and not in the hamburger menu. --- .../hudson/model/ManageJenkinsAction.java | 5 ++ .../main/java/hudson/model/RootAction.java | 10 +++ .../model/navigation/SearchAction.java | 5 ++ .../jenkins/model/navigation/UserAction.java | 5 ++ core/src/main/java/jenkins/views/Header.java | 3 +- .../resources/lib/layout/header/actions.jelly | 90 +++++++------------ .../lib/layout/header/primaryAction.jelly | 72 +++++++++++++++ .../lib/layout/header/secondaryAction.jelly | 22 +++++ .../images/symbols/menu-hamburger.svg | 3 + 9 files changed, 157 insertions(+), 58 deletions(-) create mode 100644 core/src/main/resources/lib/layout/header/primaryAction.jelly create mode 100644 core/src/main/resources/lib/layout/header/secondaryAction.jelly create mode 100644 war/src/main/resources/images/symbols/menu-hamburger.svg diff --git a/core/src/main/java/hudson/model/ManageJenkinsAction.java b/core/src/main/java/hudson/model/ManageJenkinsAction.java index 9590289330..5c46ceff53 100644 --- a/core/src/main/java/hudson/model/ManageJenkinsAction.java +++ b/core/src/main/java/hudson/model/ManageJenkinsAction.java @@ -68,6 +68,11 @@ public class ManageJenkinsAction implements RootAction, StaplerFallback, ModelOb return "/manage"; } + @Override + public boolean isPrimaryAction() { + return true; + } + @Override public Object getStaplerFallback() { return Jenkins.get(); diff --git a/core/src/main/java/hudson/model/RootAction.java b/core/src/main/java/hudson/model/RootAction.java index d1279c47e8..b13e69f160 100644 --- a/core/src/main/java/hudson/model/RootAction.java +++ b/core/src/main/java/hudson/model/RootAction.java @@ -50,4 +50,14 @@ public interface RootAction extends Action, ExtensionPoint { default @CheckForNull Badge getBadge() { return null; } + + /** + * Identifies if the action as a primary action. + * Primary actions may be handled differently in the UI (for existence by always showing on the header rather than in an actions dropdown). + * In almost all cases this should return {@code false} which is the default + * @return {@code true} iff this action should be considered primary. + */ + default boolean isPrimaryAction() { + return false; + } } diff --git a/core/src/main/java/jenkins/model/navigation/SearchAction.java b/core/src/main/java/jenkins/model/navigation/SearchAction.java index 36a56bda2f..adb10d6272 100644 --- a/core/src/main/java/jenkins/model/navigation/SearchAction.java +++ b/core/src/main/java/jenkins/model/navigation/SearchAction.java @@ -47,4 +47,9 @@ public class SearchAction implements RootAction { public String getUrlName() { return null; } + + @Override + public boolean isPrimaryAction() { + return true; + } } diff --git a/core/src/main/java/jenkins/model/navigation/UserAction.java b/core/src/main/java/jenkins/model/navigation/UserAction.java index 5900e3d097..c85829f6e5 100644 --- a/core/src/main/java/jenkins/model/navigation/UserAction.java +++ b/core/src/main/java/jenkins/model/navigation/UserAction.java @@ -80,6 +80,11 @@ public class UserAction implements RootAction { return User.current(); } + @Override + public boolean isPrimaryAction() { + return true; + } + @Restricted(NoExternalUse.class) public List getActions() { User current = User.current(); diff --git a/core/src/main/java/jenkins/views/Header.java b/core/src/main/java/jenkins/views/Header.java index 083f241340..d6ad845634 100644 --- a/core/src/main/java/jenkins/views/Header.java +++ b/core/src/main/java/jenkins/views/Header.java @@ -64,7 +64,8 @@ public abstract class Header implements ExtensionPoint { } /** - * @return a list of {@link Action} to show in the header, defaults to {@link hudson.model.RootAction} extensions + * @return a list of {@link Action} to show in the header. + * The default implemention returns an {@link Jenkins#getActions()} that should be displayed (ie have an icon). */ @Restricted(NoExternalUse.class) public List getActions() { diff --git a/core/src/main/resources/lib/layout/header/actions.jelly b/core/src/main/resources/lib/layout/header/actions.jelly index 874e0cce94..4e30e5266d 100644 --- a/core/src/main/resources/lib/layout/header/actions.jelly +++ b/core/src/main/resources/lib/layout/header/actions.jelly @@ -3,68 +3,44 @@
- + + + + + - - - - - - - - - - - - - -
${action.displayName}
- -
${badge.tooltip}
-
-
-
- - - - - ${interactive} - root-action-${action.class.simpleName} - ${h.getActionUrl(app.url, action)} - - [0, 10] + + + + - - - - ${interactive} - tooltip - ${interactive ? 'dropdown' : 'tooltip'} - mouseenter focus - ${interactive} - header-action - false - jenkins-button ${isCurrent ? '' : 'jenkins-button--tertiary'} - - ${action.displayName} - - - - - - - - - -
+ + + + + + + + + + + + + + + + + + + + + + +
- \ No newline at end of file + diff --git a/core/src/main/resources/lib/layout/header/primaryAction.jelly b/core/src/main/resources/lib/layout/header/primaryAction.jelly new file mode 100644 index 0000000000..a199bf00ee --- /dev/null +++ b/core/src/main/resources/lib/layout/header/primaryAction.jelly @@ -0,0 +1,72 @@ + + + + + to render primaryActions. + ]]> + + The action to render + + + true if the action is the action for the current page + + + + + + + + rendering primary action: ${action.class.name} + + + + + + + + + +
${action.displayName}
+ +
${badge.tooltip}
+
+
+
+ + + + + ${interactive} + root-action-${action.class.simpleName} + ${h.getActionUrl(app.url, action)} + + [0, 10] + + + + + ${interactive} + tooltip + ${interactive ? 'dropdown' : 'tooltip'} + mouseenter focus + ${interactive} + header-action + false + jenkins-button ${isCurrent ? '' : 'jenkins-button--tertiary'} + + ${action.displayName} + + + + + + + + + +
diff --git a/core/src/main/resources/lib/layout/header/secondaryAction.jelly b/core/src/main/resources/lib/layout/header/secondaryAction.jelly new file mode 100644 index 0000000000..cf9f72abd0 --- /dev/null +++ b/core/src/main/resources/lib/layout/header/secondaryAction.jelly @@ -0,0 +1,22 @@ + + + + + to render primaryActions. + ]]> + + The action to render + + + + + + + + + + diff --git a/war/src/main/resources/images/symbols/menu-hamburger.svg b/war/src/main/resources/images/symbols/menu-hamburger.svg new file mode 100644 index 0000000000..3ecffda8c5 --- /dev/null +++ b/war/src/main/resources/images/symbols/menu-hamburger.svg @@ -0,0 +1,3 @@ + + + From 8adbd0a34f3c6ccb32cedef9ae0a850dd4862354 Mon Sep 17 00:00:00 2001 From: James Nord Date: Tue, 17 Jun 2025 10:53:39 +0100 Subject: [PATCH 2/5] remove actions-overflow --- .../js/components/header/actions-overflow.js | 133 ------------------ .../js/components/header/actions-touch.js | 16 +++ src/main/js/components/header/index.js | 4 +- 3 files changed, 18 insertions(+), 135 deletions(-) delete mode 100644 src/main/js/components/header/actions-overflow.js create mode 100644 src/main/js/components/header/actions-touch.js diff --git a/src/main/js/components/header/actions-overflow.js b/src/main/js/components/header/actions-overflow.js deleted file mode 100644 index 62d84f5f7b..0000000000 --- a/src/main/js/components/header/actions-overflow.js +++ /dev/null @@ -1,133 +0,0 @@ -import Utils from "@/components/dropdowns/utils"; -import { createElementFromHtml } from "@/util/dom"; - -const OVERFLOW_ID = "jenkins-header-actions-overflow"; - -export default function computeActions() { - document - .querySelectorAll( - ".jenkins-header__actions .jenkins-button[data-type='header-action'].jenkins-hidden", - ) - .forEach((e) => { - e.classList.remove("jenkins-hidden"); - }); - - if (!actionsOverflows()) { - removeOverflowButton(); - return; - } - - const items = []; - const actions = Array.from( - document.querySelectorAll( - ".jenkins-header__actions .jenkins-button[data-type='header-action']", - ), - ).slice(1, -1); - - const overflowButton = generateOverflowButton(); - - while (actionsOverflows()) { - const item = actions.pop(); - - if (!item) { - break; - } - - items.unshift(item); - item.classList.add("jenkins-hidden"); - } - - Utils.generateDropdown( - overflowButton, - (instance) => { - const mappedItems = items.map((e) => { - let icon = e.querySelector("img"); - if (icon) { - icon = icon.src; - } - let iconXml = e.querySelector("svg"); - if (iconXml) { - icon = true; - iconXml = iconXml.outerHTML; - } - - const span = e.querySelector("[data-type='action-label']"); - let label = e.textContent; - if (span !== null) { - label = span.textContent; - } - - return { - type: "link", - icon: icon, - iconXml: iconXml, - label: label, - url: e.href, - }; - }); - - instance.setContent(Utils.generateDropdownItems(mappedItems)); - }, - true, - { - trigger: "mouseenter focus", - offset: [0, 10], - animation: "tooltip", - }, - ); - - // We want to disable the User action href on touch devices so that they can still activate the overflow menu - const link = document.querySelector("#root-action-UserAction"); - - if (link) { - const originalHref = link.getAttribute("href"); - const isTouchDevice = window.matchMedia("(hover: none)").matches; - - // HTMLUnit doesn't register itself as supporting hover, thus the href is removed when it shouldn't be - if (isTouchDevice && !window.isRunAsTest) { - link.removeAttribute("href"); - } else { - link.setAttribute("href", originalHref); - } - } -} - -function actionsOverflows() { - const actions = document.querySelector(".jenkins-header__actions"); - return actions.offsetWidth > Math.max(window.innerWidth / 4.5, 150); -} - -function generateOverflowButton() { - // If an overflow menu already exists let's use that - const overflowMenu = document.querySelector("#" + OVERFLOW_ID); - if (overflowMenu) { - return overflowMenu; - } - - // Generate an overflow menu to store actions - const element = - createElementFromHtml(``); - - const actionsContainer = document.querySelector(".jenkins-header__actions"); - - // Insert the new element before the last child - actionsContainer.insertBefore( - element, - actionsContainer.children[actionsContainer.children.length - 2], - ); - - return element; -} - -function removeOverflowButton() { - const overflowButton = document.querySelector("#" + OVERFLOW_ID); - - if (overflowButton) { - overflowButton.remove(); - } -} diff --git a/src/main/js/components/header/actions-touch.js b/src/main/js/components/header/actions-touch.js new file mode 100644 index 0000000000..d5f691ee6e --- /dev/null +++ b/src/main/js/components/header/actions-touch.js @@ -0,0 +1,16 @@ +export default function updateActionsForTouch() { + // We want to disable the User action href on touch devices so that they can still activate the overflow menu + const link = document.querySelector("#root-action-UserAction"); + + if (link) { + const originalHref = link.getAttribute("href"); + const isTouchDevice = window.matchMedia("(hover: none)").matches; + + // HTMLUnit doesn't register itself as supporting hover, thus the href is removed when it shouldn't be + if (isTouchDevice && !window.isRunAsTest) { + link.removeAttribute("href"); + } else { + link.setAttribute("href", originalHref); + } + } +} diff --git a/src/main/js/components/header/index.js b/src/main/js/components/header/index.js index 0d864ec32a..4df9be01e9 100644 --- a/src/main/js/components/header/index.js +++ b/src/main/js/components/header/index.js @@ -1,9 +1,10 @@ -import computeActions from "@/components/header/actions-overflow"; +import updateActionsForTouch from "@/components/header/actions-touch"; import computeBreadcrumbs from "@/components/header/breadcrumbs-overflow"; function init() { // Recompute what actions and breadcrumbs should be visible when the viewport size is changed computeOverflow(); + updateActionsForTouch(); let lastWidth = window.innerWidth; window.addEventListener("resize", () => { if (window.innerWidth !== lastWidth) { @@ -59,7 +60,6 @@ function init() { } function computeOverflow() { - computeActions(); computeBreadcrumbs(); } From c701361ec7bc9aa58d7745de6291a01b3d7abbe4 Mon Sep 17 00:00:00 2001 From: James Nord Date: Thu, 19 Jun 2025 11:03:50 +0100 Subject: [PATCH 3/5] Fix Typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- core/src/main/java/jenkins/views/Header.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/java/jenkins/views/Header.java b/core/src/main/java/jenkins/views/Header.java index d6ad845634..bbdfaeb85c 100644 --- a/core/src/main/java/jenkins/views/Header.java +++ b/core/src/main/java/jenkins/views/Header.java @@ -65,7 +65,7 @@ public abstract class Header implements ExtensionPoint { /** * @return a list of {@link Action} to show in the header. - * The default implemention returns an {@link Jenkins#getActions()} that should be displayed (ie have an icon). + * The default implementation returns an {@link Jenkins#getActions()} that should be displayed (ie have an icon). */ @Restricted(NoExternalUse.class) public List getActions() { From 188baf00e23df5a15b5907a8ea1c81232833e5e5 Mon Sep 17 00:00:00 2001 From: James Nord Date: Thu, 19 Jun 2025 11:07:38 +0100 Subject: [PATCH 4/5] fix inverted badgeClass Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- core/src/main/resources/lib/layout/header/secondaryAction.jelly | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/resources/lib/layout/header/secondaryAction.jelly b/core/src/main/resources/lib/layout/header/secondaryAction.jelly index cf9f72abd0..508fb3f794 100644 --- a/core/src/main/resources/lib/layout/header/secondaryAction.jelly +++ b/core/src/main/resources/lib/layout/header/secondaryAction.jelly @@ -11,7 +11,7 @@ - + Date: Thu, 22 May 2025 18:17:09 +0100 Subject: [PATCH 5/5] [JENKINS-75683] re-introduce actions for Jenkins with custom action.jelly actions without a URL but a custom action.jelly where not shown correctly in the header. This re-introduces them and allows custom rendering of actions again (by using an action.jelly). adjust the task taglib so that it renders in the style of the header when in the header. Nested tasks work, but when expanded the original looses its tooltip. move subtasks to the dropdown use tasks class to fix layout fix scss formatting --- core/src/main/java/hudson/Functions.java | 41 ++++++ core/src/main/java/jenkins/views/Header.java | 18 ++- .../resources/lib/layout/header/actions.jelly | 4 +- .../lib/layout/header/primaryAction.jelly | 118 ++++++++++-------- .../lib/layout/header/secondaryAction.jelly | 30 +++-- core/src/main/resources/lib/layout/task.jelly | 114 ++++++++++++++++- core/src/test/java/hudson/FunctionsTest.java | 55 ++++++++ .../scss/components/_side-panel-tasks.scss | 118 +++++++++--------- 8 files changed, 377 insertions(+), 121 deletions(-) diff --git a/core/src/main/java/hudson/Functions.java b/core/src/main/java/hudson/Functions.java index 13a51432af..e525dd6c4e 100644 --- a/core/src/main/java/hudson/Functions.java +++ b/core/src/main/java/hudson/Functions.java @@ -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[]} diff --git a/core/src/main/java/jenkins/views/Header.java b/core/src/main/java/jenkins/views/Header.java index bbdfaeb85c..ad5ef4d5a8 100644 --- a/core/src/main/java/jenkins/views/Header.java +++ b/core/src/main/java/jenkins/views/Header.java @@ -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; + } + } } diff --git a/core/src/main/resources/lib/layout/header/actions.jelly b/core/src/main/resources/lib/layout/header/actions.jelly index 4e30e5266d..fff1d892ee 100644 --- a/core/src/main/resources/lib/layout/header/actions.jelly +++ b/core/src/main/resources/lib/layout/header/actions.jelly @@ -8,7 +8,7 @@ - + @@ -23,7 +23,7 @@ - + diff --git a/core/src/main/resources/lib/layout/header/primaryAction.jelly b/core/src/main/resources/lib/layout/header/primaryAction.jelly index a199bf00ee..bc91147f0b 100644 --- a/core/src/main/resources/lib/layout/header/primaryAction.jelly +++ b/core/src/main/resources/lib/layout/header/primaryAction.jelly @@ -14,59 +14,73 @@ - - - - rendering primary action: ${action.class.name} - - - - - + + + + + + + + + + + + + + + + rendering primary action: ${action.class.name} + + + + + - - - -
${action.displayName}
- -
${badge.tooltip}
+ + + +
${action.displayName}
+ +
${badge.tooltip}
+
+
+
+ + + + ${interactive} + root-action-${action.class.simpleName} + ${h.getActionUrl(app.url, action)} + + [0, 10] + + + + + ${interactive} + tooltip + ${interactive ? 'dropdown' : 'tooltip'} + mouseenter focus + ${interactive} + header-action + false + jenkins-button ${isCurrent ? '' : 'jenkins-button--tertiary'} + + ${action.displayName} + + + + + + + +
-
-
- - - - - ${interactive} - root-action-${action.class.simpleName} - ${h.getActionUrl(app.url, action)} - - [0, 10] - - - - - ${interactive} - tooltip - ${interactive ? 'dropdown' : 'tooltip'} - mouseenter focus - ${interactive} - header-action - false - jenkins-button ${isCurrent ? '' : 'jenkins-button--tertiary'} - - ${action.displayName} - - - - - - - - +
+
diff --git a/core/src/main/resources/lib/layout/header/secondaryAction.jelly b/core/src/main/resources/lib/layout/header/secondaryAction.jelly index 508fb3f794..5c8123de01 100644 --- a/core/src/main/resources/lib/layout/header/secondaryAction.jelly +++ b/core/src/main/resources/lib/layout/header/secondaryAction.jelly @@ -10,13 +10,27 @@ - - - - - + + + + + + + + + + + + + + + + + + + diff --git a/core/src/main/resources/lib/layout/task.jelly b/core/src/main/resources/lib/layout/task.jelly index 550d9282e5..c4bb01dcea 100644 --- a/core/src/main/resources/lib/layout/task.jelly +++ b/core/src/main/resources/lib/layout/task.jelly @@ -23,7 +23,7 @@ THE SOFTWARE. --> - + tag renders the left navigation bar of Jenkins. Each tag gets an icon and link. @@ -133,9 +133,11 @@ THE SOFTWARE. + + + + + + + + + + + + + + + + + + + + + + + + +
${title}
+ +
${badge.tooltip}
+
+
+
+ + + + + ${interactive} + root-action-${action.class.simpleName} + + # + ${href} + ${post} + ${confirmationMessage} + ${title} + ${destructive} + + + ${href} + + + [0, 10] + + + + + ${interactive} + tooltip + ${interactive ? 'dropdown' : 'tooltip'} + mouseenter focus + ${interactive} + header-action + false + jenkins-button ${isCurrent ? '' : 'jenkins-button--tertiary'} ${requiresConfirmation ? 'confirmation-link' : ''} + + ${title} + + + + + + + + +
+
+
+
+ + + + + + +
+ +
+ ${title} + +
+
+
+ + + + + +
+
+
diff --git a/core/src/test/java/hudson/FunctionsTest.java b/core/src/test/java/hudson/FunctionsTest.java index bcaa51300c..32fd49c433 100644 --- a/core/src/test/java/hudson/FunctionsTest.java +++ b/core/src/test/java/hudson/FunctionsTest.java @@ -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 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 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 mocked = mockStatic(Stapler.class)) { + mocked.when(Stapler::getCurrentRequest2).thenReturn(req); + assertFalse(Functions.showInPrimaryHeader(action)); + } + } + } diff --git a/src/main/scss/components/_side-panel-tasks.scss b/src/main/scss/components/_side-panel-tasks.scss index 65edd8dc13..1e94cdc56d 100644 --- a/src/main/scss/components/_side-panel-tasks.scss +++ b/src/main/scss/components/_side-panel-tasks.scss @@ -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; - } - } }