This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new 7b97a601fc issue #3526 : add Favorites category for transforms and 
actions (#7598)
7b97a601fc is described below

commit 7b97a601fc3772e13d02bfee6d0d1e3c9373cc5b
Author: Matt Casters <[email protected]>
AuthorDate: Wed Jul 22 16:46:03 2026 +0200

    issue #3526 : add Favorites category for transforms and actions (#7598)
---
 .../apache/hop/ui/core/dialog/ContextDialog.java   | 171 +++++++++++-----
 .../hop/ui/core/gui/ShortcutDisplayUtil.java       |   8 +
 .../hop/ui/hopgui/context/GuiActionFavorites.java  | 223 +++++++++++++++++++++
 .../hop/ui/hopgui/context/GuiContextUtil.java      |   7 +-
 .../pipeline/context/HopGuiPipelineContext.java    |  20 +-
 .../workflow/context/HopGuiWorkflowContext.java    |  24 ++-
 .../core/dialog/messages/messages_en_US.properties |   3 +
 .../ui/hopgui/context/GuiActionFavoritesTest.java  | 161 +++++++++++++++
 8 files changed, 552 insertions(+), 65 deletions(-)

diff --git a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java 
b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
index 7dcac7a119..2a17bd2b13 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/dialog/ContextDialog.java
@@ -24,6 +24,7 @@ import java.util.IdentityHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.function.Supplier;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.hop.core.Const;
 import org.apache.hop.core.Props;
@@ -49,6 +50,7 @@ import org.apache.hop.ui.core.gui.IToolbarContainer;
 import org.apache.hop.ui.core.gui.WindowProperty;
 import org.apache.hop.ui.core.widget.OsHelper;
 import org.apache.hop.ui.hopgui.ToolbarFacade;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.apache.hop.ui.util.EnvironmentUtils;
 import org.eclipse.swt.SWT;
@@ -97,7 +99,8 @@ public class ContextDialog extends Dialog {
   public static final String AUDIT_NAME_CATEGORY_STATES = "CategoryStates";
 
   private final Point location;
-  private final List<GuiAction> actions;
+  private List<GuiAction> actions;
+  private final Supplier<List<GuiAction>> actionsSupplier;
   private final PropsUi props;
   private Shell shell;
   private Text wSearch;
@@ -303,11 +306,22 @@ public class ContextDialog extends Dialog {
 
   public ContextDialog(
       Shell parent, String title, Point location, List<GuiAction> actions, 
String contextId) {
+    this(parent, title, location, actions, contextId, null);
+  }
+
+  public ContextDialog(
+      Shell parent,
+      String title,
+      Point location,
+      List<GuiAction> actions,
+      String contextId,
+      Supplier<List<GuiAction>> actionsSupplier) {
     super(parent);
 
     this.setText(title);
     this.location = location;
     this.actions = actions;
+    this.actionsSupplier = actionsSupplier;
 
     props = PropsUi.getInstance();
 
@@ -328,58 +342,10 @@ public class ContextDialog extends Dialog {
     shell.setImage(GuiResource.getInstance().getImageHop());
     shell.setLayout(new FormLayout());
 
-    Display display = shell.getDisplay();
-
     xMargin = 3 * margin;
     yMargin = 2 * margin;
 
-    // Let's take a look at the list of actions and see if we've got 
categories to use...
-    //
-    categories = new ArrayList<>();
-    for (GuiAction action : actions) {
-      CategoryAndOrder categoryAndOrder;
-      if (StringUtils.isNotEmpty(action.getCategory())) {
-        categoryAndOrder =
-            new CategoryAndOrder(
-                action.getCategory(), Const.NVL(action.getCategoryOrder(), 
"0"), false);
-      } else {
-        // Add an "Other" category
-        categoryAndOrder = new CategoryAndOrder(CATEGORY_OTHER, "9999", false);
-      }
-      if (!categories.contains(categoryAndOrder)) {
-        categories.add(categoryAndOrder);
-      }
-    }
-
-    categories.sort(Comparator.comparing(o -> o.order));
-
-    // Correct the icon size which is multiplied in GuiResource...
-    //
-    int correctedIconSize = (int) (iconSize / props.getZoomFactor());
-
-    // Load the action images
-    //
-    items.clear();
-    for (GuiAction action : actions) {
-      ClassLoader classLoader = action.getClassLoader();
-      if (classLoader == null) {
-        classLoader = ClassLoader.getSystemClassLoader();
-      }
-      // Load or get from the image cache...
-      //
-      Image image;
-      try {
-        image =
-            GuiResource.getInstance()
-                .getImage(action.getImage(), classLoader, correctedIconSize, 
correctedIconSize);
-      } catch (Exception e) {
-        image =
-            GuiResource.getInstance()
-                .getSwtImageMissing()
-                .getAsBitmapForSize(display, correctedIconSize, 
correctedIconSize);
-      }
-      items.add(new Item(action, image));
-    }
+    rebuildCategoriesAndItems();
 
     // Add a search bar at the top...
     //
@@ -578,6 +544,7 @@ public class ContextDialog extends Dialog {
 
     // Wait until the dialog is closed
     //
+    Display display = shell.getDisplay();
     while (!shell.isDisposed()) {
       if (!display.readAndDispatch()) {
         display.sleep();
@@ -797,6 +764,23 @@ public class ContextDialog extends Dialog {
         //
         Item item = (Item) areaOwner.getOwner();
         if (item != null) {
+          // ALT-Click: toggle transform/action favorite without closing the 
dialog (issue #3526)
+          //
+          boolean altClicked = (event.stateMask & SWT.ALT) != 0;
+          if (altClicked && 
GuiActionFavorites.tryToggleFromAction(item.getAction())) {
+            try {
+              HopConfig.getInstance().saveToFile();
+            } catch (Exception e) {
+              new ErrorDialog(
+                  shell,
+                  BaseMessages.getString(PKG, 
"ContextDialog.SaveConfig.Error.Dialog.Header"),
+                  BaseMessages.getString(PKG, 
"ContextDialog.SaveConfig.Error.Dialog.Message"),
+                  e);
+            }
+            refreshActionsFromSupplier();
+            return;
+          }
+
           selectedAction = item.getAction();
 
           shiftClicked = (event.stateMask & SWT.SHIFT) != 0;
@@ -812,6 +796,93 @@ public class ContextDialog extends Dialog {
     }
   }
 
+  /**
+   * Rebuild the category list and icon items from the current {@link 
#actions} list. Preserves
+   * collapsed state of categories when refreshing after a favorites toggle.
+   */
+  private void rebuildCategoriesAndItems() {
+    Map<String, Boolean> previousCollapsed = new HashMap<>();
+    if (categories != null) {
+      for (CategoryAndOrder category : categories) {
+        previousCollapsed.put(category.getCategory(), category.isCollapsed());
+      }
+    }
+
+    categories = new ArrayList<>();
+    for (GuiAction action : actions) {
+      CategoryAndOrder categoryAndOrder;
+      if (StringUtils.isNotEmpty(action.getCategory())) {
+        categoryAndOrder =
+            new CategoryAndOrder(
+                action.getCategory(), Const.NVL(action.getCategoryOrder(), 
"0"), false);
+      } else {
+        categoryAndOrder = new CategoryAndOrder(CATEGORY_OTHER, "9999", false);
+      }
+      if (!categories.contains(categoryAndOrder)) {
+        Boolean wasCollapsed = 
previousCollapsed.get(categoryAndOrder.getCategory());
+        if (wasCollapsed != null) {
+          categoryAndOrder.setCollapsed(wasCollapsed);
+        }
+        categories.add(categoryAndOrder);
+      }
+    }
+
+    categories.sort(Comparator.comparing(o -> o.order));
+
+    int correctedIconSize = (int) (iconSize / props.getZoomFactor());
+    Display display = shell != null && !shell.isDisposed() ? 
shell.getDisplay() : null;
+
+    items.clear();
+    for (GuiAction action : actions) {
+      ClassLoader classLoader = action.getClassLoader();
+      if (classLoader == null) {
+        classLoader = ClassLoader.getSystemClassLoader();
+      }
+      Image image;
+      try {
+        image =
+            GuiResource.getInstance()
+                .getImage(action.getImage(), classLoader, correctedIconSize, 
correctedIconSize);
+      } catch (Exception e) {
+        if (display != null) {
+          image =
+              GuiResource.getInstance()
+                  .getSwtImageMissing()
+                  .getAsBitmapForSize(display, correctedIconSize, 
correctedIconSize);
+        } else {
+          image = null;
+        }
+      }
+      items.add(new Item(action, image));
+    }
+  }
+
+  /**
+   * Reload actions from the supplier (after a favorite toggle) and re-apply 
the current search
+   * filter so the Favorites category appears/disappears without closing the 
dialog.
+   */
+  private void refreshActionsFromSupplier() {
+    if (actionsSupplier == null) {
+      return;
+    }
+    String selectedName = selectedItem != null ? 
selectedItem.getAction().getName() : null;
+    actions = actionsSupplier.get();
+    rebuildCategoriesAndItems();
+    String searchText = wSearch != null && !wSearch.isDisposed() ? 
wSearch.getText() : "";
+    filter(searchText);
+
+    // Try to re-select the same tool by name after the list refresh
+    //
+    if (selectedName != null) {
+      for (Item item : filteredItems) {
+        if (selectedName.equals(item.getAction().getName())) {
+          selectItem(item, false);
+          break;
+        }
+      }
+    }
+  }
+
   private void onResize(Event event) {
     updateVerticalBar();
   }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/gui/ShortcutDisplayUtil.java 
b/ui/src/main/java/org/apache/hop/ui/core/gui/ShortcutDisplayUtil.java
index 9fcc70bf0b..081f1c59b4 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/ShortcutDisplayUtil.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/ShortcutDisplayUtil.java
@@ -65,6 +65,14 @@ public final class ShortcutDisplayUtil {
     return out;
   }
 
+  /**
+   * Returns the display label of the ALT modifier for the current platform: 
"⌥" (Option) on macOS,
+   * "ALT" on Windows/Linux. Useful for hints like "ALT-Click to ...".
+   */
+  public static String getAltModifierDisplay() {
+    return Const.isOSX() ? "⌥" : "ALT";
+  }
+
   /**
    * Converts an SWT key code to the display string used in the configuration 
panel (e.g. "⌫" for
    * Delete on macOS, "↑" for arrow up).
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiActionFavorites.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiActionFavorites.java
new file mode 100644
index 0000000000..167b198f6b
--- /dev/null
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiActionFavorites.java
@@ -0,0 +1,223 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.context;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.gui.plugin.action.GuiAction;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.dialog.ContextDialog;
+import org.apache.hop.ui.core.gui.ShortcutDisplayUtil;
+
+/**
+ * Manages user favorites for transforms (pipeline canvas) and workflow 
actions. Favorites are
+ * stored in hop-config.json under guiProperties and shown as a "Favorites" 
category in the context
+ * action dialog, immediately below the Basic category.
+ */
+public final class GuiActionFavorites {
+
+  private static final Class<?> PKG = ContextDialog.class; // i18n
+
+  public static final String CONFIG_KEY_TRANSFORMS = "FavoriteTransforms";
+  public static final String CONFIG_KEY_WORKFLOW_ACTIONS = 
"FavoriteWorkflowActions";
+
+  /** Category order: after Basic ("1"), before other contextual categories 
("2", …). */
+  public static final String CATEGORY_ORDER = "1.5";
+
+  public static final String ID_PREFIX_TRANSFORM = 
"pipeline-graph-create-transform-";
+  public static final String ID_PREFIX_TRANSFORM_FAVORITE =
+      "pipeline-graph-create-transform-favorite-";
+  public static final String ID_PREFIX_ACTION = 
"workflow-graph-create-workflow-action-";
+  public static final String ID_PREFIX_ACTION_FAVORITE =
+      "workflow-graph-create-workflow-action-favorite-";
+
+  public enum Kind {
+    TRANSFORM(CONFIG_KEY_TRANSFORMS),
+    WORKFLOW_ACTION(CONFIG_KEY_WORKFLOW_ACTIONS);
+
+    private final String configKey;
+
+    Kind(String configKey) {
+      this.configKey = configKey;
+    }
+
+    public String getConfigKey() {
+      return configKey;
+    }
+  }
+
+  private GuiActionFavorites() {
+    // utility
+  }
+
+  public static String getFavoritesCategoryName() {
+    return BaseMessages.getString(PKG, "ContextDialog.Category.Favorites");
+  }
+
+  /**
+   * Append the ALT-Click hint to a plugin description for use as a GuiAction 
tooltip. The modifier
+   * is shown with the platform specific label: "⌥" (Option) on macOS, "ALT" 
elsewhere.
+   *
+   * @param description the plugin description (may be null)
+   * @param favorite true if the plugin is already a favorite (show remove 
hint)
+   * @return description plus newline and hint
+   */
+  public static String tooltipWithFavoriteHint(String description, boolean 
favorite) {
+    String modifier = ShortcutDisplayUtil.getAltModifierDisplay();
+    String hint =
+        favorite
+            ? BaseMessages.getString(PKG, "ContextDialog.Favorite.RemoveHint", 
modifier)
+            : BaseMessages.getString(PKG, "ContextDialog.Favorite.AddHint", 
modifier);
+    return Const.NVL(description, "") + hint;
+  }
+
+  public static List<String> getFavoriteIds(Kind kind) {
+    return parseIds(HopConfig.getGuiProperty(kind.getConfigKey()));
+  }
+
+  public static boolean isFavorite(Kind kind, String pluginId) {
+    if (StringUtils.isEmpty(pluginId)) {
+      return false;
+    }
+    return getFavoriteIds(kind).contains(pluginId);
+  }
+
+  /**
+   * Toggle the given plugin ID in the favorites list and persist to hop 
config.
+   *
+   * @return true if the plugin is a favorite after the toggle, false if 
removed
+   */
+  public static boolean toggle(Kind kind, String pluginId) {
+    if (StringUtils.isEmpty(pluginId)) {
+      return false;
+    }
+    Set<String> ids = new LinkedHashSet<>(getFavoriteIds(kind));
+    boolean nowFavorite;
+    if (ids.contains(pluginId)) {
+      ids.remove(pluginId);
+      nowFavorite = false;
+    } else {
+      ids.add(pluginId);
+      nowFavorite = true;
+    }
+    saveIds(kind, new ArrayList<>(ids));
+    return nowFavorite;
+  }
+
+  /**
+   * If the action id refers to a create-transform or create-action entry, 
toggle its favorite
+   * status.
+   *
+   * @param action the selected gui action
+   * @return true if a favorite was toggled (caller should refresh the dialog)
+   */
+  public static boolean tryToggleFromAction(GuiAction action) {
+    if (action == null || StringUtils.isEmpty(action.getId())) {
+      return false;
+    }
+    String id = action.getId();
+    KindAndPluginId resolved = resolve(id);
+    if (resolved == null) {
+      return false;
+    }
+    toggle(resolved.kind, resolved.pluginId);
+    return true;
+  }
+
+  /**
+   * Create a Favorites-category copy of a create action for the given plugin 
id.
+   *
+   * @param base the original create action
+   * @param kind transform or workflow action
+   * @param pluginId plugin id
+   * @return a new GuiAction with Favorites category and order
+   */
+  public static GuiAction createFavoriteAction(GuiAction base, Kind kind, 
String pluginId) {
+    GuiAction favorite = new GuiAction(base);
+    favorite.setId(favoriteId(kind, pluginId));
+    favorite.setCategory(getFavoritesCategoryName());
+    favorite.setCategoryOrder(CATEGORY_ORDER);
+    return favorite;
+  }
+
+  public static String favoriteId(Kind kind, String pluginId) {
+    return switch (kind) {
+      case TRANSFORM -> ID_PREFIX_TRANSFORM_FAVORITE + pluginId;
+      case WORKFLOW_ACTION -> ID_PREFIX_ACTION_FAVORITE + pluginId;
+    };
+  }
+
+  public static String createId(Kind kind, String pluginId) {
+    return switch (kind) {
+      case TRANSFORM -> ID_PREFIX_TRANSFORM + pluginId;
+      case WORKFLOW_ACTION -> ID_PREFIX_ACTION + pluginId;
+    };
+  }
+
+  static List<String> parseIds(String csv) {
+    List<String> result = new ArrayList<>();
+    if (StringUtils.isEmpty(csv)) {
+      return result;
+    }
+    Arrays.stream(csv.split(","))
+        .map(String::trim)
+        .filter(StringUtils::isNotEmpty)
+        .forEach(result::add);
+    return result;
+  }
+
+  static String serializeIds(List<String> ids) {
+    if (ids == null || ids.isEmpty()) {
+      return "";
+    }
+    return String.join(",", ids);
+  }
+
+  private static void saveIds(Kind kind, List<String> ids) {
+    // Persist the in-memory guiProperties value; the context dialog saves 
hop-config.json after a
+    // successful toggle so unit tests can exercise this without writing the 
config file.
+    HopConfig.setGuiProperty(kind.getConfigKey(), serializeIds(ids));
+  }
+
+  private static KindAndPluginId resolve(String actionId) {
+    if (actionId.startsWith(ID_PREFIX_TRANSFORM_FAVORITE)) {
+      return new KindAndPluginId(
+          Kind.TRANSFORM, 
actionId.substring(ID_PREFIX_TRANSFORM_FAVORITE.length()));
+    }
+    if (actionId.startsWith(ID_PREFIX_TRANSFORM)) {
+      return new KindAndPluginId(Kind.TRANSFORM, 
actionId.substring(ID_PREFIX_TRANSFORM.length()));
+    }
+    if (actionId.startsWith(ID_PREFIX_ACTION_FAVORITE)) {
+      return new KindAndPluginId(
+          Kind.WORKFLOW_ACTION, 
actionId.substring(ID_PREFIX_ACTION_FAVORITE.length()));
+    }
+    if (actionId.startsWith(ID_PREFIX_ACTION)) {
+      return new KindAndPluginId(
+          Kind.WORKFLOW_ACTION, actionId.substring(ID_PREFIX_ACTION.length()));
+    }
+    return null;
+  }
+
+  private record KindAndPluginId(Kind kind, String pluginId) {}
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiContextUtil.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiContextUtil.java
index 7f49d04120..35cabcd6b0 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiContextUtil.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/context/GuiContextUtil.java
@@ -162,7 +162,12 @@ public class GuiContextUtil {
 
         contextDialog =
             new ContextDialog(
-                parent, message, clickLocation, actions, 
contextHandler.getContextId());
+                parent,
+                message,
+                clickLocation,
+                actions,
+                contextHandler.getContextId(),
+                contextHandler::getSupportedActions);
         shellDialogMap.put(parent.getText(), contextDialog);
         GuiAction selectedAction = contextDialog.open();
         shellDialogMap.remove(parent.getText());
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/context/HopGuiPipelineContext.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/context/HopGuiPipelineContext.java
index 8587dc92a7..ea5092673b 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/context/HopGuiPipelineContext.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/context/HopGuiPipelineContext.java
@@ -32,6 +32,7 @@ import org.apache.hop.core.plugins.TransformPluginType;
 import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.ui.hopgui.PaletteEngineFilter;
 import org.apache.hop.ui.hopgui.context.BaseGuiContextHandler;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
 import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
 import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
 
@@ -86,17 +87,20 @@ public class HopGuiPipelineContext extends 
BaseGuiContextHandler implements IGui
       if (!filter.isPluginAllowed(transformPlugin)) {
         continue;
       }
+      String pluginId = transformPlugin.getIds()[0];
+      boolean favorite = 
GuiActionFavorites.isFavorite(GuiActionFavorites.Kind.TRANSFORM, pluginId);
       GuiAction createTransformAction =
           new GuiAction(
-              "pipeline-graph-create-transform-" + transformPlugin.getIds()[0],
+              GuiActionFavorites.createId(GuiActionFavorites.Kind.TRANSFORM, 
pluginId),
               GuiActionType.Create,
               transformPlugin.getName(),
-              transformPlugin.getDescription(),
+              GuiActionFavorites.tooltipWithFavoriteHint(
+                  transformPlugin.getDescription(), favorite),
               transformPlugin.getImageFile(),
               (shiftClicked, controlClicked, t) ->
                   pipelineGraph.pipelineTransformDelegate.newTransform(
                       pipelineMeta,
-                      transformPlugin.getIds()[0],
+                      pluginId,
                       transformPlugin.getName(),
                       transformPlugin.getDescription(),
                       controlClicked,
@@ -113,11 +117,17 @@ public class HopGuiPipelineContext extends 
BaseGuiContextHandler implements IGui
       try {
         
createTransformAction.setClassLoader(registry.getClassLoader(transformPlugin));
       } catch (HopPluginException e) {
-        LogChannel.UI.logError(
-            "Unable to get classloader for transform plugin " + 
transformPlugin.getIds()[0], e);
+        LogChannel.UI.logError("Unable to get classloader for transform plugin 
" + pluginId, e);
       }
       createTransformAction.getKeywords().add(transformPlugin.getCategory());
       actions.add(createTransformAction);
+
+      // Duplicate under Favorites when the user marked this transform as 
favorite (issue #3526)
+      if (favorite) {
+        actions.add(
+            GuiActionFavorites.createFavoriteAction(
+                createTransformAction, GuiActionFavorites.Kind.TRANSFORM, 
pluginId));
+      }
     }
 
     return actions;
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/context/HopGuiWorkflowContext.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/context/HopGuiWorkflowContext.java
index 042eea05a5..13930d1ec1 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/context/HopGuiWorkflowContext.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/context/HopGuiWorkflowContext.java
@@ -31,6 +31,7 @@ import org.apache.hop.core.plugins.IPlugin;
 import org.apache.hop.core.plugins.PluginRegistry;
 import org.apache.hop.ui.hopgui.PaletteEngineFilter;
 import org.apache.hop.ui.hopgui.context.BaseGuiContextHandler;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites;
 import org.apache.hop.ui.hopgui.context.IGuiContextHandler;
 import org.apache.hop.ui.hopgui.file.workflow.HopGuiWorkflowGraph;
 import org.apache.hop.workflow.WorkflowMeta;
@@ -87,20 +88,19 @@ public class HopGuiWorkflowContext extends 
BaseGuiContextHandler implements IGui
         continue;
       }
 
+      String pluginId = actionPlugin.getIds()[0];
+      boolean favorite =
+          
GuiActionFavorites.isFavorite(GuiActionFavorites.Kind.WORKFLOW_ACTION, 
pluginId);
       GuiAction createActionGuiAction =
           new GuiAction(
-              "workflow-graph-create-workflow-action-" + 
actionPlugin.getIds()[0],
+              
GuiActionFavorites.createId(GuiActionFavorites.Kind.WORKFLOW_ACTION, pluginId),
               GuiActionType.Create,
               actionPlugin.getName(),
-              actionPlugin.getDescription(),
+              
GuiActionFavorites.tooltipWithFavoriteHint(actionPlugin.getDescription(), 
favorite),
               actionPlugin.getImageFile(),
               (shiftClicked, controlClicked, t) ->
                   workflowGraph.workflowActionDelegate.newAction(
-                      workflowMeta,
-                      actionPlugin.getIds()[0],
-                      actionPlugin.getName(),
-                      controlClicked,
-                      click));
+                      workflowMeta, pluginId, actionPlugin.getName(), 
controlClicked, click));
       
createActionGuiAction.getKeywords().addAll(Arrays.asList(actionPlugin.getKeywords()));
       // Also search on the English name/category/keywords for non-English 
locales (issue #2633)
       
createActionGuiAction.getKeywords().addAll(Arrays.asList(actionPlugin.getEnglishKeywords()));
@@ -110,11 +110,17 @@ public class HopGuiWorkflowContext extends 
BaseGuiContextHandler implements IGui
       try {
         
createActionGuiAction.setClassLoader(registry.getClassLoader(actionPlugin));
       } catch (HopPluginException e) {
-        LogChannel.UI.logError(
-            "Unable to get classloader for action plugin " + 
actionPlugin.getIds()[0], e);
+        LogChannel.UI.logError("Unable to get classloader for action plugin " 
+ pluginId, e);
       }
       createActionGuiAction.getKeywords().add(actionPlugin.getCategory());
       guiActions.add(createActionGuiAction);
+
+      // Duplicate under Favorites when the user marked this action as 
favorite (issue #3526)
+      if (favorite) {
+        guiActions.add(
+            GuiActionFavorites.createFavoriteAction(
+                createActionGuiAction, 
GuiActionFavorites.Kind.WORKFLOW_ACTION, pluginId));
+      }
     }
 
     return guiActions;
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
index 7e0760d31b..07434d07f9 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
@@ -43,6 +43,9 @@ CheckResultDialog.TextDialog.Title=View message
 CheckResultDialog.Title=Results of pipeline checks
 CheckResultDialog.TransformName.Label=TransformName
 CheckResultDialog.WarningsErrors.Label=Warnings and errors 
+ContextDialog.Category.Favorites=Favorites
+ContextDialog.Favorite.AddHint=\n{0}-Click to add to favorite
+ContextDialog.Favorite.RemoveHint=\n{0}-Click to remove from favorite
 ContextDialog.GuiAction.CollapseCategories.Tooltip=Collapse all categories
 ContextDialog.GuiAction.ExpandCategories.Tooltip=Expand all categories
 ContextDialog.GuiAction.FixedWidth.Label=Fixed width
diff --git 
a/ui/src/test/java/org/apache/hop/ui/hopgui/context/GuiActionFavoritesTest.java 
b/ui/src/test/java/org/apache/hop/ui/hopgui/context/GuiActionFavoritesTest.java
new file mode 100644
index 0000000000..87fd6c5e88
--- /dev/null
+++ 
b/ui/src/test/java/org/apache/hop/ui/hopgui/context/GuiActionFavoritesTest.java
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.context;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.List;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.gui.plugin.action.GuiAction;
+import org.apache.hop.core.gui.plugin.action.GuiActionType;
+import org.apache.hop.ui.hopgui.context.GuiActionFavorites.Kind;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class GuiActionFavoritesTest {
+
+  private String savedTransforms;
+  private String savedActions;
+
+  @BeforeEach
+  void setUp() {
+    savedTransforms = 
HopConfig.getGuiProperty(GuiActionFavorites.CONFIG_KEY_TRANSFORMS);
+    savedActions = 
HopConfig.getGuiProperty(GuiActionFavorites.CONFIG_KEY_WORKFLOW_ACTIONS);
+    HopConfig.setGuiProperty(GuiActionFavorites.CONFIG_KEY_TRANSFORMS, "");
+    HopConfig.setGuiProperty(GuiActionFavorites.CONFIG_KEY_WORKFLOW_ACTIONS, 
"");
+  }
+
+  @AfterEach
+  void tearDown() {
+    if (savedTransforms == null) {
+      
HopConfig.readGuiProperties().remove(GuiActionFavorites.CONFIG_KEY_TRANSFORMS);
+    } else {
+      HopConfig.setGuiProperty(GuiActionFavorites.CONFIG_KEY_TRANSFORMS, 
savedTransforms);
+    }
+    if (savedActions == null) {
+      
HopConfig.readGuiProperties().remove(GuiActionFavorites.CONFIG_KEY_WORKFLOW_ACTIONS);
+    } else {
+      HopConfig.setGuiProperty(GuiActionFavorites.CONFIG_KEY_WORKFLOW_ACTIONS, 
savedActions);
+    }
+  }
+
+  @Test
+  void parseAndSerializeIds() {
+    assertTrue(GuiActionFavorites.parseIds(null).isEmpty());
+    assertTrue(GuiActionFavorites.parseIds("").isEmpty());
+    assertEquals(List.of("a", "b", "c"), GuiActionFavorites.parseIds("a, 
b,c"));
+    assertEquals("a,b,c", GuiActionFavorites.serializeIds(Arrays.asList("a", 
"b", "c")));
+    assertEquals("", GuiActionFavorites.serializeIds(List.of()));
+  }
+
+  @Test
+  void toggleTransformFavorite() {
+    assertFalse(GuiActionFavorites.isFavorite(Kind.TRANSFORM, 
"TextFileInput"));
+
+    assertTrue(GuiActionFavorites.toggle(Kind.TRANSFORM, "TextFileInput"));
+    assertTrue(GuiActionFavorites.isFavorite(Kind.TRANSFORM, "TextFileInput"));
+    assertEquals(List.of("TextFileInput"), 
GuiActionFavorites.getFavoriteIds(Kind.TRANSFORM));
+
+    assertFalse(GuiActionFavorites.toggle(Kind.TRANSFORM, "TextFileInput"));
+    assertFalse(GuiActionFavorites.isFavorite(Kind.TRANSFORM, 
"TextFileInput"));
+    assertTrue(GuiActionFavorites.getFavoriteIds(Kind.TRANSFORM).isEmpty());
+  }
+
+  @Test
+  void transformAndActionListsAreIndependent() {
+    GuiActionFavorites.toggle(Kind.TRANSFORM, "SelectValues");
+    GuiActionFavorites.toggle(Kind.WORKFLOW_ACTION, "PIPELINE");
+
+    assertTrue(GuiActionFavorites.isFavorite(Kind.TRANSFORM, "SelectValues"));
+    assertFalse(GuiActionFavorites.isFavorite(Kind.TRANSFORM, "PIPELINE"));
+    assertTrue(GuiActionFavorites.isFavorite(Kind.WORKFLOW_ACTION, 
"PIPELINE"));
+    assertFalse(GuiActionFavorites.isFavorite(Kind.WORKFLOW_ACTION, 
"SelectValues"));
+  }
+
+  @Test
+  void tryToggleFromActionId() {
+    GuiAction create =
+        new GuiAction(
+            GuiActionFavorites.createId(Kind.TRANSFORM, "FilterRows"),
+            GuiActionType.Create,
+            "Filter rows",
+            "desc",
+            "image.svg",
+            (s, c, t) -> {});
+    assertTrue(GuiActionFavorites.tryToggleFromAction(create));
+    assertTrue(GuiActionFavorites.isFavorite(Kind.TRANSFORM, "FilterRows"));
+
+    GuiAction favorite =
+        new GuiAction(
+            GuiActionFavorites.favoriteId(Kind.TRANSFORM, "FilterRows"),
+            GuiActionType.Create,
+            "Filter rows",
+            "desc",
+            "image.svg",
+            (s, c, t) -> {});
+    assertTrue(GuiActionFavorites.tryToggleFromAction(favorite));
+    assertFalse(GuiActionFavorites.isFavorite(Kind.TRANSFORM, "FilterRows"));
+
+    GuiAction other =
+        new GuiAction(
+            "pipeline-graph-edit-properties",
+            GuiActionType.Modify,
+            "Edit",
+            "d",
+            "i",
+            (s, c, t) -> {});
+    assertFalse(GuiActionFavorites.tryToggleFromAction(other));
+  }
+
+  @Test
+  void createFavoriteActionSetsCategoryAndOrder() {
+    GuiAction base =
+        new GuiAction(
+            GuiActionFavorites.createId(Kind.WORKFLOW_ACTION, "START"),
+            GuiActionType.Create,
+            "Start",
+            "desc",
+            "image.svg",
+            (s, c, t) -> {});
+    base.setCategory("General");
+    base.setCategoryOrder("9999_General");
+
+    GuiAction favorite =
+        GuiActionFavorites.createFavoriteAction(base, Kind.WORKFLOW_ACTION, 
"START");
+
+    assertEquals(GuiActionFavorites.favoriteId(Kind.WORKFLOW_ACTION, "START"), 
favorite.getId());
+    assertEquals(GuiActionFavorites.CATEGORY_ORDER, 
favorite.getCategoryOrder());
+    assertEquals(base.getActionLambda(), favorite.getActionLambda());
+    assertEquals("Start", favorite.getName());
+  }
+
+  @Test
+  void multiFavoriteOrderPreserved() {
+    GuiActionFavorites.toggle(Kind.TRANSFORM, "A");
+    GuiActionFavorites.toggle(Kind.TRANSFORM, "B");
+    GuiActionFavorites.toggle(Kind.TRANSFORM, "C");
+    assertEquals(List.of("A", "B", "C"), 
GuiActionFavorites.getFavoriteIds(Kind.TRANSFORM));
+
+    GuiActionFavorites.toggle(Kind.TRANSFORM, "B");
+    assertEquals(List.of("A", "C"), 
GuiActionFavorites.getFavoriteIds(Kind.TRANSFORM));
+  }
+}

Reply via email to