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

mattcasters 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 ada9a5c51b Issue #2728 : Create mapping from selected sequential 
transforms (#8272)
ada9a5c51b is described below

commit ada9a5c51bf8704274e3566a0f5b065c20d7a757
Author: Matt Casters <[email protected]>
AuthorDate: Tue Sep 8 14:22:02 2026 +0200

    Issue #2728 : Create mapping from selected sequential transforms (#8272)
    
    * Issue #2728 : Create mapping from selected sequential transforms
    
    * Issue #2728 : Keep dialog filename and align mapping save with pipeline 
save
---
 .../ROOT/pages/pipeline/create-pipeline.adoc       |   1 +
 .../pages/pipeline/transforms/simple-mapping.adoc  |  11 +
 .../pages/snippets/best-practices/mappings.adoc    |   2 +
 .../mapping/CreateMappingFromSelection.java        | 603 +++++++++++++++++++++
 .../transforms/mapping/CreateMappingGuiPlugin.java | 208 +++++++
 .../mapping/messages/messages_en_US.properties     |  19 +-
 .../mapping/CreateMappingFromSelectionTest.java    | 409 ++++++++++++++
 7 files changed, 1252 insertions(+), 1 deletion(-)

diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/create-pipeline.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/create-pipeline.adoc
index cb435efbf1..81759a9e18 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/pipeline/create-pipeline.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/pipeline/create-pipeline.adoc
@@ -96,6 +96,7 @@ image::hop-gui/pipeline/transforms.png[Hop - 
transforms,75%,align="left"]
 |===
 |Action|Description
 |Detach transform|Detach the transform from the pipeline
+|Create mapping|Extract the selected sequential transforms into a mapping 
pipeline and replace them with a Simple Mapping transform
 |Edit the transform|Edit the transform's metadata
 |Copy transform to clipboard|Copies selected items to clipboard.
 |Create hop| Creates a new hop between two transforms.
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/simple-mapping.adoc
 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/simple-mapping.adoc
index 881107c7e9..234a7e97b7 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/simple-mapping.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/pipeline/transforms/simple-mapping.adoc
@@ -46,6 +46,17 @@ Use this transform if you find yourself repeating the same 
logic multiple times
 
 If you need more than one input or output, or info / target hops (for example 
a reusable Stream Lookup or Switch / Case), use 
xref:pipeline/transforms/multi-mapping.adoc[Multi Mapping] instead.
 
+== Create mapping from the canvas
+
+To extract an existing sequential chain into a mapping:
+
+. Select one transform, or a chain of transforms connected by hops (`A → B → 
C`).
+. Right-click a selected transform and choose *Create mapping*.
+. Save the new mapping pipeline (Mapping input, the selected transforms, 
Mapping output).
+. Hop replaces the selection with a Simple Mapping transform at the location 
of the first selected transform and rewires the surrounding hops.
+
+The selection must be a single path with at most one incoming hop and one 
outgoing hop. Splits, joins, extra info hops, and error hops that leave the 
selection are not supported; use Multi Mapping for those cases. Cancelling the 
save dialog leaves the original pipeline unchanged.
+
 == Options
 
 The options are fairly self-explanatory:
diff --git 
a/docs/hop-user-manual/modules/ROOT/pages/snippets/best-practices/mappings.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/snippets/best-practices/mappings.adoc
index 8667fae483..fc46ad928c 100644
--- 
a/docs/hop-user-manual/modules/ROOT/pages/snippets/best-practices/mappings.adoc
+++ 
b/docs/hop-user-manual/modules/ROOT/pages/snippets/best-practices/mappings.adoc
@@ -26,6 +26,8 @@ The Simple Mapping is a pipeline reading from a 
xref:pipeline/transforms/mapping
 
 You can re-use the work in other pipelines using the 
xref:pipeline/transforms/simple-mapping.adoc[Simple Mapping] transform.
 
+To turn an existing sequential chain into a mapping, select the transforms on 
the canvas and choose *Create mapping* from the transform context menu.
+
 When the reusable logic needs more than one input or output, or info / target 
hops, use xref:pipeline/transforms/multi-mapping.adoc[Multi Mapping] instead.
 
 
diff --git 
a/plugins/transforms/mapping/src/main/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingFromSelection.java
 
b/plugins/transforms/mapping/src/main/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingFromSelection.java
new file mode 100644
index 0000000000..1e4fc71396
--- /dev/null
+++ 
b/plugins/transforms/mapping/src/main/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingFromSelection.java
@@ -0,0 +1,603 @@
+/*
+ * 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.pipeline.transforms.mapping;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import lombok.Getter;
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.core.parameters.DuplicateParamException;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.pipeline.PipelineHopMeta;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.ITransformIOMeta;
+import org.apache.hop.pipeline.transform.ITransformMeta;
+import org.apache.hop.pipeline.transform.TransformErrorMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transform.stream.IStream;
+import org.apache.hop.pipeline.transforms.input.MappingInputMeta;
+import org.apache.hop.pipeline.transforms.multimapping.MultiMappingMeta;
+import org.apache.hop.pipeline.transforms.output.MappingOutputMeta;
+
+/**
+ * Extracts a sequential chain of transforms into a mapping pipeline (Mapping 
Input → selection →
+ * Mapping Output) and replaces that chain in the parent pipeline with a 
Simple Mapping transform.
+ */
+public final class CreateMappingFromSelection {
+
+  private static final Class<?> PKG = CreateMappingFromSelection.class;
+
+  static final String PLUGIN_MAPPING_INPUT = "MappingInput";
+  static final String PLUGIN_MAPPING_OUTPUT = "MappingOutput";
+  static final String PLUGIN_SIMPLE_MAPPING = "SimpleMapping";
+  static final String PLUGIN_MULTI_MAPPING = "MultiMapping";
+
+  static final String DEFAULT_MAPPING_INPUT_NAME = "Mapping Input";
+  static final String DEFAULT_MAPPING_OUTPUT_NAME = "Mapping Output";
+  static final String DEFAULT_SIMPLE_MAPPING_NAME = "Simple mapping";
+
+  static final int MAPPING_IO_X_OFFSET = 150;
+
+  public static final String KEY_EMPTY = "CreateMapping.Error.EmptySelection";
+  public static final String KEY_MAPPING_TRANSFORM = 
"CreateMapping.Error.MappingTransform";
+  public static final String KEY_NOT_CONNECTED = 
"CreateMapping.Error.NotConnected";
+  public static final String KEY_NOT_A_PATH = "CreateMapping.Error.NotAPath";
+  public static final String KEY_MULTIPLE_INPUTS = 
"CreateMapping.Error.MultipleInputs";
+  public static final String KEY_MULTIPLE_OUTPUTS = 
"CreateMapping.Error.MultipleOutputs";
+  public static final String KEY_ERROR_HOP = "CreateMapping.Error.ErrorHopOut";
+  public static final String KEY_INFO_HOP = "CreateMapping.Error.InfoHopIn";
+  public static final String KEY_CYCLE = "CreateMapping.Error.Cycle";
+
+  private CreateMappingFromSelection() {
+    // utility
+  }
+
+  /**
+   * When the context transform is part of a multi-selection, operate on the 
whole selection;
+   * otherwise just the clicked transform.
+   */
+  public static List<TransformMeta> resolveSelectedTransforms(
+      PipelineMeta pipelineMeta, TransformMeta contextTransform) {
+    if (contextTransform == null) {
+      return List.of();
+    }
+    List<TransformMeta> selected = pipelineMeta.getSelectedTransforms();
+    if (Utils.isEmpty(selected) || !selected.contains(contextTransform)) {
+      return List.of(contextTransform);
+    }
+    return new ArrayList<>(selected);
+  }
+
+  public static Result analyze(PipelineMeta parent, List<TransformMeta> 
selectedTransforms) {
+    if (Utils.isEmpty(selectedTransforms)) {
+      return Result.error(KEY_EMPTY);
+    }
+
+    Set<TransformMeta> selected = new HashSet<>(selectedTransforms);
+    for (TransformMeta transformMeta : selectedTransforms) {
+      if (isMappingPlugin(transformMeta)) {
+        return Result.error(KEY_MAPPING_TRANSFORM, transformMeta.getName());
+      }
+    }
+
+    List<PipelineHopMeta> internalHops = new ArrayList<>();
+    List<PipelineHopMeta> externalIncoming = new ArrayList<>();
+    List<PipelineHopMeta> externalOutgoing = new ArrayList<>();
+    boolean infoIncoming = false;
+    boolean boundaryErrorHop = false;
+
+    for (PipelineHopMeta hop : parent.getPipelineHops()) {
+      TransformMeta from = hop.getFromTransform();
+      TransformMeta to = hop.getToTransform();
+      if (from == null || to == null) {
+        continue;
+      }
+      boolean fromSel = selected.contains(from);
+      boolean toSel = selected.contains(to);
+      if (!fromSel && !toSel) {
+        continue;
+      }
+      if (isErrorHop(hop, from, to) && fromSel != toSel) {
+        boundaryErrorHop = true;
+      }
+      if (fromSel && toSel) {
+        internalHops.add(hop);
+      } else if (!fromSel && toSel) {
+        if (to.getTransform() != null && parent.isTransformInformative(to, 
from)) {
+          infoIncoming = true;
+        }
+        externalIncoming.add(hop);
+      } else {
+        externalOutgoing.add(hop);
+      }
+    }
+
+    for (TransformMeta transformMeta : selectedTransforms) {
+      TransformErrorMeta errorMeta = transformMeta.getTransformErrorMeta();
+      if (errorMeta != null
+          && errorMeta.getTargetTransform() != null
+          && !selected.contains(errorMeta.getTargetTransform())) {
+        boundaryErrorHop = true;
+      }
+    }
+
+    if (boundaryErrorHop) {
+      return Result.error(KEY_ERROR_HOP);
+    }
+    if (infoIncoming) {
+      return Result.error(KEY_INFO_HOP);
+    }
+    if (externalIncoming.size() > 1) {
+      return Result.error(KEY_MULTIPLE_INPUTS);
+    }
+    if (externalOutgoing.size() > 1) {
+      return Result.error(KEY_MULTIPLE_OUTPUTS);
+    }
+
+    Map<TransformMeta, Integer> selectedPred = new HashMap<>();
+    Map<TransformMeta, Integer> selectedSucc = new HashMap<>();
+    Map<TransformMeta, List<TransformMeta>> undirected = new HashMap<>();
+    for (TransformMeta transformMeta : selectedTransforms) {
+      selectedPred.put(transformMeta, 0);
+      selectedSucc.put(transformMeta, 0);
+      undirected.put(transformMeta, new ArrayList<>());
+    }
+    for (PipelineHopMeta hop : internalHops) {
+      TransformMeta from = hop.getFromTransform();
+      TransformMeta to = hop.getToTransform();
+      selectedSucc.merge(from, 1, Integer::sum);
+      selectedPred.merge(to, 1, Integer::sum);
+      undirected.get(from).add(to);
+      undirected.get(to).add(from);
+    }
+    for (TransformMeta transformMeta : selectedTransforms) {
+      if (selectedPred.get(transformMeta) > 1 || 
selectedSucc.get(transformMeta) > 1) {
+        return Result.error(KEY_NOT_A_PATH);
+      }
+    }
+
+    if (selectedTransforms.size() > 1) {
+      if (internalHops.isEmpty()) {
+        return Result.error(KEY_NOT_CONNECTED);
+      }
+      Set<TransformMeta> visited = new HashSet<>();
+      ArrayDeque<TransformMeta> queue = new ArrayDeque<>();
+      TransformMeta start = selectedTransforms.get(0);
+      queue.add(start);
+      visited.add(start);
+      while (!queue.isEmpty()) {
+        TransformMeta current = queue.removeFirst();
+        for (TransformMeta neighbor : undirected.get(current)) {
+          if (visited.add(neighbor)) {
+            queue.add(neighbor);
+          }
+        }
+      }
+      if (visited.size() != selected.size()) {
+        return Result.error(KEY_NOT_CONNECTED);
+      }
+    }
+
+    List<TransformMeta> entries = new ArrayList<>();
+    List<TransformMeta> exits = new ArrayList<>();
+    for (TransformMeta transformMeta : selectedTransforms) {
+      if (selectedPred.get(transformMeta) == 0) {
+        entries.add(transformMeta);
+      }
+      if (selectedSucc.get(transformMeta) == 0) {
+        exits.add(transformMeta);
+      }
+    }
+    if (entries.size() != 1 || exits.size() != 1) {
+      return Result.error(KEY_CYCLE);
+    }
+
+    TransformMeta entry = entries.get(0);
+    TransformMeta exit = exits.get(0);
+    PipelineHopMeta incomingHop = externalIncoming.isEmpty() ? null : 
externalIncoming.get(0);
+    PipelineHopMeta outgoingHop = externalOutgoing.isEmpty() ? null : 
externalOutgoing.get(0);
+
+    PipelineMeta mappingPipeline =
+        buildMappingPipeline(parent, selectedTransforms, internalHops, entry, 
exit);
+
+    Point location =
+        entry.getLocation() != null ? new Point(entry.getLocation()) : new 
Point(50, 50);
+
+    return new Result(
+        null,
+        null,
+        mappingPipeline,
+        entry,
+        exit,
+        incomingHop != null ? incomingHop.getFromTransform() : null,
+        outgoingHop != null ? outgoingHop.getToTransform() : null,
+        incomingHop == null || incomingHop.isEnabled(),
+        outgoingHop == null || outgoingHop.isEnabled(),
+        location,
+        new ArrayList<>(selectedTransforms));
+  }
+
+  public static TransformMeta replaceSelection(
+      PipelineMeta parent, Result result, String mappingFilename) {
+    if (result == null || !result.isValid()) {
+      return null;
+    }
+
+    List<TransformMeta> selected = result.getSelected();
+    Set<TransformMeta> selectedSet = new HashSet<>(selected);
+
+    List<PipelineHopMeta> hopsToRemove = new ArrayList<>();
+    for (PipelineHopMeta hop : parent.getPipelineHops()) {
+      TransformMeta from = hop.getFromTransform();
+      TransformMeta to = hop.getToTransform();
+      if ((from != null && selectedSet.contains(from))
+          || (to != null && selectedSet.contains(to))) {
+        hopsToRemove.add(hop);
+      }
+    }
+
+    for (PipelineHopMeta hop : hopsToRemove) {
+      TransformMeta from = hop.getFromTransform();
+      TransformMeta to = hop.getToTransform();
+      if (to != null && !selectedSet.contains(to) && to.getTransform() != 
null) {
+        to.getTransform().cleanAfterHopToRemove(from);
+      }
+      if (from != null && !selectedSet.contains(from) && from.getTransform() 
!= null) {
+        from.getTransform().cleanAfterHopFromRemove(to);
+      }
+    }
+
+    for (TransformMeta transformMeta : selected) {
+      int index = parent.indexOfTransform(transformMeta);
+      if (index >= 0) {
+        parent.removeTransform(index);
+      }
+    }
+
+    SimpleMappingMeta mappingMeta = new SimpleMappingMeta();
+    mappingMeta.setDefault();
+    mappingMeta.setFilename(mappingFilename);
+
+    String name = 
parent.getAlternativeTransformName(transformNameFromFilename(mappingFilename));
+    TransformMeta simpleMapping = new TransformMeta(PLUGIN_SIMPLE_MAPPING, 
name, mappingMeta);
+    Point location = result.getSimpleMappingLocation();
+    if (location != null) {
+      simpleMapping.setLocation(location.x, location.y);
+    }
+    parent.addTransform(simpleMapping);
+
+    TransformMeta incomingFrom = result.getIncomingFrom();
+    if (incomingFrom != null && parent.findTransform(incomingFrom.getName()) 
!= null) {
+      PipelineHopMeta inHop =
+          new PipelineHopMeta(incomingFrom, simpleMapping, 
result.isIncomingHopEnabled());
+      parent.addPipelineHop(inHop);
+      retargetTargetStreams(incomingFrom, result.getEntry(), simpleMapping);
+    }
+
+    TransformMeta outgoingTo = result.getOutgoingTo();
+    if (outgoingTo != null && parent.findTransform(outgoingTo.getName()) != 
null) {
+      PipelineHopMeta outHop =
+          new PipelineHopMeta(simpleMapping, outgoingTo, 
result.isOutgoingHopEnabled());
+      parent.addPipelineHop(outHop);
+      retargetInfoStreams(outgoingTo, result.getExit(), simpleMapping);
+    }
+
+    if (incomingFrom != null && incomingFrom.getTransform() != null) {
+      
incomingFrom.getTransform().searchInfoAndTargetTransforms(parent.getTransforms());
+    }
+    if (outgoingTo != null && outgoingTo.getTransform() != null) {
+      
outgoingTo.getTransform().searchInfoAndTargetTransforms(parent.getTransforms());
+    }
+
+    parent.unselectAll();
+    simpleMapping.setSelected(true);
+    return simpleMapping;
+  }
+
+  /** Base filename without extension, used as the Simple Mapping transform 
name. */
+  public static String transformNameFromFilename(String mappingFilename) {
+    if (StringUtils.isEmpty(mappingFilename)) {
+      return DEFAULT_SIMPLE_MAPPING_NAME;
+    }
+    String baseName = FilenameUtils.getBaseName(mappingFilename.replace('\\', 
'/'));
+    return StringUtils.isEmpty(baseName) ? DEFAULT_SIMPLE_MAPPING_NAME : 
baseName;
+  }
+
+  /**
+   * Resolves variables in a path. Returns {@code null} when the result still 
contains {@code
+   * ${...}} so callers do not treat an expression as a real folder (e.g. 
{@code
+   * ./target/hop/${PROJECT_HOME}}).
+   */
+  public static String resolveFilesystemPath(String path, IVariables 
variables) {
+    if (StringUtils.isEmpty(path)) {
+      return path;
+    }
+    String resolved = variables != null ? variables.resolve(path) : path;
+    if (resolved.contains("${")) {
+      return null;
+    }
+    return resolved;
+  }
+
+  public static String suggestFilename(
+      PipelineMeta parent, TransformMeta entry, IVariables variables) {
+    String folder = null;
+    String parentBase = "pipeline";
+    if (parent != null && StringUtils.isNotEmpty(parent.getFilename())) {
+      String parentFilename = resolveFilesystemPath(parent.getFilename(), 
variables);
+      if (StringUtils.isNotEmpty(parentFilename)) {
+        try {
+          FileObject file = HopVfs.getFileObject(parentFilename, variables);
+          FileObject parentFolder = file.getParent();
+          if (parentFolder != null) {
+            folder = parentFolder.getName().getURI();
+          }
+          String baseName = file.getName().getBaseName();
+          if 
(baseName.toLowerCase().endsWith(PipelineMeta.PIPELINE_EXTENSION)) {
+            parentBase =
+                baseName.substring(0, baseName.length() - 
PipelineMeta.PIPELINE_EXTENSION.length());
+          } else if (StringUtils.isNotEmpty(baseName)) {
+            parentBase = baseName;
+          }
+        } catch (Exception e) {
+          folder = null;
+        }
+      } else {
+        String baseName = parent.getFilename().replace('\\', '/');
+        int slash = baseName.lastIndexOf('/');
+        if (slash >= 0) {
+          baseName = baseName.substring(slash + 1);
+        }
+        if (baseName.toLowerCase().endsWith(PipelineMeta.PIPELINE_EXTENSION)) {
+          parentBase =
+              baseName.substring(0, baseName.length() - 
PipelineMeta.PIPELINE_EXTENSION.length());
+        } else if (StringUtils.isNotEmpty(baseName)) {
+          parentBase = baseName;
+        }
+      }
+    }
+    if (folder == null && variables != null) {
+      folder = resolveFilesystemPath(Const.VAR_PROJECT_HOME, variables);
+    }
+    if (folder == null) {
+      folder = "";
+    }
+    String transformPart =
+        entry != null && StringUtils.isNotEmpty(entry.getName())
+            ? entry.getName().replaceAll("[^A-Za-z0-9._-]+", "-")
+            : "mapping";
+    String separator = folder.endsWith("/") || folder.endsWith("\\") || 
folder.isEmpty() ? "" : "/";
+    return folder + separator + parentBase + "-" + transformPart + 
"-mapping.hpl";
+  }
+
+  private static PipelineMeta buildMappingPipeline(
+      PipelineMeta parent,
+      List<TransformMeta> selectedTransforms,
+      List<PipelineHopMeta> internalHops,
+      TransformMeta entry,
+      TransformMeta exit) {
+    PipelineMeta mapping = new PipelineMeta();
+    mapping.setMetadataProvider(parent.getMetadataProvider());
+    mapping.setNameSynchronizedWithFilename(true);
+    copyParameters(parent, mapping);
+
+    Map<TransformMeta, TransformMeta> clones = new HashMap<>();
+    for (TransformMeta transformMeta : selectedTransforms) {
+      TransformMeta clone = (TransformMeta) transformMeta.clone();
+      clone.setSelected(false);
+      mapping.addTransform(clone);
+      clones.put(transformMeta, clone);
+    }
+
+    for (PipelineHopMeta hop : internalHops) {
+      TransformMeta from = clones.get(hop.getFromTransform());
+      TransformMeta to = clones.get(hop.getToTransform());
+      if (from == null || to == null) {
+        continue;
+      }
+      PipelineHopMeta clonedHop = new PipelineHopMeta(from, to, 
hop.isEnabled());
+      clonedHop.setErrorHop(hop.isErrorHop());
+      mapping.addPipelineHop(clonedHop);
+    }
+
+    for (TransformMeta original : selectedTransforms) {
+      TransformMeta clone = clones.get(original);
+      TransformErrorMeta errorMeta = clone.getTransformErrorMeta();
+      if (errorMeta == null) {
+        continue;
+      }
+      errorMeta.setSourceTransform(clone);
+      TransformMeta originalTarget = errorMeta.getTargetTransform();
+      if (originalTarget != null && clones.containsKey(originalTarget)) {
+        errorMeta.setTargetTransform(clones.get(originalTarget));
+      } else {
+        clone.setTransformErrorMeta(null);
+      }
+    }
+
+    for (TransformMeta clone : clones.values()) {
+      ITransformMeta meta = clone.getTransform();
+      if (meta != null) {
+        meta.searchInfoAndTargetTransforms(mapping.getTransforms());
+      }
+    }
+
+    TransformMeta mappingInput =
+        new TransformMeta(
+            PLUGIN_MAPPING_INPUT,
+            mapping.getAlternativeTransformName(DEFAULT_MAPPING_INPUT_NAME),
+            new MappingInputMeta());
+    Point entryLocation = entry.getLocation() != null ? entry.getLocation() : 
new Point(200, 100);
+    mappingInput.setLocation(entryLocation.x - MAPPING_IO_X_OFFSET, 
entryLocation.y);
+    mapping.addTransform(mappingInput);
+    mapping.addPipelineHop(new PipelineHopMeta(mappingInput, 
clones.get(entry)));
+
+    TransformMeta mappingOutput =
+        new TransformMeta(
+            PLUGIN_MAPPING_OUTPUT,
+            mapping.getAlternativeTransformName(DEFAULT_MAPPING_OUTPUT_NAME),
+            new MappingOutputMeta());
+    Point exitLocation = exit.getLocation() != null ? exit.getLocation() : 
entryLocation;
+    mappingOutput.setLocation(exitLocation.x + MAPPING_IO_X_OFFSET, 
exitLocation.y);
+    mapping.addTransform(mappingOutput);
+    mapping.addPipelineHop(new PipelineHopMeta(clones.get(exit), 
mappingOutput));
+
+    return mapping;
+  }
+
+  private static void copyParameters(PipelineMeta parent, PipelineMeta 
mapping) {
+    for (String param : parent.listParameters()) {
+      try {
+        mapping.addParameterDefinition(
+            param, parent.getParameterDefault(param), 
parent.getParameterDescription(param));
+      } catch (DuplicateParamException e) {
+        // already defined
+      } catch (Exception e) {
+        // skip a parameter we cannot copy
+      }
+    }
+  }
+
+  private static void retargetTargetStreams(
+      TransformMeta from, TransformMeta oldTarget, TransformMeta newTarget) {
+    if (from == null || from.getTransform() == null || oldTarget == null || 
newTarget == null) {
+      return;
+    }
+    ITransformIOMeta io = from.getTransform().getTransformIOMeta();
+    if (io == null) {
+      return;
+    }
+    for (IStream stream : io.getTargetStreams()) {
+      if (stream.getTransformMeta() != null && 
stream.getTransformMeta().equals(oldTarget)) {
+        stream.setTransformMeta(newTarget);
+        stream.setSubject(newTarget.getName());
+        from.getTransform().handleStreamSelection(stream);
+      }
+    }
+  }
+
+  private static void retargetInfoStreams(
+      TransformMeta to, TransformMeta oldSource, TransformMeta newSource) {
+    if (to == null || to.getTransform() == null || oldSource == null || 
newSource == null) {
+      return;
+    }
+    ITransformIOMeta io = to.getTransform().getTransformIOMeta();
+    if (io == null) {
+      return;
+    }
+    for (IStream stream : io.getInfoStreams()) {
+      if (stream.getTransformMeta() != null && 
stream.getTransformMeta().equals(oldSource)) {
+        stream.setTransformMeta(newSource);
+        stream.setSubject(newSource.getName());
+        to.getTransform().handleStreamSelection(stream);
+      }
+    }
+  }
+
+  private static boolean isErrorHop(PipelineHopMeta hop, TransformMeta from, 
TransformMeta to) {
+    if (hop.isErrorHop()) {
+      return true;
+    }
+    return from != null && from.isSendingErrorRowsToTransform(to);
+  }
+
+  private static boolean isMappingPlugin(TransformMeta transformMeta) {
+    if (transformMeta == null) {
+      return false;
+    }
+    ITransformMeta meta = transformMeta.getTransform();
+    if (meta instanceof MappingInputMeta
+        || meta instanceof MappingOutputMeta
+        || meta instanceof SimpleMappingMeta
+        || meta instanceof MultiMappingMeta) {
+      return true;
+    }
+    String pluginId = transformMeta.getPluginId();
+    return PLUGIN_MAPPING_INPUT.equals(pluginId)
+        || PLUGIN_MAPPING_OUTPUT.equals(pluginId)
+        || PLUGIN_SIMPLE_MAPPING.equals(pluginId)
+        || PLUGIN_MULTI_MAPPING.equals(pluginId);
+  }
+
+  @Getter
+  public static final class Result {
+    private final String validationKey;
+    private final Object[] validationArgs;
+    private final PipelineMeta mappingPipeline;
+    private final TransformMeta entry;
+    private final TransformMeta exit;
+    private final TransformMeta incomingFrom;
+    private final TransformMeta outgoingTo;
+    private final boolean incomingHopEnabled;
+    private final boolean outgoingHopEnabled;
+    private final Point simpleMappingLocation;
+    private final List<TransformMeta> selected;
+
+    private Result(
+        String validationKey,
+        Object[] validationArgs,
+        PipelineMeta mappingPipeline,
+        TransformMeta entry,
+        TransformMeta exit,
+        TransformMeta incomingFrom,
+        TransformMeta outgoingTo,
+        boolean incomingHopEnabled,
+        boolean outgoingHopEnabled,
+        Point simpleMappingLocation,
+        List<TransformMeta> selected) {
+      this.validationKey = validationKey;
+      this.validationArgs = validationArgs == null ? new Object[0] : 
validationArgs;
+      this.mappingPipeline = mappingPipeline;
+      this.entry = entry;
+      this.exit = exit;
+      this.incomingFrom = incomingFrom;
+      this.outgoingTo = outgoingTo;
+      this.incomingHopEnabled = incomingHopEnabled;
+      this.outgoingHopEnabled = outgoingHopEnabled;
+      this.simpleMappingLocation = simpleMappingLocation;
+      this.selected = selected == null ? List.of() : selected;
+    }
+
+    static Result error(String key, Object... args) {
+      return new Result(key, args, null, null, null, null, null, true, true, 
null, List.of());
+    }
+
+    public boolean isValid() {
+      return validationKey == null;
+    }
+
+    public String getValidationError() {
+      if (validationKey == null) {
+        return null;
+      }
+      return BaseMessages.getString(PKG, validationKey, validationArgs);
+    }
+  }
+}
diff --git 
a/plugins/transforms/mapping/src/main/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingGuiPlugin.java
 
b/plugins/transforms/mapping/src/main/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingGuiPlugin.java
new file mode 100644
index 0000000000..6c4146be4f
--- /dev/null
+++ 
b/plugins/transforms/mapping/src/main/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingGuiPlugin.java
@@ -0,0 +1,208 @@
+/*
+ * 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.pipeline.transforms.mapping;
+
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.action.GuiContextAction;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.extension.ExtensionPointHandler;
+import org.apache.hop.core.extension.HopExtensionPoint;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.action.GuiActionType;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.security.Permission;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.vfs.HopVfs;
+import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.history.AuditManager;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.dialog.MessageBox;
+import org.apache.hop.ui.core.gui.HopNamespace;
+import org.apache.hop.ui.core.security.HopSecurityUi;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.file.pipeline.HopGuiPipelineGraph;
+import org.apache.hop.ui.hopgui.file.pipeline.HopPipelineFileType;
+import 
org.apache.hop.ui.hopgui.file.pipeline.context.HopGuiPipelineTransformContext;
+import 
org.apache.hop.ui.hopgui.file.pipeline.extension.PipelineRenamedExtension;
+import org.apache.hop.ui.hopgui.file.shared.ReferencedConnectionSaveValidator;
+import org.apache.hop.ui.hopgui.perspective.explorer.ExplorerPerspective;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.widgets.Shell;
+
+@GuiPlugin
+public class CreateMappingGuiPlugin {
+
+  public static final String ACTION_ID_PIPELINE_GRAPH_TRANSFORM_CREATE_MAPPING 
=
+      "pipeline-graph-transform-10900-create-mapping";
+
+  private static final Class<?> PKG = CreateMappingGuiPlugin.class;
+
+  @GuiContextAction(
+      id = ACTION_ID_PIPELINE_GRAPH_TRANSFORM_CREATE_MAPPING,
+      parentId = HopGuiPipelineTransformContext.CONTEXT_ID,
+      type = GuiActionType.Create,
+      name = "i18n::CreateMapping.Action.Name",
+      tooltip = "i18n::CreateMapping.Action.Tooltip",
+      image = "MAP.svg",
+      category =
+          
"i18n:org.apache.hop.ui.hopgui.file.pipeline:HopGuiPipelineGraph.ContextualAction.Category.Basic.Text",
+      categoryOrder = "1")
+  public void createMapping(HopGuiPipelineTransformContext context) {
+    HopGui hopGui = HopGui.getInstance();
+    if (!HopSecurityUi.check(Permission.FILE_SAVE) || 
!HopSecurityUi.check(Permission.FILE_EDIT)) {
+      return;
+    }
+
+    PipelineMeta pipelineMeta = context.getPipelineMeta();
+    HopGuiPipelineGraph pipelineGraph = context.getPipelineGraph();
+    IVariables variables = pipelineGraph.getVariables();
+    Shell shell = hopGui.getShell();
+
+    List<TransformMeta> selected =
+        CreateMappingFromSelection.resolveSelectedTransforms(
+            pipelineMeta, context.getTransformMeta());
+    CreateMappingFromSelection.Result result =
+        CreateMappingFromSelection.analyze(pipelineMeta, selected);
+    if (!result.isValid()) {
+      MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_ERROR);
+      box.setText(BaseMessages.getString(PKG, "CreateMapping.Error.Title"));
+      box.setMessage(result.getValidationError());
+      box.open();
+      return;
+    }
+
+    try {
+      String filename = askSaveFilename(shell, pipelineMeta, 
result.getEntry(), variables);
+      if (StringUtils.isEmpty(filename)) {
+        return;
+      }
+      if (!filename.toLowerCase().endsWith(PipelineMeta.PIPELINE_EXTENSION)) {
+        filename = filename + PipelineMeta.PIPELINE_EXTENSION;
+      }
+      // Keep the filename the dialog handed us: the HopGuiFileOpenedDialog 
extension point has
+      // already replaced every project path variable in it. Resolve only for 
the VFS write.
+      String resolvedFilename =
+          CreateMappingFromSelection.resolveFilesystemPath(filename, 
variables);
+      if (StringUtils.isEmpty(resolvedFilename)) {
+        MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_ERROR);
+        box.setText(BaseMessages.getString(PKG, "CreateMapping.Error.Title"));
+        box.setMessage(BaseMessages.getString(PKG, 
"CreateMapping.Error.UnresolvedPath"));
+        box.open();
+        return;
+      }
+      resolvedFilename = HopVfs.normalize(resolvedFilename);
+      if (!confirmOverwrite(shell, resolvedFilename, variables)) {
+        return;
+      }
+
+      PipelineMeta mappingPipeline = result.getMappingPipeline();
+      IHopMetadataProvider saveMetadataProvider = 
mappingPipeline.getMetadataProvider();
+      if (saveMetadataProvider == null) {
+        saveMetadataProvider = hopGui.getMetadataProvider();
+      }
+      if (!ReferencedConnectionSaveValidator.confirmSave(
+          shell, mappingPipeline, variables, saveMetadataProvider)) {
+        return;
+      }
+
+      mappingPipeline.setFilename(resolvedFilename);
+      mappingPipeline.setModifiedHopVersion(Const.NVL(Const.getHopVersion(), 
""));
+      writePipeline(mappingPipeline, variables, hopGui.getLog());
+
+      AuditManager.registerEvent(HopNamespace.getNamespace(), "file", 
resolvedFilename, "save");
+      ExtensionPointHandler.callExtensionPoint(
+          hopGui.getLog(),
+          variables,
+          HopExtensionPoint.PipelineAfterSaveAs.id,
+          new PipelineRenamedExtension(mappingPipeline, null));
+
+      ExplorerPerspective explorer = HopGui.getExplorerPerspective();
+      if (explorer != null) {
+        explorer.refresh();
+      }
+
+      pipelineGraph.markUndoPoint();
+      CreateMappingFromSelection.replaceSelection(pipelineMeta, result, 
filename);
+      pipelineMeta.setChanged();
+      pipelineGraph.updateGui();
+    } catch (Exception e) {
+      new ErrorDialog(
+          shell,
+          BaseMessages.getString(PKG, "CreateMapping.Error.Save.Title"),
+          BaseMessages.getString(PKG, "CreateMapping.Error.Save.Message"),
+          e);
+    }
+  }
+
+  private String askSaveFilename(
+      Shell shell, PipelineMeta pipelineMeta, TransformMeta entry, IVariables 
variables)
+      throws Exception {
+    HopPipelineFileType<PipelineMeta> fileType = new HopPipelineFileType<>();
+    String suggested = 
CreateMappingFromSelection.suggestFilename(pipelineMeta, entry, variables);
+    FileObject startFile = null;
+    if (StringUtils.isNotEmpty(suggested)) {
+      startFile = HopVfs.getFileObject(suggested, variables);
+    }
+    return BaseDialog.presentFileDialog(
+        true,
+        shell,
+        null,
+        startFile,
+        fileType.getFilterExtensions(),
+        fileType.getFilterNames(),
+        true);
+  }
+
+  private boolean confirmOverwrite(Shell shell, String filename, IVariables 
variables)
+      throws HopException {
+    if (!HopVfs.fileExists(filename, variables)) {
+      return true;
+    }
+    MessageBox box = new MessageBox(shell, SWT.YES | SWT.NO | 
SWT.ICON_QUESTION);
+    box.setText(BaseMessages.getString(PKG, "CreateMapping.Overwrite.Title"));
+    box.setMessage(BaseMessages.getString(PKG, 
"CreateMapping.Overwrite.Message", filename));
+    return (box.open() & SWT.YES) != 0;
+  }
+
+  private void writePipeline(PipelineMeta mappingPipeline, IVariables 
variables, ILogChannel log)
+      throws Exception {
+    ExtensionPointHandler.callExtensionPoint(
+        log, variables, HopExtensionPoint.PipelineBeforeSave.id, 
mappingPipeline);
+
+    String xml = mappingPipeline.getXml(variables);
+    try (OutputStream out =
+        HopVfs.getOutputStream(mappingPipeline.getFilename(), false, 
variables)) {
+      
out.write(XmlHandler.getXmlHeader(Const.UTF_8).getBytes(StandardCharsets.UTF_8));
+      out.write(xml.getBytes(StandardCharsets.UTF_8));
+      mappingPipeline.clearChanged();
+    }
+
+    ExtensionPointHandler.callExtensionPoint(
+        log, variables, HopExtensionPoint.PipelineAfterSave.id, 
mappingPipeline);
+  }
+}
diff --git 
a/plugins/transforms/mapping/src/main/resources/org/apache/hop/pipeline/transforms/mapping/messages/messages_en_US.properties
 
b/plugins/transforms/mapping/src/main/resources/org/apache/hop/pipeline/transforms/mapping/messages/messages_en_US.properties
index 83bf0cccd4..521011085e 100644
--- 
a/plugins/transforms/mapping/src/main/resources/org/apache/hop/pipeline/transforms/mapping/messages/messages_en_US.properties
+++ 
b/plugins/transforms/mapping/src/main/resources/org/apache/hop/pipeline/transforms/mapping/messages/messages_en_US.properties
@@ -166,4 +166,21 @@ SimpleMapping.Injection.INHERIT_ALL_VARIABLES=Inherit all 
variables from the par
 SimpleMapping.Injection.VARIABLE=Mapping parameter name
 SimpleMapping.Injection.VALUE=Mapping parameter value
 SimpleMapping.Injection.Group.FIELDS=Field mappings
-SimpleMapping.Injection.Group.MAPPINGS=Parameter mappings 
\ No newline at end of file
+SimpleMapping.Injection.Group.MAPPINGS=Parameter mappings
+CreateMapping.Action.Name=Create mapping
+CreateMapping.Action.Tooltip=Extract the selected sequential transforms into a 
mapping pipeline and replace them with a Simple Mapping transform
+CreateMapping.Error.Title=Cannot create mapping
+CreateMapping.Error.EmptySelection=Select one or more sequential transforms to 
extract into a mapping.
+CreateMapping.Error.MappingTransform=Transform ''{0}'' is already a mapping 
transform and cannot be extracted.
+CreateMapping.Error.NotConnected=The selected transforms are not connected to 
each other by pipeline hops. Select a single sequential chain.
+CreateMapping.Error.NotAPath=The selected transforms are not a sequential 
chain (a split or join was found). Simple Mapping supports one input and one 
output.
+CreateMapping.Error.MultipleInputs=The selection has more than one incoming 
hop. Simple Mapping supports a single input stream.
+CreateMapping.Error.MultipleOutputs=The selection has more than one outgoing 
hop. Simple Mapping supports a single output stream.
+CreateMapping.Error.ErrorHopOut=The selection has an error hop that crosses 
its boundary. Simple Mapping has no error-output port.
+CreateMapping.Error.InfoHopIn=The selection receives an info hop from outside. 
Use Multi Mapping for extra info or target streams.
+CreateMapping.Error.Cycle=The selected transforms form a loop. Select a 
sequential chain with a single start and end.
+CreateMapping.Error.Save.Title=Error
+CreateMapping.Error.Save.Message=Unable to save the mapping pipeline or 
replace the selected transforms.
+CreateMapping.Error.UnresolvedPath=The selected filename still contains an 
unresolved variable (for example '${PROJECT_HOME}'). Open a project or pick a 
real folder.
+CreateMapping.Overwrite.Title=Overwrite?
+CreateMapping.Overwrite.Message=Are you sure you want to overwrite file 
''{0}''? 
\ No newline at end of file
diff --git 
a/plugins/transforms/mapping/src/test/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingFromSelectionTest.java
 
b/plugins/transforms/mapping/src/test/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingFromSelectionTest.java
new file mode 100644
index 0000000000..e8c0d3e793
--- /dev/null
+++ 
b/plugins/transforms/mapping/src/test/java/org/apache/hop/pipeline/transforms/mapping/CreateMappingFromSelectionTest.java
@@ -0,0 +1,409 @@
+/*
+ * 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.pipeline.transforms.mapping;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.gui.Point;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.pipeline.PipelineHopMeta;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transforms.dummy.DummyMeta;
+import org.apache.hop.pipeline.transforms.input.MappingInputMeta;
+import org.apache.hop.pipeline.transforms.output.MappingOutputMeta;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class CreateMappingFromSelectionTest {
+
+  @BeforeAll
+  static void setUpBeforeClass() throws Exception {
+    HopEnvironment.init();
+    PluginRegistry.init();
+  }
+
+  @Test
+  void threeTransformPathIsExtractedAndReplaced() {
+    PipelineMeta parent = pipeline("In", "A", "B", "C", "Out");
+    List<TransformMeta> selected = List.of(find(parent, "A"), find(parent, 
"B"), find(parent, "C"));
+
+    CreateMappingFromSelection.Result result = 
CreateMappingFromSelection.analyze(parent, selected);
+    assertTrue(result.isValid(), result.getValidationError());
+    assertEquals("A", result.getEntry().getName());
+    assertEquals("C", result.getExit().getName());
+    assertEquals("In", result.getIncomingFrom().getName());
+    assertEquals("Out", result.getOutgoingTo().getName());
+    assertEquals(100, result.getSimpleMappingLocation().x);
+    assertEquals(50, result.getSimpleMappingLocation().y);
+
+    PipelineMeta mapping = result.getMappingPipeline();
+    assertEquals(5, mapping.nrTransforms());
+    assertNotNull(mapping.findTransform("A"));
+    assertNotNullInstance(mapping, MappingInputMeta.class);
+    assertNotNullInstance(mapping, MappingOutputMeta.class);
+    assertEquals("A", mapping.findTransform("A").getName());
+    assertEquals("B", mapping.findTransform("B").getName());
+    assertEquals("C", mapping.findTransform("C").getName());
+    assertEquals(4, mapping.nrPipelineHops());
+    assertHop(mapping, "Mapping Input", "A");
+    assertHop(mapping, "A", "B");
+    assertHop(mapping, "B", "C");
+    assertHop(mapping, "C", "Mapping Output");
+
+    TransformMeta clonedA = mapping.findTransform("A");
+    assertNotSame(find(parent, "A"), clonedA);
+    assertNotSame(find(parent, "A").getTransform(), clonedA.getTransform());
+
+    TransformMeta simpleMapping =
+        CreateMappingFromSelection.replaceSelection(parent, result, 
"${PROJECT_HOME}/map.hpl");
+    assertEquals("map", simpleMapping.getName());
+    assertEquals(100, simpleMapping.getLocation().x);
+    assertEquals(50, simpleMapping.getLocation().y);
+    assertTrue(simpleMapping.getTransform() instanceof SimpleMappingMeta);
+    assertEquals(
+        "${PROJECT_HOME}/map.hpl",
+        ((SimpleMappingMeta) simpleMapping.getTransform()).getFilename());
+    assertNull(parent.findTransform("A"));
+    assertNull(parent.findTransform("B"));
+    assertNull(parent.findTransform("C"));
+    assertHop(parent, "In", "map");
+    assertHop(parent, "map", "Out");
+    assertTrue(simpleMapping.isSelected());
+    assertFalse(find(parent, "In").isSelected());
+  }
+
+  @Test
+  void singleTransformWithNeighbors() {
+    PipelineMeta parent = pipeline("In", "A", "Out");
+    CreateMappingFromSelection.Result result =
+        CreateMappingFromSelection.analyze(parent, List.of(find(parent, "A")));
+    assertTrue(result.isValid(), result.getValidationError());
+    assertEquals("A", result.getEntry().getName());
+    assertEquals("A", result.getExit().getName());
+    PipelineMeta mapping = result.getMappingPipeline();
+    assertEquals(3, mapping.nrTransforms());
+    assertHop(mapping, "Mapping Input", "A");
+    assertHop(mapping, "A", "Mapping Output");
+
+    CreateMappingFromSelection.replaceSelection(parent, result, 
"extracted.hpl");
+    assertHop(parent, "In", "extracted");
+    assertHop(parent, "extracted", "Out");
+  }
+
+  @Test
+  void isolatedSingleTransform() {
+    PipelineMeta parent = new PipelineMeta();
+    parent.addTransform(dummy("Solo", 40, 80));
+    CreateMappingFromSelection.Result result =
+        CreateMappingFromSelection.analyze(parent, List.of(find(parent, 
"Solo")));
+    assertTrue(result.isValid(), result.getValidationError());
+    assertNull(result.getIncomingFrom());
+    assertNull(result.getOutgoingTo());
+    assertEquals(3, result.getMappingPipeline().nrTransforms());
+
+    TransformMeta sm = CreateMappingFromSelection.replaceSelection(parent, 
result, "solo-map.hpl");
+    assertEquals(1, parent.nrTransforms());
+    assertEquals(0, parent.nrPipelineHops());
+    assertEquals("solo-map", sm.getName());
+    assertEquals(40, sm.getLocation().x);
+    assertEquals(80, sm.getLocation().y);
+  }
+
+  @Test
+  void preservesDisabledInternalHop() {
+    PipelineMeta parent = pipeline("In", "A", "B", "Out");
+    PipelineHopMeta ab = parent.findPipelineHop(find(parent, "A"), 
find(parent, "B"));
+    ab.setEnabled(false);
+
+    CreateMappingFromSelection.Result result =
+        CreateMappingFromSelection.analyze(parent, List.of(find(parent, "A"), 
find(parent, "B")));
+    assertTrue(result.isValid(), result.getValidationError());
+    PipelineHopMeta cloned =
+        result
+            .getMappingPipeline()
+            .findPipelineHop(
+                find(result.getMappingPipeline(), "A"),
+                find(result.getMappingPipeline(), "B"),
+                true);
+    assertFalse(cloned.isEnabled());
+  }
+
+  @Test
+  void rejectsDisconnectedSelection() {
+    PipelineMeta parent = pipeline("A", "B");
+    parent.addTransform(dummy("D", 400, 50));
+    parent.addTransform(dummy("E", 500, 50));
+    parent.addPipelineHop(new PipelineHopMeta(find(parent, "D"), find(parent, 
"E")));
+
+    CreateMappingFromSelection.Result result =
+        CreateMappingFromSelection.analyze(
+            parent, List.of(find(parent, "A"), find(parent, "B"), find(parent, 
"D")));
+    assertFalse(result.isValid());
+    assertEquals(CreateMappingFromSelection.KEY_NOT_CONNECTED, 
result.getValidationKey());
+  }
+
+  @Test
+  void rejectsMissingMiddleTransform() {
+    PipelineMeta parent = pipeline("A", "B", "C");
+    CreateMappingFromSelection.Result result =
+        CreateMappingFromSelection.analyze(parent, List.of(find(parent, "A"), 
find(parent, "C")));
+    assertFalse(result.isValid());
+    assertEquals(CreateMappingFromSelection.KEY_NOT_CONNECTED, 
result.getValidationKey());
+  }
+
+  @Test
+  void rejectsSplit() {
+    PipelineMeta parent = new PipelineMeta();
+    parent.addTransform(dummy("A", 0, 50));
+    parent.addTransform(dummy("B", 100, 0));
+    parent.addTransform(dummy("C", 100, 100));
+    parent.addPipelineHop(new PipelineHopMeta(find(parent, "A"), find(parent, 
"B")));
+    parent.addPipelineHop(new PipelineHopMeta(find(parent, "A"), find(parent, 
"C")));
+
+    CreateMappingFromSelection.Result result =
+        CreateMappingFromSelection.analyze(
+            parent, List.of(find(parent, "A"), find(parent, "B"), find(parent, 
"C")));
+    assertFalse(result.isValid());
+    assertEquals(CreateMappingFromSelection.KEY_NOT_A_PATH, 
result.getValidationKey());
+  }
+
+  @Test
+  void rejectsJoin() {
+    PipelineMeta parent = new PipelineMeta();
+    parent.addTransform(dummy("A", 0, 0));
+    parent.addTransform(dummy("B", 0, 100));
+    parent.addTransform(dummy("C", 100, 50));
+    parent.addPipelineHop(new PipelineHopMeta(find(parent, "A"), find(parent, 
"C")));
+    parent.addPipelineHop(new PipelineHopMeta(find(parent, "B"), find(parent, 
"C")));
+
+    CreateMappingFromSelection.Result result =
+        CreateMappingFromSelection.analyze(
+            parent, List.of(find(parent, "A"), find(parent, "B"), find(parent, 
"C")));
+    assertFalse(result.isValid());
+    assertEquals(CreateMappingFromSelection.KEY_NOT_A_PATH, 
result.getValidationKey());
+  }
+
+  @Test
+  void rejectsTwoExternalInputs() {
+    PipelineMeta parent = new PipelineMeta();
+    parent.addTransform(dummy("X", 0, 0));
+    parent.addTransform(dummy("Y", 0, 100));
+    parent.addTransform(dummy("A", 100, 50));
+    parent.addTransform(dummy("B", 200, 50));
+    parent.addPipelineHop(new PipelineHopMeta(find(parent, "X"), find(parent, 
"A")));
+    parent.addPipelineHop(new PipelineHopMeta(find(parent, "Y"), find(parent, 
"A")));
+    parent.addPipelineHop(new PipelineHopMeta(find(parent, "A"), find(parent, 
"B")));
+
+    CreateMappingFromSelection.Result result =
+        CreateMappingFromSelection.analyze(parent, List.of(find(parent, "A"), 
find(parent, "B")));
+    assertFalse(result.isValid());
+    assertEquals(CreateMappingFromSelection.KEY_MULTIPLE_INPUTS, 
result.getValidationKey());
+  }
+
+  @Test
+  void rejectsTwoExternalOutputs() {
+    PipelineMeta parent = new PipelineMeta();
+    parent.addTransform(dummy("A", 0, 50));
+    parent.addTransform(dummy("B", 100, 50));
+    parent.addTransform(dummy("X", 200, 0));
+    parent.addTransform(dummy("Y", 200, 100));
+    parent.addPipelineHop(new PipelineHopMeta(find(parent, "A"), find(parent, 
"B")));
+    parent.addPipelineHop(new PipelineHopMeta(find(parent, "B"), find(parent, 
"X")));
+    parent.addPipelineHop(new PipelineHopMeta(find(parent, "B"), find(parent, 
"Y")));
+
+    CreateMappingFromSelection.Result result =
+        CreateMappingFromSelection.analyze(parent, List.of(find(parent, "A"), 
find(parent, "B")));
+    assertFalse(result.isValid());
+    assertEquals(CreateMappingFromSelection.KEY_MULTIPLE_OUTPUTS, 
result.getValidationKey());
+  }
+
+  @Test
+  void rejectsErrorHopLeavingSelection() {
+    PipelineMeta parent = pipeline("A", "B");
+    PipelineHopMeta hop = parent.findPipelineHop(find(parent, "A"), 
find(parent, "B"));
+    hop.setErrorHop(true);
+
+    CreateMappingFromSelection.Result result =
+        CreateMappingFromSelection.analyze(parent, List.of(find(parent, "A")));
+    assertFalse(result.isValid());
+    assertEquals(CreateMappingFromSelection.KEY_ERROR_HOP, 
result.getValidationKey());
+  }
+
+  @Test
+  void rejectsMappingTransform() {
+    PipelineMeta parent = new PipelineMeta();
+    SimpleMappingMeta meta = new SimpleMappingMeta();
+    meta.setDefault();
+    TransformMeta sm = new TransformMeta("SimpleMapping", "SM", meta);
+    sm.setLocation(10, 10);
+    parent.addTransform(sm);
+
+    CreateMappingFromSelection.Result result =
+        CreateMappingFromSelection.analyze(parent, List.of(sm));
+    assertFalse(result.isValid());
+    assertEquals(CreateMappingFromSelection.KEY_MAPPING_TRANSFORM, 
result.getValidationKey());
+  }
+
+  @Test
+  void emptySelectionIsInvalid() {
+    CreateMappingFromSelection.Result result =
+        CreateMappingFromSelection.analyze(new PipelineMeta(), List.of());
+    assertFalse(result.isValid());
+    assertEquals(CreateMappingFromSelection.KEY_EMPTY, 
result.getValidationKey());
+  }
+
+  @Test
+  void resolveSelectedUsesMultiSelectionWhenContextIsSelected() {
+    PipelineMeta parent = pipeline("A", "B", "C");
+    find(parent, "A").setSelected(true);
+    find(parent, "B").setSelected(true);
+    List<TransformMeta> resolved =
+        CreateMappingFromSelection.resolveSelectedTransforms(parent, 
find(parent, "A"));
+    assertEquals(2, resolved.size());
+  }
+
+  @Test
+  void resolveSelectedFallsBackToContextWhenNotInSelection() {
+    PipelineMeta parent = pipeline("A", "B");
+    find(parent, "A").setSelected(true);
+    List<TransformMeta> resolved =
+        CreateMappingFromSelection.resolveSelectedTransforms(parent, 
find(parent, "B"));
+    assertEquals(1, resolved.size());
+    assertEquals("B", resolved.get(0).getName());
+  }
+
+  @Test
+  void suggestFilenameUsesParentFolderAndEntryName() {
+    PipelineMeta parent = new PipelineMeta();
+    parent.setFilename("/data/project/etl/main.hpl");
+    TransformMeta entry = dummy("Calc Name", 0, 0);
+    String suggested = CreateMappingFromSelection.suggestFilename(parent, 
entry, new Variables());
+    assertTrue(suggested.endsWith("main-Calc-Name-mapping.hpl"), suggested);
+    assertTrue(suggested.contains("etl"), suggested);
+  }
+
+  @Test
+  void suggestFilenameResolvesProjectHomeInParentPath() {
+    Variables variables = new Variables();
+    variables.setVariable("PROJECT_HOME", "/data/project");
+    PipelineMeta parent = new PipelineMeta();
+    parent.setFilename("${PROJECT_HOME}/etl/main.hpl");
+    TransformMeta entry = dummy("Calculator", 0, 0);
+    String suggested = CreateMappingFromSelection.suggestFilename(parent, 
entry, variables);
+    assertFalse(suggested.contains("${"), suggested);
+    assertTrue(suggested.contains("/data/project/etl"), suggested);
+    assertTrue(suggested.endsWith("main-Calculator-mapping.hpl"), suggested);
+  }
+
+  @Test
+  void transformNameFromFilenameUsesBaseName() {
+    assertEquals(
+        "map", 
CreateMappingFromSelection.transformNameFromFilename("${PROJECT_HOME}/map.hpl"));
+    assertEquals(
+        "child", 
CreateMappingFromSelection.transformNameFromFilename("/data/project/child.hpl"));
+    assertEquals("Simple mapping", 
CreateMappingFromSelection.transformNameFromFilename(""));
+  }
+
+  @Test
+  void resolveFilesystemPathExpandsVariables() {
+    Variables variables = new Variables();
+    variables.setVariable("PROJECT_HOME", "/data/project");
+    assertEquals(
+        "/data/project/map.hpl",
+        
CreateMappingFromSelection.resolveFilesystemPath("${PROJECT_HOME}/map.hpl", 
variables));
+    assertNull(
+        CreateMappingFromSelection.resolveFilesystemPath(
+            "${PROJECT_HOME}/map.hpl", new Variables()));
+    assertEquals(
+        "/tmp/a.hpl", 
CreateMappingFromSelection.resolveFilesystemPath("/tmp/a.hpl", variables));
+  }
+
+  @Test
+  void copiesNamedParametersToMappingPipeline() throws Exception {
+    PipelineMeta parent = pipeline("A");
+    parent.addParameterDefinition("ENV", "dev", "environment");
+    CreateMappingFromSelection.Result result =
+        CreateMappingFromSelection.analyze(parent, List.of(find(parent, "A")));
+    assertTrue(result.isValid(), result.getValidationError());
+    assertEquals("dev", 
result.getMappingPipeline().getParameterDefault("ENV"));
+  }
+
+  @Test
+  void mappingInputIsClampedOffTheLeftEdge() {
+    PipelineMeta parent = new PipelineMeta();
+    parent.addTransform(dummy("A", 10, 20));
+    CreateMappingFromSelection.Result result =
+        CreateMappingFromSelection.analyze(parent, List.of(find(parent, "A")));
+    TransformMeta mappingInput = 
result.getMappingPipeline().findTransform("Mapping Input");
+    assertEquals(0, mappingInput.getLocation().x);
+    assertEquals(20, mappingInput.getLocation().y);
+  }
+
+  private static PipelineMeta pipeline(String... names) {
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    TransformMeta previous = null;
+    int x = 0;
+    for (String name : names) {
+      TransformMeta transformMeta = dummy(name, x, 50);
+      pipelineMeta.addTransform(transformMeta);
+      if (previous != null) {
+        pipelineMeta.addPipelineHop(new PipelineHopMeta(previous, 
transformMeta));
+      }
+      previous = transformMeta;
+      x += 100;
+    }
+    return pipelineMeta;
+  }
+
+  private static TransformMeta dummy(String name, int x, int y) {
+    TransformMeta transformMeta = new TransformMeta("Dummy", name, new 
DummyMeta());
+    transformMeta.setLocation(new Point(x, y));
+    return transformMeta;
+  }
+
+  private static TransformMeta find(PipelineMeta pipelineMeta, String name) {
+    return pipelineMeta.findTransform(name);
+  }
+
+  private static void assertHop(PipelineMeta pipelineMeta, String from, String 
to) {
+    assertTrue(
+        pipelineMeta.findPipelineHop(
+                pipelineMeta.findTransform(from), 
pipelineMeta.findTransform(to), true)
+            != null,
+        "expected hop " + from + " -> " + to);
+  }
+
+  private static void assertNotNullInstance(PipelineMeta pipelineMeta, 
Class<?> type) {
+    boolean found = false;
+    for (TransformMeta transformMeta : pipelineMeta.getTransforms()) {
+      if (type.isInstance(transformMeta.getTransform())) {
+        found = true;
+        break;
+      }
+    }
+    assertTrue(found, "expected a transform of type " + type.getSimpleName());
+  }
+}

Reply via email to