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 6402b5cf62 fix tableview issue, fixes #7843 (#7846)
6402b5cf62 is described below

commit 6402b5cf6264d47b84fbe30e536d08875561fcee
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Fri Aug 7 20:35:30 2026 +0200

    fix tableview issue, fixes #7843 (#7846)
---
 .../SelectValuesDialogMetaTabTest.java             | 249 +++++++++++++++++++++
 .../ui/core/widget/TableViewLeaveEditorTest.java   | 220 ++++++++++++++++++
 .../HopGuiPipelineGraphHopCreationTest.java        | 216 +++++++++++++++++-
 .../org/apache/hop/ui/core/widget/TableView.java   |  33 +--
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  |  19 +-
 .../hopgui/file/workflow/HopGuiWorkflowGraph.java  |  13 +-
 6 files changed, 724 insertions(+), 26 deletions(-)

diff --git 
a/plugins/transforms/selectvalues/src/test/java/org/apache/hop/pipeline/transforms/selectvalues/SelectValuesDialogMetaTabTest.java
 
b/plugins/transforms/selectvalues/src/test/java/org/apache/hop/pipeline/transforms/selectvalues/SelectValuesDialogMetaTabTest.java
new file mode 100644
index 0000000000..7d4f931f32
--- /dev/null
+++ 
b/plugins/transforms/selectvalues/src/test/java/org/apache/hop/pipeline/transforms/selectvalues/SelectValuesDialogMetaTabTest.java
@@ -0,0 +1,249 @@
+/*
+ * 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.selectvalues;
+
+import static 
org.eclipse.swtbot.swt.finder.matchers.WidgetMatcherFactory.widgetOfType;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Consumer;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.plugins.TransformPluginType;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.custom.CTabItem;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.Table;
+import org.eclipse.swt.widgets.TableItem;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.eclipse.swtbot.swt.finder.widgets.SWTBotTable;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * SWTBot coverage for the Meta-data tab of {@link SelectValuesDialog}, in 
particular for what
+ * happens to the cell being edited when the user leaves it by clicking the 
empty area under the
+ * grid.
+ *
+ * <p>The Format column is a {@code COLUMN_TYPE_FORMAT} column, edited with 
the same combo as a
+ * {@code COLUMN_TYPE_CCOMBO} column - but the TableView mouse listener that 
commits an open editor
+ * before handling the click only knows the TEXT and CCOMBO types. The format 
combo is therefore
+ * still open, and still what TableView's shared {@code combo} field points 
at, when the click below
+ * the rows moves the active cell to the Fieldname column and inserts a row; 
the commit that the
+ * insert then does reads that stale combo and writes the mask into the 
Fieldname cell. {@link
+ * #editingTheFormatCellMustNotLeakIntoTheFieldnameColumn()} pins that down; 
{@link
+ * #editingTheTypeCellStaysInTheTypeColumn()} is the same scene on a plain 
CCOMBO column, which is
+ * committed correctly today.
+ *
+ * <p>Tagged {@code uitest} so it is skipped on headless machines; run with 
{@code mvn -pl
+ * plugins/transforms/selectvalues -Puitest test}.
+ */
+@Tag("uitest")
+class SelectValuesDialogMetaTabTest extends SwtBotTestBase {
+
+  private static final String TRANSFORM_NAME = "select values";
+
+  private static final String DIALOG_TITLE =
+      BaseMessages.getString(SelectValuesMeta.class, 
"SelectValuesDialog.Shell.Label");
+  private static final String META_TAB =
+      BaseMessages.getString(SelectValuesMeta.class, 
"SelectValuesDialog.MetaTab.TabItem");
+  private static final String FORMAT_HEADER =
+      BaseMessages.getString(SelectValuesMeta.class, 
"SelectValuesDialog.ColumnInfo.Format");
+
+  /** Grid layout of the Meta-data tab: column 0 holds the row number, the 
fields follow. */
+  private static final int FIELDNAME_COLUMN = 1;
+
+  private static final int TYPE_COLUMN = 3;
+  private static final int FORMAT_COLUMN = 7;
+
+  /** The Meta-data tab is the third of the dialog's tabs. */
+  private static final int META_TAB_INDEX = 2;
+
+  private static final String MASK = "MM/dd/yyyy HH:mm:ss";
+
+  /**
+   * Issue: typing a format mask and then clicking outside the editor copies 
the mask into the
+   * Fieldname column of another row.
+   */
+  @Test
+  void editingTheFormatCellMustNotLeakIntoTheFieldnameColumn() {
+    SelectValuesMeta meta = metaWith(change("sdfsdfsdf", "Timestamp"), 
change("other", "String"));
+
+    withDialog(
+        openerFor(meta),
+        bot -> {
+          SWTBot dialog = dialogBot(bot);
+          activateTheMetaTab(dialog);
+
+          SWTBotTable grid = metaGrid(dialog);
+          grid.click(0, FORMAT_COLUMN);
+          typeIntoTheOpenComboEditor(grid.widget, MASK);
+
+          clickTheEmptyAreaUnderTheGrid(grid.widget);
+
+          assertEquals(
+              MASK, grid.cell(0, FORMAT_COLUMN), "the mask belongs to the cell 
it was typed in");
+          assertEquals(
+              "other",
+              grid.cell(1, FIELDNAME_COLUMN),
+              "leaving the format editor must not overwrite another row's 
fieldname");
+
+          dialog.button(buttonLabel("System.Button.Cancel")).click();
+        });
+  }
+
+  /** The same scene on a plain CCOMBO column, which TableView does commit 
before the click. */
+  @Test
+  void editingTheTypeCellStaysInTheTypeColumn() {
+    SelectValuesMeta meta = metaWith(change("sdfsdfsdf", "Timestamp"), 
change("other", "String"));
+
+    withDialog(
+        openerFor(meta),
+        bot -> {
+          SWTBot dialog = dialogBot(bot);
+          activateTheMetaTab(dialog);
+
+          SWTBotTable grid = metaGrid(dialog);
+          grid.click(0, TYPE_COLUMN);
+          typeIntoTheOpenComboEditor(grid.widget, "Date");
+
+          clickTheEmptyAreaUnderTheGrid(grid.widget);
+
+          assertEquals(
+              "Date", grid.cell(0, TYPE_COLUMN), "the type belongs to the cell 
it was typed in");
+          assertEquals(
+              "other",
+              grid.cell(1, FIELDNAME_COLUMN),
+              "leaving the type editor must not overwrite another row's 
fieldname");
+
+          dialog.button(buttonLabel("System.Button.Cancel")).click();
+        });
+  }
+
+  private SWTBot dialogBot(SWTBot bot) {
+    return bot.shell(DIALOG_TITLE).activate().bot();
+  }
+
+  /**
+   * Brings the Meta-data tab to the front. Done on the folder rather than 
through {@code
+   * bot.cTabItem(...)} because SWTBot's widget finder only walks controls and 
never sees the tab
+   * items of a dialog's CTabFolder.
+   */
+  private void activateTheMetaTab(SWTBot dialog) {
+    CTabFolder tabFolder = (CTabFolder) 
dialog.widget(widgetOfType(CTabFolder.class));
+    display.syncExec(
+        () -> {
+          CTabItem metaTab = tabFolder.getItem(META_TAB_INDEX);
+          // The look-and-feel pads tab labels with spaces, hence the trim().
+          assertEquals(META_TAB, metaTab.getText().trim(), "expected the 
Meta-data tab");
+          tabFolder.setSelection(metaTab);
+          tabFolder.layout();
+        });
+  }
+
+  /**
+   * The Meta-data grid. Every tab of the dialog holds one, but SWTBot only 
looks at visible
+   * controls, so with the Meta-data tab up front this one is the only grid it 
finds.
+   */
+  private SWTBotTable metaGrid(SWTBot dialog) {
+    SWTBotTable grid = dialog.table();
+    assertEquals(
+        FORMAT_HEADER,
+        grid.columns().get(FORMAT_COLUMN),
+        "expected the Meta-data grid, whose column " + FORMAT_COLUMN + " is 
the format mask");
+    return grid;
+  }
+
+  /**
+   * Writes into the combo the click on a cell opened, which is what typing in 
it amounts to: the
+   * TableView modify listener copies the text into the cell as it is entered.
+   */
+  private void typeIntoTheOpenComboEditor(Table table, String value) {
+    AtomicReference<Combo> editor = new AtomicReference<>();
+    display.syncExec(
+        () -> {
+          for (Control child : table.getChildren()) {
+            if (child instanceof Combo combo) {
+              editor.set(combo);
+            }
+          }
+        });
+    assertNotNull(editor.get(), "clicking the cell should have opened a combo 
editor on the grid");
+    display.syncExec(() -> editor.get().setText(value));
+  }
+
+  /**
+   * Clicks the empty space below the last row - the "click outside the 
editor" of the report. The
+   * grid answers that with a new row, which is where the still-open editor's 
text can end up.
+   */
+  private void clickTheEmptyAreaUnderTheGrid(Table table) {
+    display.syncExec(
+        () -> {
+          TableItem lastRow = table.getItem(table.getItemCount() - 1);
+          Rectangle bounds = lastRow.getBounds(FIELDNAME_COLUMN);
+          Event click = new Event();
+          click.widget = table;
+          click.button = 1;
+          click.count = 1;
+          click.x = bounds.x + bounds.width / 2;
+          click.y = bounds.y + bounds.height + bounds.height / 2;
+          assertTrue(
+              table.getClientArea().contains(click.x, click.y),
+              "the grid must be tall enough to have empty space under its 
rows");
+          table.notifyListeners(SWT.MouseDown, click);
+        });
+  }
+
+  private Consumer<Shell> openerFor(SelectValuesMeta meta) {
+    PipelineMeta pipelineMeta = pipelineWith(meta);
+    return parent -> new SelectValuesDialog(parent, new Variables(), meta, 
pipelineMeta).open();
+  }
+
+  private static SelectMetadataChange change(String name, String type) {
+    SelectMetadataChange change = new SelectMetadataChange();
+    change.setName(name);
+    change.setType(type);
+    return change;
+  }
+
+  private static SelectValuesMeta metaWith(SelectMetadataChange... changes) {
+    SelectValuesMeta meta = new SelectValuesMeta();
+    meta.getSelectOption().getMeta().addAll(Arrays.asList(changes));
+    return meta;
+  }
+
+  private static PipelineMeta pipelineWith(SelectValuesMeta meta) {
+    String pluginId = 
PluginRegistry.getInstance().getPluginId(TransformPluginType.class, meta);
+    assertNotNull(pluginId, "Select values transform must be registered via 
HopEnvironment.init()");
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    pipelineMeta.addTransform(new TransformMeta(pluginId, TRANSFORM_NAME, 
meta));
+    return pipelineMeta;
+  }
+}
diff --git 
a/rcp/src/test/java/org/apache/hop/ui/core/widget/TableViewLeaveEditorTest.java 
b/rcp/src/test/java/org/apache/hop/ui/core/widget/TableViewLeaveEditorTest.java
new file mode 100644
index 0000000000..5cba616fb9
--- /dev/null
+++ 
b/rcp/src/test/java/org/apache/hop/ui/core/widget/TableViewLeaveEditorTest.java
@@ -0,0 +1,220 @@
+/*
+ * 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.core.widget;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.BiConsumer;
+import java.util.function.Supplier;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.events.SelectionAdapter;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.layout.FillLayout;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.TableItem;
+import org.eclipse.swt.widgets.Text;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Covers what happens to an open cell editor when the user leaves it by 
clicking the empty area
+ * under the grid - the click that makes the grid add a row.
+ *
+ * <p>That click first moves the active cell to column 1 of the last row and 
only then commits, so
+ * an editor the commit does not recognise is applied to the cell that was 
moved to rather than the
+ * one it belongs to. The commit has to know every editor {@code edit()} can 
open; this pins down
+ * the {@code COLUMN_TYPE_TEXT_BUTTON} one, which is a {@link TextVarButton} - 
a {@link TextVar},
+ * not the plain {@link Text} that the type mismatch makes {@code 
getTextWidgetValue} cast to.
+ *
+ * <p>Tagged {@code uitest} so it is skipped on headless machines; run with 
{@code mvn -pl rcp
+ * -Puitest test}.
+ */
+@Tag("uitest")
+class TableViewLeaveEditorTest extends SwtBotTestBase {
+
+  /** Grid layout: column 0 holds the row number, then the name and the 
browsable value. */
+  private static final int NAME_COLUMN = 1;
+
+  private static final int GENERATOR_COLUMN = 2;
+
+  private static final int EDITED_ROW = 0;
+  private static final int LAST_ROW = 1;
+
+  private static final String TYPED = "uuid";
+
+  /**
+   * With a plain text column next to it the misapplied commit does not even 
reach the wrong cell:
+   * it casts the browse editor to the widget type that column would have used.
+   */
+  @Test
+  void aTextButtonCellIsCommittedToItsOwnCellNextToAPlainColumn() {
+    assertTextButtonCellSurvivesLeavingTheEditor(false);
+  }
+
+  /** With a variables column next to it the same commit silently lands in the 
wrong cell. */
+  @Test
+  void aTextButtonCellIsCommittedToItsOwnCellNextToAVariablesColumn() {
+    assertTextButtonCellSurvivesLeavingTheEditor(true);
+  }
+
+  private void assertTextButtonCellSurvivesLeavingTheEditor(boolean 
nameColumnUsesVariables) {
+    withTableView(
+        nameColumnUsesVariables,
+        (tableView, bot) -> {
+          editCell(tableView, EDITED_ROW, GENERATOR_COLUMN);
+          setOpenEditorText(tableView, TYPED);
+
+          clickTheEmptyAreaUnderTheGrid(tableView);
+
+          assertEquals(
+              TYPED,
+              cellValue(tableView, EDITED_ROW, GENERATOR_COLUMN),
+              "what was typed belongs to the cell it was typed in");
+          assertEquals(
+              "second",
+              cellValue(tableView, LAST_ROW, NAME_COLUMN),
+              "leaving the editor must not overwrite another row's name");
+        });
+  }
+
+  // --- scene 
-----------------------------------------------------------------------------------
+
+  /** Builds a two-row grid whose second column is browsable, the way e.g. the 
Fake dialog is. */
+  private void withTableView(boolean nameColumnUsesVariables, 
BiConsumer<TableView, SWTBot> body) {
+    AtomicReference<TableView> tableViewRef = new AtomicReference<>();
+    withScene(
+        shell -> {
+          shell.setLayout(new FillLayout());
+          shell.setSize(900, 320);
+          ColumnInfo[] columns = {
+            new ColumnInfo("Name", ColumnInfo.COLUMN_TYPE_TEXT, false, false),
+            new ColumnInfo("Generator", ColumnInfo.COLUMN_TYPE_TEXT_BUTTON, 
false, false),
+          };
+          columns[0].setUsingVariables(nameColumnUsesVariables);
+          // A TEXT_BUTTON column only renders its browse button when the 
column uses variables,
+          // so its editor is a TextVarButton rather than a plain Text.
+          columns[1].setUsingVariables(true);
+          columns[1].setTextVarButtonSelectionListener(new SelectionAdapter() 
{});
+          TableView tableView =
+              new TableView(
+                  new Variables(),
+                  shell,
+                  SWT.BORDER | SWT.FULL_SELECTION,
+                  columns,
+                  2,
+                  null,
+                  PropsUi.getInstance());
+          TableItem first = tableView.table.getItem(EDITED_ROW);
+          first.setText(NAME_COLUMN, "first");
+          first.setText(GENERATOR_COLUMN, "random");
+          TableItem second = tableView.table.getItem(LAST_ROW);
+          second.setText(NAME_COLUMN, "second");
+          tableView.optWidth(true);
+          tableViewRef.set(tableView);
+        },
+        bot -> body.accept(tableViewRef.get(), bot));
+  }
+
+  // --- interactions 
----------------------------------------------------------------------------
+
+  private void editCell(TableView tableView, int rowNr, int colNr) {
+    onUi(
+        () -> {
+          tableView.edit(rowNr, colNr);
+          return null;
+        });
+  }
+
+  /** Types into the editor the edit opened, wherever in the cell's holder it 
lives. */
+  private void setOpenEditorText(TableView tableView, String value) {
+    onUi(
+        () -> {
+          Text editor = findTextIn(tableView.table);
+          assertNotNull(editor, "no editor is open on the grid");
+          editor.setText(value);
+          return null;
+        });
+  }
+
+  /**
+   * Clicks the empty space below the last row. The grid answers that with a 
new row, and it is the
+   * commit done on the way there that can pick up the editor left open.
+   */
+  private void clickTheEmptyAreaUnderTheGrid(TableView tableView) {
+    onUi(
+        () -> {
+          TableItem lastRow = 
tableView.table.getItem(tableView.table.getItemCount() - 1);
+          Rectangle bounds = lastRow.getBounds(NAME_COLUMN);
+          Event click = new Event();
+          click.widget = tableView.table;
+          click.button = 1;
+          click.count = 1;
+          click.x = bounds.x + bounds.width / 2;
+          click.y = bounds.y + bounds.height + bounds.height / 2;
+          tableView.table.notifyListeners(SWT.MouseDown, click);
+          return null;
+        });
+  }
+
+  // --- lookups 
---------------------------------------------------------------------------------
+
+  private String cellValue(TableView tableView, int rowNr, int colNr) {
+    return onUi(() -> tableView.table.getItem(rowNr).getText(colNr));
+  }
+
+  private static Text findTextIn(Composite parent) {
+    for (Control child : parent.getChildren()) {
+      if (child instanceof Text text) {
+        return text;
+      }
+      if (child instanceof Composite composite) {
+        Text found = findTextIn(composite);
+        if (found != null) {
+          return found;
+        }
+      }
+    }
+    return null;
+  }
+
+  /** Runs {@code supplier} on the UI thread and hands its result back to the 
SWTBot worker. */
+  private static <T> T onUi(Supplier<T> supplier) {
+    AtomicReference<T> result = new AtomicReference<>();
+    AtomicReference<RuntimeException> failure = new AtomicReference<>();
+    display.syncExec(
+        () -> {
+          try {
+            result.set(supplier.get());
+          } catch (RuntimeException e) {
+            failure.set(e);
+          }
+        });
+    if (failure.get() != null) {
+      throw failure.get();
+    }
+    return result.get();
+  }
+}
diff --git 
a/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphHopCreationTest.java
 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphHopCreationTest.java
index ee21a99d9b..d4adc8a136 100644
--- 
a/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphHopCreationTest.java
+++ 
b/rcp/src/test/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraphHopCreationTest.java
@@ -23,19 +23,30 @@ import static 
org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
+import java.util.ArrayList;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.gui.AreaOwner;
 import org.apache.hop.core.gui.Point;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.PipelineHopMeta;
 import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.transform.BaseTransformMeta;
 import org.apache.hop.pipeline.transform.ITransform;
 import org.apache.hop.pipeline.transform.ITransformData;
 import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.hopgui.HopGuiEnvironment;
 import org.apache.hop.ui.hopgui.file.GraphCanvasTestBase;
 import 
org.apache.hop.ui.hopgui.file.pipeline.context.HopGuiPipelineTransformContext;
@@ -63,8 +74,10 @@ class HopGuiPipelineGraphHopCreationTest extends 
GraphCanvasTestBase {
 
   private static final String SOURCE_TRANSFORM = "Query Delete";
   private static final String TARGET_TRANSFORM = "Write to log";
+  private static final String OTHER_INPUT_TRANSFORM = "Second input";
   private static final Point SOURCE_LOCATION = new Point(60, 60);
   private static final Point TARGET_LOCATION = new Point(280, 60);
+  private static final Point OTHER_INPUT_LOCATION = new Point(500, 60);
 
   @BeforeAll
   static void registerGuiPlugins() throws HopException {
@@ -436,6 +449,90 @@ class HopGuiPipelineGraphHopCreationTest extends 
GraphCanvasTestBase {
         });
   }
 
+  // ------------------------------------------------- target reading another 
row layout already
+
+  /**
+   * The target already reads from a transform that sends a different row 
layout, so completing the
+   * hop runs the layout check, which reports the mismatch and offers to merge 
the streams. Those
+   * two are the whole story: the click drew a hop, so the context dialog of 
the transform it landed
+   * on must not appear on top of them.
+   *
+   * <p>The check runs from the mouseDown that completes the hop, and its 
dialogs run their own
+   * event loop - which is what dispatches the release of that same click, 
long before mouseDown is
+   * done with it. Getting that release ignored is the point here.
+   */
+  @Test
+  void 
createHopActionThenClickingATargetMixingRowLayoutsOnlyRunsTheLayoutCheck() {
+    onCanvas(
+        HopGuiPipelineGraphHopCreationTest::buildPipelineMixingRowLayouts,
+        (bot, graph, pipelineMeta, spots) -> {
+          TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+          TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+
+          clickTransformAndPickCreateHop(graph, spots, source);
+
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0, 
SWT.NONE);
+          List<String> popups = clickAndCatchDialogs(bot, spots, spots.target, 
SWT.NONE);
+
+          assertAll(
+              () -> assertHopExists(pipelineMeta, source, target),
+              () -> assertOnlyTheLayoutCheckDialogs(popups),
+              () -> assertNoFailures(),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  @Test
+  void shiftClickingATargetMixingRowLayoutsOnlyRunsTheLayoutCheck() {
+    onCanvas(
+        HopGuiPipelineGraphHopCreationTest::buildPipelineMixingRowLayouts,
+        (bot, graph, pipelineMeta, spots) -> {
+          TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+          TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, 
SWT.SHIFT);
+          fire(spots.canvas, SWT.MouseUp, spots.scale, spots.source, 1, 
SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0, 
SWT.SHIFT);
+          List<String> popups = clickAndCatchDialogs(bot, spots, spots.target, 
SWT.SHIFT);
+
+          assertAll(
+              () -> assertHopExists(pipelineMeta, source, target),
+              () -> assertOnlyTheLayoutCheckDialogs(popups),
+              () -> assertNoFailures(),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
+  @Test
+  void shiftDraggingOntoATargetMixingRowLayoutsOnlyRunsTheLayoutCheck() {
+    onCanvas(
+        HopGuiPipelineGraphHopCreationTest::buildPipelineMixingRowLayouts,
+        (bot, graph, pipelineMeta, spots) -> {
+          TransformMeta source = pipelineMeta.findTransform(SOURCE_TRANSFORM);
+          TransformMeta target = pipelineMeta.findTransform(TARGET_TRANSFORM);
+
+          fire(spots.canvas, SWT.MouseDown, spots.scale, spots.source, 1, 
SWT.SHIFT);
+          fire(
+              spots.canvas,
+              SWT.MouseMove,
+              spots.scale,
+              midpoint(spots.source, spots.target),
+              0,
+              SWT.SHIFT | SWT.BUTTON1);
+          fire(spots.canvas, SWT.MouseMove, spots.scale, spots.target, 0, 
SWT.SHIFT | SWT.BUTTON1);
+          Set<Shell> before = openShells();
+          fireAsync(
+              spots.canvas, SWT.MouseUp, spots.scale, spots.target, 1, 
SWT.SHIFT | SWT.BUTTON1);
+          List<String> popups = catchDialogs(bot, before);
+
+          assertAll(
+              () -> assertHopExists(pipelineMeta, source, target),
+              () -> assertOnlyTheLayoutCheckDialogs(popups),
+              () -> assertNoFailures(),
+              () -> assertCanvasIsIdle(graph));
+        });
+  }
+
   // ------------------------------------------------------------------ 
gesture building blocks
 
   /**
@@ -485,6 +582,59 @@ class HopGuiPipelineGraphHopCreationTest extends 
GraphCanvasTestBase {
     return title;
   }
 
+  /**
+   * Presses and releases the left button without waiting for the handlers. 
Both halves of the click
+   * are posted up front on purpose: the press may open a dialog, and that 
dialog runs its own event
+   * loop, which is what dispatches the release. Waiting for the press would 
deadlock the test, and
+   * holding the release back until the dialog is gone would hide the very 
ordering under test.
+   */
+  private List<String> clickAndCatchDialogs(SWTBot bot, Spots spots, Point at, 
int stateMask) {
+    Set<Shell> before = openShells();
+    fireAsync(spots.canvas, SWT.MouseDown, spots.scale, at, 1, stateMask);
+    fireAsync(spots.canvas, SWT.MouseUp, spots.scale, at, 1, stateMask | 
SWT.BUTTON1);
+    return catchDialogs(bot, before);
+  }
+
+  /**
+   * Collects the titles of the dialogs that opened since {@code before}, in 
the order they
+   * appeared, and closes every one of them so the event loops underneath are 
handed back.
+   *
+   * <p>Dialogs both stack and follow one another here: a dialog runs its own 
event loop, so
+   * anything that loop dispatches can open a second dialog on top of the 
first, while the code
+   * after the first dialog can open yet another one once it is gone. So a 
round gathers everything
+   * that is up at the same time, closes the newest first - an older one 
cannot return while a newer
+   * loop sits on top of it - and then looks again for whatever that let 
through.
+   *
+   * <p>Closing a dialog is the answer a test wants: Hop dialogs treat it as 
cancel, so a question
+   * like "replace this transform?" is answered with no.
+   */
+  private List<String> catchDialogs(SWTBot bot, Set<Shell> before) {
+    List<String> titles = new ArrayList<>();
+    Set<Shell> seen = new HashSet<>(before);
+    for (List<Shell> round = awaitNewShells(bot, seen);
+        !round.isEmpty();
+        round = awaitNewShells(bot, seen)) {
+      round.forEach(popup -> titles.add(titleOf(popup)));
+      for (int i = round.size() - 1; i >= 0; i--) {
+        closeShell(bot, round.get(i));
+      }
+    }
+    return titles;
+  }
+
+  /**
+   * Every dialog that is open at the same time, in the order it appeared. 
Adds them to {@code
+   * seen}.
+   */
+  private List<Shell> awaitNewShells(SWTBot bot, Set<Shell> seen) {
+    List<Shell> found = new ArrayList<>();
+    for (Shell popup = awaitNewShell(bot, seen); popup != null; popup = 
awaitNewShell(bot, seen)) {
+      found.add(popup);
+      seen.add(popup);
+    }
+    return found;
+  }
+
   // ------------------------------------------------------------------ 
assertions
 
   private void assertHopExists(PipelineMeta meta, TransformMeta from, 
TransformMeta to) {
@@ -501,6 +651,20 @@ class HopGuiPipelineGraphHopCreationTest extends 
GraphCanvasTestBase {
     assertNull(dialogTitle, "no dialog may open here, but one did");
   }
 
+  /**
+   * Mixing row layouts is worth reporting, and a Dummy target is worth an 
offer to replace it with
+   * a Stream Schema Merge. Those two are all: the click completed a hop, so 
it may not also be read
+   * as a plain click on the transform and open the context dialog on top of 
them.
+   */
+  private void assertOnlyTheLayoutCheckDialogs(List<String> dialogTitles) {
+    assertEquals(
+        List.of(
+            BaseMessages.getString(HopGui.class, 
"HopGui.LayoutCheck.Dialog.MismatchTitle"),
+            BaseMessages.getString(HopGui.class, 
"HopGui.LayoutCheck.Dialog.ReplaceDummyTitle")),
+        dialogTitles,
+        "the layout check is all this gesture may report");
+  }
+
   private void assertNoFailures() {
     assertTrue(swallowed.isEmpty(), "the canvas must not throw, but got " + 
swallowed);
   }
@@ -548,6 +712,10 @@ class HopGuiPipelineGraphHopCreationTest extends 
GraphCanvasTestBase {
   }
 
   private void onCanvas(CanvasTest test) {
+    onCanvas(HopGuiPipelineGraphHopCreationTest::buildPipeline, test);
+  }
+
+  private void onCanvas(Supplier<PipelineMeta> scene, CanvasTest test) {
     AtomicReference<HopGuiPipelineGraph> graphRef = new AtomicReference<>();
     AtomicReference<PipelineMeta> metaRef = new AtomicReference<>();
 
@@ -558,7 +726,7 @@ class HopGuiPipelineGraphHopCreationTest extends 
GraphCanvasTestBase {
           // Keep the single click on the canvas synchronous and deterministic.
           PropsUi.getInstance().setUseDoubleClickOnCanvas(false);
 
-          PipelineMeta pipelineMeta = buildPipeline();
+          PipelineMeta pipelineMeta = scene.get();
           metaRef.set(pipelineMeta);
           graphRef.set(
               new HopGuiPipelineGraph(
@@ -619,10 +787,56 @@ class HopGuiPipelineGraphHopCreationTest extends 
GraphCanvasTestBase {
     return pipelineMeta;
   }
 
+  /**
+   * The same two transforms, plus a third one that already feeds the target - 
and sends a different
+   * row layout than the source does. Drawing the second hop into the target 
is what makes the row
+   * layout check report a mismatch.
+   *
+   * <p>The target is a {@code Dummy}, exactly as in the report, so the layout 
check also offers to
+   * replace it with a Stream Schema Merge. The tests decline that offer.
+   */
+  private static PipelineMeta buildPipelineMixingRowLayouts() {
+    PipelineMeta pipelineMeta = new PipelineMeta();
+    pipelineMeta.setName("hop-creation-mixed-layouts");
+
+    TransformMeta source = fieldTransform(SOURCE_TRANSFORM, SOURCE_LOCATION, 
"one");
+    TransformMeta other = fieldTransform(OTHER_INPUT_TRANSFORM, 
OTHER_INPUT_LOCATION, "one", "two");
+    TransformMeta target = transform(TARGET_TRANSFORM, TARGET_LOCATION);
+    pipelineMeta.addTransform(source);
+    pipelineMeta.addTransform(other);
+    pipelineMeta.addTransform(target);
+    pipelineMeta.addPipelineHop(new PipelineHopMeta(other, target));
+    return pipelineMeta;
+  }
+
   private static TransformMeta transform(String name, Point location) {
     TransformMeta transformMeta =
         new TransformMeta("Dummy", name, new BaseTransformMeta<ITransform, 
ITransformData>());
     transformMeta.setLocation(location.x, location.y);
     return transformMeta;
   }
+
+  /** A transform putting the given fields on the stream, so that two of them 
can disagree. */
+  private static TransformMeta fieldTransform(String name, Point location, 
String... fields) {
+    TransformMeta transformMeta =
+        new TransformMeta(
+            "Dummy",
+            name,
+            new BaseTransformMeta<ITransform, ITransformData>() {
+              @Override
+              public void getFields(
+                  IRowMeta inputRowMeta,
+                  String transformName,
+                  IRowMeta[] info,
+                  TransformMeta nextTransform,
+                  IVariables variables,
+                  IHopMetadataProvider metadataProvider) {
+                for (String field : fields) {
+                  inputRowMeta.addValueMeta(new ValueMetaString(field));
+                }
+              }
+            });
+    transformMeta.setLocation(location.x, location.y);
+    return transformMeta;
+  }
 }
diff --git a/ui/src/main/java/org/apache/hop/ui/core/widget/TableView.java 
b/ui/src/main/java/org/apache/hop/ui/core/widget/TableView.java
index 96f2ba39df..47fbc5d709 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/widget/TableView.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/widget/TableView.java
@@ -741,18 +741,10 @@ public class TableView extends Composite {
     return new MouseAdapter() {
       @Override
       public void mouseDown(MouseEvent event) {
-        if (activeTableItem != null
-            && !activeTableItem.isDisposed()
-            && editor != null
-            && editor.getEditor() != null
-            && !editor.getEditor().isDisposed()
-            && activeTableColumn > 0) {
-          if (columns[activeTableColumn - 1].getType() == 
ColumnInfo.COLUMN_TYPE_TEXT) {
-            applyTextChange(activeTableItem, activeTableRow, 
activeTableColumn);
-          } else if (columns[activeTableColumn - 1].getType() == 
ColumnInfo.COLUMN_TYPE_CCOMBO) {
-            applyComboChange(activeTableItem, activeTableRow, 
activeTableColumn);
-          }
-        }
+        // Commit whatever cell is being edited before the click moves the 
active cell somewhere
+        // else: an editor left open here would be applied to the cell we are 
about to move to.
+        applyAllChanges();
+
         boolean rightClick = event.button == 3;
         if (event.button == 1 || rightClick) {
           boolean shift = (event.stateMask & SWT.SHIFT) != 0;
@@ -821,10 +813,19 @@ public class TableView extends Composite {
         && editor.getEditor() != null
         && !editor.getEditor().isDisposed()
         && activeTableColumn > 0) {
-      if (columns[activeTableColumn - 1].getType() == 
ColumnInfo.COLUMN_TYPE_TEXT) {
-        applyTextChange(activeTableItem, activeTableRow, activeTableColumn);
-      } else if (columns[activeTableColumn - 1].getType() == 
ColumnInfo.COLUMN_TYPE_CCOMBO) {
-        applyComboChange(activeTableItem, activeTableRow, activeTableColumn);
+      // Mirror the editors edit() opens: a format column is edited with a 
combo and a text-button
+      // column with a text field, just like the plain combo and text columns, 
so they have to be
+      // committed (and disposed) the same way. Leaving one open makes the 
next commit read that
+      // stale editor and write its text into another cell.
+      switch (columns[activeTableColumn - 1].getType()) {
+        case ColumnInfo.COLUMN_TYPE_TEXT, ColumnInfo.COLUMN_TYPE_TEXT_BUTTON:
+          applyTextChange(activeTableItem, activeTableRow, activeTableColumn);
+          break;
+        case ColumnInfo.COLUMN_TYPE_CCOMBO, ColumnInfo.COLUMN_TYPE_FORMAT:
+          applyComboChange(activeTableItem, activeTableRow, activeTableColumn);
+          break;
+        default:
+          break;
       }
     }
   }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index cc5ab9c7ce..b1889fce9c 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -905,13 +905,20 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
             // If we click on the start hop transform or a forbidden 
transform, then we don't have a
             // candidate hop, but we need to ignore this click to not start a 
drag operation.
             if (candidate != null) {
-              addCandidateAsHop(event.x, event.y);
               // Single-stream hop completes on mouseDown and clears 
startHopTransform; without
-              // this,
-              // the following mouseUp would look like a plain transform click 
and open the action
-              // dialog.
-              if (startHopTransform == null) {
-                avoidContextDialog = true;
+              // this, the following mouseUp would look like a plain transform 
click and open the
+              // action dialog. Claim that release up front: completing the 
hop can put a dialog on
+              // screen - the row layout report, the copy/distribute question 
- and a dialog runs
+              // its own event loop, which dispatches the release of this very 
click before we get
+              // back here.
+              avoidContextDialog = true;
+              addCandidateAsHop(event.x, event.y);
+              if (avoidContextDialog && startHopTransform != null) {
+                // The hop is not done yet: it is the pop-up menu of stream 
options that completes
+                // it, so the release is an ordinary one after all. Should the 
release already have
+                // been handled from a dialog's event loop, the flag is 
cleared by now and has to
+                // stay that way, or it would swallow the next click.
+                avoidContextDialog = false;
               }
             }
           } else if (event.button == 1 && alt && 
currentTransform.supportsErrorHandling()) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
index 4d907a94b3..c230175cd5 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
@@ -755,11 +755,18 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
             // If we click on the start hop action or a forbidden action, then 
we don't have a
             // candidate hop, but we need to ignore this click to not start a 
drag operation
             if (hopCandidate != null) {
-              addCandidateAsHop();
               // The hop completes on mouseDown and clears startHopAction; 
without this, the
               // following mouseUp would look like a plain action click and 
open the context dialog.
-              if (startHopAction == null) {
-                avoidContextDialog = true;
+              // Claim that release up front: completing the hop can put a 
dialog on screen - the
+              // hop exists already, the hop causes a loop - and a dialog runs 
its own event loop,
+              // which dispatches the release of this very click before we get 
back here.
+              avoidContextDialog = true;
+              addCandidateAsHop();
+              if (avoidContextDialog && startHopAction != null) {
+                // The hop was not made, so the release is an ordinary one 
after all. Should it
+                // already have been handled from a dialog's event loop, the 
flag is cleared by now
+                // and has to stay that way, or it would swallow the next 
click.
+                avoidContextDialog = false;
               }
             }
           } else if (event.button == 2 || (event.button == 1 && shift)) {

Reply via email to