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 6315f3c8c5 Issue #2286 : Add a GUI annotation for data grids (#8652)
6315f3c8c5 is described below

commit 6315f3c8c591765b4b5c0e4463caadab86ecb99d
Author: Matt Casters <[email protected]>
AuthorDate: Thu Oct 1 11:09:26 2026 +0200

    Issue #2286 : Add a GUI annotation for data grids (#8652)
    
    * Issue #2286 : Add a GUI annotation for data grids
    
    @GuiWidgetElement(type = TABLE) on a List field draws a TableView.
    Columns come from @GuiTableColumn on the row class. The AI Provider
    models list is the first grid built this way.
    
    * Issue #2286 : Refresh grid combo items and match setters by type
    
    A String combo column reads comboValuesMethod again whenever the grid
    is filled, so the items can change after the dialog opens. A setter is
    resolved by the field type, so a one-arg overload is not picked by
    method order.
---
 .../apache/hop/core/gui/plugin/GuiElementType.java |   5 +
 .../apache/hop/core/gui/plugin/GuiElements.java    |  45 ++
 .../apache/hop/core/gui/plugin/GuiRegistry.java    |  14 +
 .../apache/hop/core/gui/plugin/GuiTableColumn.java |  77 ++++
 ...ElementType.java => GuiTableColumnElement.java} |  40 +-
 ...GuiElementType.java => GuiTableColumnType.java} |  24 +-
 .../hop/core/gui/plugin/GuiTableColumns.java       | 266 +++++++++++
 .../hop/core/gui/plugin/GuiWidgetElement.java      |   9 +
 .../hop/core/gui/plugin/GuiRegistryTest.java       | 270 +++++++++++
 .../modules/ROOT/pages/plugin-types/gui.adoc       |  41 ++
 .../org/apache/hop/ai/metadata/AiProvider.java     |  11 +
 .../apache/hop/ai/metadata/AiProviderEditor.java   |  87 +---
 .../apache/hop/ai/metadata/AiProviderModel.java    |  11 +
 .../ui/core/gui/GuiCompositeWidgetsTableTest.java  | 509 +++++++++++++++++++++
 .../hop/ui/core/gui/GuiCompositeWidgets.java       | 448 +++++++++++++++++-
 15 files changed, 1735 insertions(+), 122 deletions(-)

diff --git 
a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiElementType.java 
b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiElementType.java
index afe04a8c70..5fd5274b72 100644
--- a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiElementType.java
+++ b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiElementType.java
@@ -33,4 +33,9 @@ public enum GuiElementType {
   BUTTON, // Push button
   LINK, // A URL style link (underlined text)
   COMPOSITE, // Painted on a method which can add widgets to a composite
+  /**
+   * Data grid bound to a {@code List} field. Columns come from {@link 
GuiTableColumn} on the row
+   * class.
+   */
+  TABLE,
 }
diff --git a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiElements.java 
b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiElements.java
index a2dd1f842b..4ee61d1f10 100644
--- a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiElements.java
+++ b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiElements.java
@@ -89,8 +89,18 @@ public class GuiElements extends BaseGuiElements implements 
Comparable<GuiElemen
 
   private String namingSchemeType;
 
+  /** Row class of a {@link GuiElementType#TABLE}, taken from {@code 
List<Row>}. */
+  private Class<?> tableRowClass;
+
+  /** Columns of a {@link GuiElementType#TABLE}, sorted by {@code order}. 
Empty for other types. */
+  private List<GuiTableColumnElement> tableColumns;
+
+  /** Preferred height of a {@link GuiElementType#TABLE}, in rows (at least 1, 
default 5). */
+  private int tableRows = 5;
+
   public GuiElements() {
     children = new ArrayList<>();
+    tableColumns = new ArrayList<>();
     groupType = GuiWidgetGroupType.NONE;
   }
 
@@ -130,6 +140,9 @@ public class GuiElements extends BaseGuiElements implements 
Comparable<GuiElemen
     this.buttonMethod = null;
     this.namingSchemeType = resolveNamingSchemeType(guiElement, field);
     copyGroup(guiElement, fieldPackageName, field.getDeclaringClass());
+    if (guiElement.type() == GuiElementType.TABLE) {
+      GuiTableColumns.apply(this, guiElement, field);
+    }
   }
 
   /**
@@ -713,4 +726,36 @@ public class GuiElements extends BaseGuiElements 
implements Comparable<GuiElemen
   public void setNamingSchemeType(String namingSchemeType) {
     this.namingSchemeType = namingSchemeType;
   }
+
+  public Class<?> getTableRowClass() {
+    return tableRowClass;
+  }
+
+  public void setTableRowClass(Class<?> tableRowClass) {
+    this.tableRowClass = tableRowClass;
+  }
+
+  public List<GuiTableColumnElement> getTableColumns() {
+    return tableColumns;
+  }
+
+  public void setTableColumns(List<GuiTableColumnElement> tableColumns) {
+    this.tableColumns = tableColumns == null ? new ArrayList<>() : 
tableColumns;
+  }
+
+  /**
+   * Preferred height of a {@link GuiElementType#TABLE}, in rows. Values below 
1 become 5.
+   *
+   * @return value of tableRows
+   */
+  public int getTableRows() {
+    return tableRows;
+  }
+
+  /**
+   * @param tableRows The tableRows to set (values below 1 become 5)
+   */
+  public void setTableRows(int tableRows) {
+    this.tableRows = tableRows < 1 ? 5 : tableRows;
+  }
 }
diff --git a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiRegistry.java 
b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiRegistry.java
index 0c982ae6db..297aa9620e 100644
--- a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiRegistry.java
+++ b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiRegistry.java
@@ -52,6 +52,8 @@ import 
org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
 import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElementFilter;
 import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarItem;
 import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarItemFilter;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.util.TranslateUtil;
 import org.apache.hop.core.vfs.HopVfs;
 import org.apache.hop.core.xml.XmlHandler;
@@ -428,6 +430,18 @@ public class GuiRegistry {
       String dataClassName,
       ClassLoader classLoader) {
 
+    // A grid is a List field. A method has nothing to read the rows from.
+    if (guiElement.type() == GuiElementType.TABLE) {
+      if (HopLogStore.isInitialized()) {
+        LogChannel.GENERAL.logError(
+            "GuiWidgetElement type TABLE is only supported on a List field, 
not on method "
+                + guiPluginClassMethod.getDeclaringClass().getName()
+                + "."
+                + guiPluginClassMethod.getName());
+      }
+      return;
+    }
+
     GuiElements guiElements = findGuiElements(dataClassName, 
guiElement.parentId());
     if (guiElements == null) {
       guiElements = new GuiElements();
diff --git 
a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiTableColumn.java 
b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiTableColumn.java
new file mode 100644
index 0000000000..23cb634394
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiTableColumn.java
@@ -0,0 +1,77 @@
+/*
+ * 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.core.gui.plugin;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Column of a {@link GuiElementType#TABLE} widget. Put this on a field of the 
row class named by
+ * {@code List<Row>} on the widget. It is not read by the metadata serializers;
+ * {@code @HopMetadataProperty} still stores the field.
+ */
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface GuiTableColumn {
+
+  /** Column id. Empty uses the field name. */
+  String id() default "";
+
+  /** Alphabetical sort key among the columns of this row class, same rule as 
{@code order()}. */
+  String order() default "";
+
+  /** Header text. Use {@code i18n::} the same way as {@link 
GuiWidgetElement#label()}. */
+  String label() default "";
+
+  /** Header tooltip. Use {@code i18n::} the same way as {@link 
GuiWidgetElement#toolTip()}. */
+  String toolTip() default "";
+
+  /** Cell editor. Must agree with the field type, or the column is left out 
of the grid. */
+  GuiTableColumnType type();
+
+  /**
+   * @return true if a text or string-combo cell offers variables
+   */
+  boolean variables() default true;
+
+  /**
+   * @return true if a text cell masks its value
+   */
+  boolean password() default false;
+
+  /** Column width in pixels. Negative means the table sizes the column. */
+  int width() default -1;
+
+  /** Getter name when it is not the bean property name ({@code getX} / {@code 
isX}). */
+  String getterMethod() default "";
+
+  /** Setter name when it is not {@code setX}. */
+  String setterMethod() default "";
+
+  /**
+   * Method on the parent object (the action or metadata, not the row) that 
returns the items of a
+   * {@link GuiTableColumnType#COMBO} on a {@link String} field. Signature: 
{@code List<String>
+   * method(ILogChannel log, IHopMetadataProvider metadataProvider)}. Ignored 
for an enum field,
+   * which uses {@link Enum#name()}.
+   */
+  String comboValuesMethod() default "";
+}
diff --git 
a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiElementType.java 
b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiTableColumnElement.java
similarity index 56%
copy from core/src/main/java/org/apache/hop/core/gui/plugin/GuiElementType.java
copy to 
core/src/main/java/org/apache/hop/core/gui/plugin/GuiTableColumnElement.java
index afe04a8c70..c97d12982d 100644
--- a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiElementType.java
+++ 
b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiTableColumnElement.java
@@ -17,20 +17,28 @@
 
 package org.apache.hop.core.gui.plugin;
 
-public enum GuiElementType {
-  NONE, // To disable default options
-  TEXT,
-  /**
-   * Multi-line text widget ({@code SWT.MULTI}). Height in lines is set via 
{@link
-   * GuiWidgetElement#multiLineTextHeight()}.
-   */
-  MULTI_LINE_TEXT,
-  FILENAME, // Text widget with browse button
-  FOLDER, // Text widget with browse button
-  COMBO,
-  CHECKBOX,
-  METADATA, // Metadata selection line
-  BUTTON, // Push button
-  LINK, // A URL style link (underlined text)
-  COMPOSITE, // Painted on a method which can add widgets to a composite
+import lombok.Getter;
+import lombok.Setter;
+
+/**
+ * One column of a {@link GuiElementType#TABLE} widget, captured when the GUI 
registry scans a
+ * {@link GuiTableColumn}. SWT-free so the registry can live in core.
+ */
+@Getter
+@Setter
+public class GuiTableColumnElement {
+
+  private String id;
+  private String order;
+  private String label;
+  private String toolTip;
+  private GuiTableColumnType type;
+  private String fieldName;
+  private Class<?> fieldClass;
+  private String getterMethod;
+  private String setterMethod;
+  private boolean variables;
+  private boolean password;
+  private int width = -1;
+  private String comboValuesMethod;
 }
diff --git 
a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiElementType.java 
b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiTableColumnType.java
similarity index 63%
copy from core/src/main/java/org/apache/hop/core/gui/plugin/GuiElementType.java
copy to 
core/src/main/java/org/apache/hop/core/gui/plugin/GuiTableColumnType.java
index afe04a8c70..f5060e4ea7 100644
--- a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiElementType.java
+++ b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiTableColumnType.java
@@ -17,20 +17,20 @@
 
 package org.apache.hop.core.gui.plugin;
 
-public enum GuiElementType {
-  NONE, // To disable default options
+/** Cell editor used by a {@link GuiTableColumn}. Mapped to a table column by 
the UI layer. */
+public enum GuiTableColumnType {
+  /** A text cell. The annotated field must be a {@link String}. */
   TEXT,
+
   /**
-   * Multi-line text widget ({@code SWT.MULTI}). Height in lines is set via 
{@link
-   * GuiWidgetElement#multiLineTextHeight()}.
+   * A combo cell. An enum field is read-only and uses {@link Enum#name()}. A 
{@link String} field
+   * is editable; its items come from {@link 
GuiTableColumn#comboValuesMethod()}.
    */
-  MULTI_LINE_TEXT,
-  FILENAME, // Text widget with browse button
-  FOLDER, // Text widget with browse button
   COMBO,
-  CHECKBOX,
-  METADATA, // Metadata selection line
-  BUTTON, // Push button
-  LINK, // A URL style link (underlined text)
-  COMPOSITE, // Painted on a method which can add widgets to a composite
+
+  /**
+   * A yes/no cell ({@code Y} / {@code N}). The annotated field must be {@code 
boolean} or {@link
+   * Boolean}.
+   */
+  CHECKBOX
 }
diff --git 
a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiTableColumns.java 
b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiTableColumns.java
new file mode 100644
index 0000000000..24fe4ae25d
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiTableColumns.java
@@ -0,0 +1,266 @@
+/*
+ * 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.core.gui.plugin;
+
+import java.beans.IntrospectionException;
+import java.beans.PropertyDescriptor;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
+import java.lang.reflect.WildcardType;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.util.StringUtil;
+
+/**
+ * Reads {@link GuiTableColumn} fields from the row class of a {@link 
GuiElementType#TABLE} widget.
+ * A column whose type does not match its field is logged and left out. A bad 
widget is logged and
+ * left without columns; startup keeps going.
+ */
+class GuiTableColumns extends BaseGuiElements {
+
+  private static final GuiTableColumns INSTANCE = new GuiTableColumns();
+
+  private GuiTableColumns() {}
+
+  static void apply(GuiElements target, GuiWidgetElement annotation, Field 
field) {
+    INSTANCE.populate(target, annotation, field);
+  }
+
+  /** Registry scans run in unit tests before {@link HopLogStore} exists. Do 
not fail those. */
+  private static void logError(String message) {
+    if (HopLogStore.isInitialized()) {
+      LogChannel.GENERAL.logError(message);
+    }
+  }
+
+  private void populate(GuiElements target, GuiWidgetElement annotation, Field 
field) {
+    target.setTableRows(annotation.tableRows());
+    if (annotation.type() != GuiElementType.TABLE) {
+      return;
+    }
+
+    Class<?> rowClass = rowClass(target, field);
+    if (rowClass == null) {
+      return;
+    }
+    target.setTableRowClass(rowClass);
+
+    List<GuiTableColumnElement> columns = columnsOf(rowClass);
+    target.setTableColumns(columns);
+    if (columns.isEmpty()) {
+      logError(
+          "TABLE widget '"
+              + target.getId()
+              + "' has no @GuiTableColumn fields on "
+              + rowClass.getName());
+    }
+  }
+
+  private Class<?> rowClass(GuiElements target, Field field) {
+    if (!List.class.isAssignableFrom(field.getType())) {
+      logError(
+          "TABLE widget '"
+              + target.getId()
+              + "' on "
+              + field.getDeclaringClass().getName()
+              + "."
+              + field.getName()
+              + " must be a List");
+      return null;
+    }
+
+    Type generic = field.getGenericType();
+    if (!(generic instanceof ParameterizedType parameterized)) {
+      logError(
+          "TABLE widget '"
+              + target.getId()
+              + "' on "
+              + field.getName()
+              + " uses a raw List. Declare List<Row>.");
+      return null;
+    }
+
+    Class<?> rowClass = 
classArgument(parameterized.getActualTypeArguments()[0]);
+    if (rowClass == null) {
+      logError(
+          "TABLE widget '"
+              + target.getId()
+              + "' on "
+              + field.getName()
+              + " must be a List of a concrete row class");
+    }
+    return rowClass;
+  }
+
+  private Class<?> classArgument(Type argument) {
+    if (argument instanceof Class<?> type) {
+      return type;
+    }
+    if (argument instanceof WildcardType wildcard) {
+      Type[] bounds = wildcard.getUpperBounds();
+      if (bounds.length == 1 && bounds[0] instanceof Class<?> type) {
+        return type;
+      }
+    }
+    return null;
+  }
+
+  private List<GuiTableColumnElement> columnsOf(Class<?> rowClass) {
+    // Walk from the row class upward so a subclass field of the same name 
hides the superclass
+    // field. An unannotated subclass field hides it too: the subclass 
replaced the field.
+    List<GuiTableColumnElement> columns = new ArrayList<>();
+    Set<String> fieldNames = new HashSet<>();
+    Set<String> ids = new HashSet<>();
+    Class<?> type = rowClass;
+    while (type != null && type != Object.class) {
+      for (Field field : type.getDeclaredFields()) {
+        if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) {
+          continue;
+        }
+        if (!fieldNames.add(field.getName())) {
+          continue;
+        }
+        GuiTableColumn annotation = field.getAnnotation(GuiTableColumn.class);
+        if (annotation == null) {
+          continue;
+        }
+        GuiTableColumnElement column = columnElement(annotation, field, 
rowClass);
+        if (column == null) {
+          continue;
+        }
+        if (!ids.add(column.getId())) {
+          logError(
+              "Skipping @GuiTableColumn on "
+                  + field.getDeclaringClass().getSimpleName()
+                  + "."
+                  + field.getName()
+                  + ": duplicate column id '"
+                  + column.getId()
+                  + "'");
+          continue;
+        }
+        columns.add(column);
+      }
+      type = type.getSuperclass();
+    }
+
+    columns.sort(
+        Comparator.comparing((GuiTableColumnElement column) -> 
Const.NVL(column.getOrder(), ""))
+            .thenComparing(column -> Const.NVL(column.getId(), "")));
+    return columns;
+  }
+
+  private GuiTableColumnElement columnElement(
+      GuiTableColumn annotation, Field field, Class<?> rowClass) {
+    if (!typeMatches(annotation, field)) {
+      return null;
+    }
+
+    GuiTableColumnElement column = new GuiTableColumnElement();
+    column.setId(StringUtils.isEmpty(annotation.id()) ? field.getName() : 
annotation.id());
+    column.setOrder(annotation.order());
+    column.setType(annotation.type());
+    column.setFieldName(field.getName());
+    column.setFieldClass(field.getType());
+    column.setVariables(annotation.variables());
+    column.setPassword(annotation.password());
+    column.setWidth(annotation.width() < 0 ? -1 : annotation.width());
+    column.setComboValuesMethod(annotation.comboValuesMethod());
+
+    String label =
+        getTranslation(
+            annotation.label(),
+            field.getDeclaringClass().getPackage().getName(),
+            field.getDeclaringClass());
+    column.setLabel(StringUtils.isEmpty(label) ? field.getName() : label);
+    column.setToolTip(
+        getTranslation(
+            annotation.toolTip(),
+            field.getDeclaringClass().getPackage().getName(),
+            field.getDeclaringClass()));
+
+    String getter = annotation.getterMethod();
+    String setter = annotation.setterMethod();
+    if (StringUtils.isEmpty(getter) || StringUtils.isEmpty(setter)) {
+      try {
+        PropertyDescriptor descriptor = new 
PropertyDescriptor(field.getName(), rowClass);
+        if (StringUtils.isEmpty(getter) && descriptor.getReadMethod() != null) 
{
+          getter = descriptor.getReadMethod().getName();
+        }
+        if (StringUtils.isEmpty(setter) && descriptor.getWriteMethod() != 
null) {
+          setter = descriptor.getWriteMethod().getName();
+        }
+      } catch (IntrospectionException e) {
+        // Fall through to the bean name. The method may still exist.
+      }
+    }
+    if (StringUtils.isEmpty(getter)) {
+      String suffix = StringUtil.initCap(field.getName());
+      boolean flag = field.getType() == boolean.class || field.getType() == 
Boolean.class;
+      getter = (flag ? "is" : "get") + suffix;
+    }
+    if (StringUtils.isEmpty(setter)) {
+      setter = "set" + StringUtil.initCap(field.getName());
+    }
+    column.setGetterMethod(getter);
+    column.setSetterMethod(setter);
+    return column;
+  }
+
+  private boolean typeMatches(GuiTableColumn annotation, Field field) {
+    Class<?> fieldType = field.getType();
+    String where = field.getDeclaringClass().getSimpleName() + "." + 
field.getName();
+    switch (annotation.type()) {
+      case TEXT:
+        if (fieldType != String.class) {
+          logError("Skipping @GuiTableColumn on " + where + ": TEXT columns 
must be String");
+          return false;
+        }
+        return true;
+      case COMBO:
+        if (fieldType != String.class && !fieldType.isEnum()) {
+          logError(
+              "Skipping @GuiTableColumn on " + where + ": COMBO columns must 
be String or an enum");
+          return false;
+        }
+        return true;
+      case CHECKBOX:
+        if (fieldType != boolean.class && fieldType != Boolean.class) {
+          logError("Skipping @GuiTableColumn on " + where + ": CHECKBOX 
columns must be boolean");
+          return false;
+        }
+        return true;
+      default:
+        logError(
+            "Skipping @GuiTableColumn on "
+                + where
+                + ": unsupported column type "
+                + annotation.type());
+        return false;
+    }
+  }
+}
diff --git 
a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiWidgetElement.java 
b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiWidgetElement.java
index dae7253be9..c9e8fad7a5 100644
--- a/core/src/main/java/org/apache/hop/core/gui/plugin/GuiWidgetElement.java
+++ b/core/src/main/java/org/apache/hop/core/gui/plugin/GuiWidgetElement.java
@@ -88,6 +88,15 @@ public @interface GuiWidgetElement {
    */
   int multiLineTextHeight() default 1;
 
+  /**
+   * Preferred height of a {@link GuiElementType#TABLE}, in rows. Default is 
5. Values less than 1
+   * are treated as 5. Ignored for other element types. The last grid in a 
parent also grows with
+   * the parent.
+   *
+   * @return height in rows
+   */
+  int tableRows() default 5;
+
   /**
    * @return true if the widget supports variables
    */
diff --git 
a/core/src/test/java/org/apache/hop/core/gui/plugin/GuiRegistryTest.java 
b/core/src/test/java/org/apache/hop/core/gui/plugin/GuiRegistryTest.java
index eb732a7809..b723e938f2 100644
--- a/core/src/test/java/org/apache/hop/core/gui/plugin/GuiRegistryTest.java
+++ b/core/src/test/java/org/apache/hop/core/gui/plugin/GuiRegistryTest.java
@@ -18,11 +18,14 @@
 package org.apache.hop.core.gui.plugin;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
@@ -114,6 +117,106 @@ class GuiRegistryTest {
     assertTrue(elements.getChildren().get(0).isIgnored());
   }
 
+  @Test
+  void tableFieldRegistersTheRowColumns() throws Exception {
+    String dataClassName = getClass().getName() + "#tables";
+    registerTableFields(dataClassName);
+    registerTableFields(dataClassName);
+
+    GuiElements elements = registry.findGuiElements(dataClassName, 
TableHost.PARENT_ID);
+    assertEquals(6, elements.getChildren().size());
+
+    GuiElements rows = elements.findChild("rows");
+    assertEquals(SampleRow.class, rows.getTableRowClass());
+    assertEquals(4, rows.getTableRows());
+    assertEquals(List.of("kind", "name", "active", "choice"), columnIds(rows));
+
+    GuiTableColumnElement name = column(rows, "name");
+    assertEquals(GuiTableColumnType.TEXT, name.getType());
+    assertEquals(String.class, name.getFieldClass());
+    assertEquals("readName", name.getGetterMethod());
+    assertEquals("writeName", name.getSetterMethod());
+    assertFalse(name.isVariables());
+    assertTrue(name.isPassword());
+    assertEquals(80, name.getWidth());
+
+    GuiTableColumnElement kind = column(rows, "kind");
+    assertEquals(GuiTableColumnType.COMBO, kind.getType());
+    assertEquals(SampleKind.class, kind.getFieldClass());
+    assertTrue(kind.isVariables());
+
+    GuiTableColumnElement active = column(rows, "active");
+    assertEquals(GuiTableColumnType.CHECKBOX, active.getType());
+    assertEquals(boolean.class, active.getFieldClass());
+    assertEquals("isActive", active.getGetterMethod());
+    assertEquals("setActive", active.getSetterMethod());
+
+    GuiTableColumnElement choice = column(rows, "choice");
+    assertEquals(String.class, choice.getFieldClass());
+    assertEquals("choices", choice.getComboValuesMethod());
+
+    GuiElements children = elements.findChild("children");
+    assertEquals(ChildRow.class, children.getTableRowClass());
+    assertEquals(List.of("name", "extra", "note"), columnIds(children));
+    assertEquals("Child name", column(children, "name").getLabel());
+
+    GuiElements wild = elements.findChild("wild");
+    assertEquals(SampleRow.class, wild.getTableRowClass());
+    assertEquals(4, wild.getTableColumns().size());
+
+    GuiElements strings = elements.findChild("strings");
+    assertEquals(String.class, strings.getTableRowClass());
+    assertTrue(strings.getTableColumns().isEmpty());
+
+    GuiElements raw = elements.findChild("raw");
+    assertNull(raw.getTableRowClass());
+    assertTrue(raw.getTableColumns().isEmpty());
+
+    GuiElements bad = elements.findChild("bad");
+    assertNull(bad.getTableRowClass());
+    assertTrue(bad.getTableColumns().isEmpty());
+    assertEquals(5, bad.getTableRows());
+  }
+
+  @Test
+  void tableWidgetOnAMethodIsIgnored() throws Exception {
+    String dataClassName = getClass().getName() + "#tableMethod";
+    Method method = TableHost.class.getDeclaredMethod("notAGrid");
+    registry.addGuiWidgetElement(
+        method.getAnnotation(GuiWidgetElement.class),
+        method,
+        dataClassName,
+        getClass().getClassLoader());
+
+    assertNull(registry.findGuiElements(dataClassName, TableHost.PARENT_ID));
+  }
+
+  private void registerTableFields(String dataClassName) throws Exception {
+    for (Field field : TableHost.class.getDeclaredFields()) {
+      GuiWidgetElement element = field.getAnnotation(GuiWidgetElement.class);
+      if (element != null) {
+        registry.addGuiWidgetElement(dataClassName, element, field);
+      }
+    }
+  }
+
+  private static List<String> columnIds(GuiElements element) {
+    List<String> ids = new ArrayList<>();
+    for (GuiTableColumnElement column : element.getTableColumns()) {
+      ids.add(column.getId());
+    }
+    return ids;
+  }
+
+  private static GuiTableColumnElement column(GuiElements element, String id) {
+    for (GuiTableColumnElement column : element.getTableColumns()) {
+      if (id.equals(column.getId())) {
+        return column;
+      }
+    }
+    throw new AssertionError("Missing column " + id);
+  }
+
   private static class WidgetSample {
     static final String PARENT_ID = "GuiRegistryTest-parent";
 
@@ -131,4 +234,171 @@ class GuiRegistryTest {
       // Only the annotation matters here.
     }
   }
+
+  public static class TableHost {
+    static final String PARENT_ID = "GuiRegistryTest-table";
+
+    @GuiWidgetElement(id = "rows", type = GuiElementType.TABLE, parentId = 
PARENT_ID, tableRows = 4)
+    private List<SampleRow> rows;
+
+    @GuiWidgetElement(id = "children", type = GuiElementType.TABLE, parentId = 
PARENT_ID)
+    private List<ChildRow> children;
+
+    @GuiWidgetElement(id = "wild", type = GuiElementType.TABLE, parentId = 
PARENT_ID)
+    private List<? extends SampleRow> wild;
+
+    @GuiWidgetElement(id = "strings", type = GuiElementType.TABLE, parentId = 
PARENT_ID)
+    private List<String> strings;
+
+    @SuppressWarnings("rawtypes")
+    @GuiWidgetElement(id = "raw", type = GuiElementType.TABLE, parentId = 
PARENT_ID)
+    private List rawRows;
+
+    @GuiWidgetElement(id = "bad", type = GuiElementType.TABLE, parentId = 
PARENT_ID, tableRows = 0)
+    private String bad;
+
+    @GuiWidgetElement(id = "not-a-grid", type = GuiElementType.TABLE, parentId 
= PARENT_ID)
+    public void notAGrid() {
+      // TABLE on a method is rejected by the registry.
+    }
+  }
+
+  public enum SampleKind {
+    LEFT,
+    RIGHT
+  }
+
+  public static class SampleRow {
+    @GuiTableColumn(order = "10", type = GuiTableColumnType.COMBO, label = 
"Kind")
+    private SampleKind kind;
+
+    @GuiTableColumn(
+        order = "20",
+        type = GuiTableColumnType.TEXT,
+        label = "Name",
+        getterMethod = "readName",
+        setterMethod = "writeName",
+        variables = false,
+        password = true,
+        width = 80)
+    private String name;
+
+    @GuiTableColumn(order = "30", type = GuiTableColumnType.CHECKBOX, label = 
"Active")
+    private boolean active;
+
+    @GuiTableColumn(
+        order = "40",
+        type = GuiTableColumnType.COMBO,
+        label = "Choice",
+        comboValuesMethod = "choices")
+    private String choice;
+
+    @GuiTableColumn(order = "50", type = GuiTableColumnType.TEXT, label = 
"Count")
+    private int count;
+
+    private String note;
+
+    public SampleKind getKind() {
+      return kind;
+    }
+
+    public void setKind(SampleKind kind) {
+      this.kind = kind;
+    }
+
+    public String readName() {
+      return name;
+    }
+
+    public void writeName(String name) {
+      this.name = name;
+    }
+
+    public boolean isActive() {
+      return active;
+    }
+
+    public void setActive(boolean active) {
+      this.active = active;
+    }
+
+    public String getChoice() {
+      return choice;
+    }
+
+    public void setChoice(String choice) {
+      this.choice = choice;
+    }
+
+    public int getCount() {
+      return count;
+    }
+
+    public void setCount(int count) {
+      this.count = count;
+    }
+  }
+
+  public static class ParentRow {
+    @GuiTableColumn(order = "10", type = GuiTableColumnType.TEXT, label = 
"Parent name")
+    private String name;
+
+    @GuiTableColumn(order = "30", type = GuiTableColumnType.TEXT, label = 
"Note")
+    private String note;
+
+    @GuiTableColumn(order = "40", type = GuiTableColumnType.TEXT, label = 
"Hidden")
+    private String hidden;
+
+    public String getName() {
+      return name;
+    }
+
+    public void setName(String name) {
+      this.name = name;
+    }
+
+    public String getNote() {
+      return note;
+    }
+
+    public void setNote(String note) {
+      this.note = note;
+    }
+
+    public String getHidden() {
+      return hidden;
+    }
+
+    public void setHidden(String hidden) {
+      this.hidden = hidden;
+    }
+  }
+
+  public static class ChildRow extends ParentRow {
+    @GuiTableColumn(order = "10", type = GuiTableColumnType.TEXT, label = 
"Child name")
+    private String name;
+
+    @GuiTableColumn(order = "20", type = GuiTableColumnType.TEXT, label = 
"Extra")
+    private String extra;
+
+    private String hidden;
+
+    @Override
+    public String getName() {
+      return name;
+    }
+
+    @Override
+    public void setName(String name) {
+      this.name = name;
+    }
+
+    public String getExtra() {
+      return extra;
+    }
+
+    public void setExtra(String extra) {
+      this.extra = extra;
+    }
+  }
 }
diff --git a/docs/hop-dev-manual/modules/ROOT/pages/plugin-types/gui.adoc 
b/docs/hop-dev-manual/modules/ROOT/pages/plugin-types/gui.adoc
index 1b6d6a5aae..a1139a77fa 100644
--- a/docs/hop-dev-manual/modules/ROOT/pages/plugin-types/gui.adoc
+++ b/docs/hop-dev-manual/modules/ROOT/pages/plugin-types/gui.adoc
@@ -63,6 +63,47 @@ Return the incoming verdict unchanged for ids you do not 
own, or you will hide e
 xref:gui-plugins-toolbars.adoc[GUI plugins and toolbars] covers this and the 
mid-construction pitfall in detail.
 ====
 
+=== Data grids
+
+`@GuiWidgetElement(type = TABLE)` on a `List` field draws a data grid.
+The columns are `@GuiTableColumn` fields on the row class: `TEXT` (a 
`String`), `COMBO` (an enum, or a `String` whose items come from 
`comboValuesMethod` on the parent object), or `CHECKBOX` (a `boolean`, stored 
in the grid as `Y` / `N`).
+`@GuiTableColumn` is only a GUI annotation.
+`@HopMetadataProperty` still stores the list.
+Enum cells use `Enum.name()`, not `toString()`.
+`tableRows` (default 5) is the grid height in rows.
+The last grid in a tab or box keeps that height when the dialog opens and 
grows when the dialog is resized.
+
+A button that fills the list, such as Get Fields, is an ordinary 
`@GuiWidgetElement` of type `BUTTON`.
+After the method returns, the grid is refilled from the list.
+A `String` column with `comboValuesMethod` reads that method again each time 
the grid is filled, so the items can change after the dialog has opened.
+
+[source,java]
+----
+@GuiWidgetElement(
+    id = "vars",
+    order = "1000",
+    type = GuiElementType.TABLE,
+    label = "i18n::ActionDbt.Vars.Label",
+    parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+    group = "Variables",
+    groupOrder = "40",
+    groupType = GuiWidgetGroupType.TABS,
+    tableRows = 5)
+@HopMetadataProperty(groupKey = "vars", key = "var")
+private List<DbtNameValue> vars;
+----
+
+[source,java]
+----
+@GuiTableColumn(
+    order = "10",
+    type = GuiTableColumnType.TEXT,
+    label = "i18n::ActionDbt.Column.Name",
+    variables = false)
+@HopMetadataProperty
+private String name;
+----
+
 == Perspective
 
 A perspective is a full screen in the Hop GUI, selected from the icons in the 
left sidebar: the Data Orchestration, Metadata, Explorer and Execution 
Information perspectives are all plugins.
diff --git 
a/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProvider.java 
b/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProvider.java
index d974d6d5c4..39fdac30d1 100644
--- a/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProvider.java
+++ b/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProvider.java
@@ -62,6 +62,7 @@ public class AiProvider extends HopMetadataBase implements 
IHopMetadata {
   public static final String WIDGET_TIMEOUT = "0300-timeout";
   public static final String WIDGET_MODEL_NAME = "0400-model-name";
   public static final String WIDGET_TEMPERATURE = "0500-temperature";
+  public static final String WIDGET_MODELS = "0600-models";
 
   @HopMetadataProperty(key = "provider")
   private IAiProvider provider;
@@ -142,6 +143,16 @@ public class AiProvider extends HopMetadataBase implements 
IHopMetadata {
    * falls back to {@link #modelName} and nothing changes.
    */
   @HopMetadataProperty(key = "models", injectionGroupKey = "MODELS")
+  @GuiWidgetElement(
+      id = WIDGET_MODELS,
+      order = "0600",
+      type = GuiElementType.TABLE,
+      parentId = GUI_WIDGETS_PARENT_ID,
+      groupType = GuiWidgetGroupType.BOXES,
+      group = "i18n::AiProviderEditor.Models.Label",
+      groupOrder = "30",
+      toolTip = "i18n::AiProviderEditor.Models.Tooltip",
+      tableRows = 4)
   private List<AiProviderModel> models = new ArrayList<>();
 
   public AiProvider() {}
diff --git 
a/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProviderEditor.java
 
b/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProviderEditor.java
index ec26adcdae..301227c85d 100644
--- 
a/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProviderEditor.java
+++ 
b/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProviderEditor.java
@@ -36,8 +36,6 @@ import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
 import org.apache.hop.ui.core.gui.GuiCompositeWidgetsAdapter;
 import org.apache.hop.ui.core.metadata.MetadataEditor;
 import org.apache.hop.ui.core.metadata.MetadataManager;
-import org.apache.hop.ui.core.widget.ColumnInfo;
-import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.eclipse.swt.SWT;
@@ -52,7 +50,6 @@ import org.eclipse.swt.widgets.Combo;
 import org.eclipse.swt.widgets.Composite;
 import org.eclipse.swt.widgets.Control;
 import org.eclipse.swt.widgets.Label;
-import org.eclipse.swt.widgets.TableItem;
 
 /** Metadata editor for {@link AiProvider}. */
 public class AiProviderEditor extends MetadataEditor<AiProvider> {
@@ -66,7 +63,6 @@ public class AiProviderEditor extends 
MetadataEditor<AiProvider> {
   private ScrolledComposite wScrolled;
   private Composite wContent;
   private final AtomicBoolean busyChangingType = new AtomicBoolean(false);
-  private TableView wModels;
 
   public AiProviderEditor(HopGui hopGui, MetadataManager<AiProvider> manager, 
AiProvider metadata) {
     super(hopGui, manager, metadata);
@@ -120,11 +116,6 @@ public class AiProviderEditor extends 
MetadataEditor<AiProvider> {
     wScrolled.setContent(wContent);
 
     widgets = new GuiCompositeWidgets(manager.getVariables());
-    widgets.registerExtraGroup(
-        BaseMessages.getString(PKG, "AiProviderEditor.Models.Label"),
-        "30",
-        null,
-        this::addModelsTable);
     widgets.createCompositeWidgets(
         getMetadata(), null, wContent, AiProvider.GUI_WIDGETS_PARENT_ID, null);
 
@@ -144,54 +135,6 @@ public class AiProviderEditor extends 
MetadataEditor<AiProvider> {
         });
   }
 
-  /**
-   * The per-role model table. A list of rows is not something {@code 
@GuiWidgetElement} can
-   * express, so it is registered as an extra group and built into the box 
{@link
-   * GuiCompositeWidgets} creates for it, next to the annotated groups.
-   */
-  private void addModelsTable(Composite box) {
-    ColumnInfo[] columns =
-        new ColumnInfo[] {
-          new ColumnInfo(
-              BaseMessages.getString(PKG, 
"AiProviderEditor.Models.Column.Role"),
-              ColumnInfo.COLUMN_TYPE_CCOMBO,
-              roleNames(),
-              false),
-          new ColumnInfo(
-              BaseMessages.getString(PKG, 
"AiProviderEditor.Models.Column.ModelName"),
-              ColumnInfo.COLUMN_TYPE_TEXT,
-              false)
-        };
-
-    wModels =
-        new TableView(
-            manager.getVariables(),
-            box,
-            SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
-            columns,
-            0,
-            e -> setChanged(),
-            PropsUi.getInstance());
-    wModels
-        .getTable()
-        .setToolTipText(BaseMessages.getString(PKG, 
"AiProviderEditor.Models.Tooltip"));
-    FormData fdModels = new FormData();
-    fdModels.left = new FormAttachment(0, 0);
-    fdModels.right = new FormAttachment(100, 0);
-    fdModels.top = new FormAttachment(0, 0);
-    fdModels.height = (int) (PropsUi.getInstance().getZoomFactor() * 140);
-    wModels.setLayoutData(fdModels);
-  }
-
-  private static String[] roleNames() {
-    AiModelRole[] roles = AiModelRole.values();
-    String[] names = new String[roles.length];
-    for (int i = 0; i < roles.length; i++) {
-      names[i] = roles[i].name();
-    }
-    return names;
-  }
-
   private void changeProviderType() {
     if (busyChangingType.get()) {
       return;
@@ -260,17 +203,6 @@ public class AiProviderEditor extends 
MetadataEditor<AiProvider> {
       wProviderType.setText(meta.getPluginName());
     }
     widgets.setWidgetsContents(meta, wContent, 
AiProvider.GUI_WIDGETS_PARENT_ID);
-    if (wModels != null) {
-      wModels.clearAll();
-      for (AiProviderModel model : meta.getModels()) {
-        TableItem item = new TableItem(wModels.table, SWT.NONE);
-        item.setText(1, model.getRole() == null ? AiModelRole.CHAT.name() : 
model.getRole().name());
-        item.setText(2, Const.NVL(model.getModelName(), ""));
-      }
-      wModels.removeEmptyRows();
-      wModels.setRowNums();
-      wModels.optWidth(true);
-    }
     updateVisibility();
   }
 
@@ -278,7 +210,10 @@ public class AiProviderEditor extends 
MetadataEditor<AiProvider> {
   public void getWidgetsContent(AiProvider meta) {
     meta.setName(wName.getText());
     widgets.getWidgetsContents(meta, AiProvider.GUI_WIDGETS_PARENT_ID);
-    meta.setModels(readModels());
+    // A role with no model name is not a configured model. The hand-built 
grid skipped those rows.
+    if (meta.getModels() != null) {
+      meta.getModels().removeIf(model -> model == null || 
Utils.isEmpty(model.getModelName()));
+    }
     String selected = wProviderType.getText();
     if (selected != null && !selected.isEmpty()) {
       try {
@@ -291,20 +226,6 @@ public class AiProviderEditor extends 
MetadataEditor<AiProvider> {
     }
   }
 
-  private List<AiProviderModel> readModels() {
-    List<AiProviderModel> models = new ArrayList<>();
-    if (wModels == null || wModels.isDisposed()) {
-      return models;
-    }
-    for (TableItem item : wModels.getNonEmptyItems()) {
-      String modelName = item.getText(2);
-      if (!Utils.isEmpty(modelName)) {
-        models.add(new 
AiProviderModel(AiModelRole.fromString(item.getText(1)), modelName));
-      }
-    }
-    return models;
-  }
-
   @Override
   public Button[] createButtonsForButtonBar(Composite composite) {
     Button wbRefresh = new Button(composite, SWT.PUSH | SWT.CENTER);
diff --git 
a/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProviderModel.java 
b/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProviderModel.java
index 513fec1c75..0463129bcb 100644
--- 
a/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProviderModel.java
+++ 
b/plugins/tech/ai/src/main/java/org/apache/hop/ai/metadata/AiProviderModel.java
@@ -17,15 +17,26 @@
 package org.apache.hop.ai.metadata;
 
 import java.util.Objects;
+import org.apache.hop.core.gui.plugin.GuiTableColumn;
+import org.apache.hop.core.gui.plugin.GuiTableColumnType;
 import org.apache.hop.metadata.api.HopMetadataProperty;
 
 /** One model served by an {@link AiProvider}, for one {@link AiModelRole}. */
 public class AiProviderModel {
 
   @HopMetadataProperty(key = "role", injectionKey = "MODEL_ROLE")
+  @GuiTableColumn(
+      order = "10",
+      type = GuiTableColumnType.COMBO,
+      label = "i18n::AiProviderEditor.Models.Column.Role")
   private AiModelRole role = AiModelRole.CHAT;
 
   @HopMetadataProperty(key = "model_name", injectionKey = "MODEL_NAME")
+  @GuiTableColumn(
+      order = "20",
+      type = GuiTableColumnType.TEXT,
+      label = "i18n::AiProviderEditor.Models.Column.ModelName",
+      variables = false)
   private String modelName = "";
 
   public AiProviderModel() {}
diff --git 
a/rcp/src/test/java/org/apache/hop/ui/core/gui/GuiCompositeWidgetsTableTest.java
 
b/rcp/src/test/java/org/apache/hop/ui/core/gui/GuiCompositeWidgetsTableTest.java
new file mode 100644
index 0000000000..865e606fa4
--- /dev/null
+++ 
b/rcp/src/test/java/org/apache/hop/ui/core/gui/GuiCompositeWidgetsTableTest.java
@@ -0,0 +1,509 @@
+/*
+ * 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.gui;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import org.apache.hop.core.gui.plugin.GuiElementType;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.GuiRegistry;
+import org.apache.hop.core.gui.plugin.GuiTableColumn;
+import org.apache.hop.core.gui.plugin.GuiTableColumnType;
+import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.gui.plugin.GuiWidgetGroupType;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.ui.core.widget.TableView;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swt.widgets.TableItem;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+@Tag("uitest")
+class GuiCompositeWidgetsTableTest extends SwtBotTestBase {
+
+  private static final String TABBED_PARENT = 
"GuiCompositeWidgetsTableTest-tabbed";
+  private static final String FLAT_PARENT = 
"GuiCompositeWidgetsTableTest-flat";
+
+  @BeforeAll
+  static void registerSampleWidgets() throws Exception {
+    register(TableSample.class);
+    register(FlatTable.class);
+    register(ComboHost.class);
+    register(OverloadHost.class);
+  }
+
+  @Test
+  void gridRoundTripsRowsAndDropsTheBlankRow() {
+    Shell shell = new Shell(display);
+    shell.setLayout(new FormLayout());
+    try {
+      TableSample source = new TableSample();
+      List<SampleRow> original = source.getRows();
+      original.add(new SampleRow("alpha", true, SampleKind.LEFT));
+      original.add(new SampleRow("beta", false, SampleKind.RIGHT));
+
+      GuiCompositeWidgets widgets = new GuiCompositeWidgets(new Variables());
+      widgets.createCompositeWidgets(source, null, shell, TABBED_PARENT, null);
+      widgets.setWidgetsContents(source, shell, TABBED_PARENT);
+
+      TableView table = (TableView) widgets.getWidgetsMap().get("rows");
+      assertEquals("alpha", table.getTable().getItem(0).getText(1));
+      assertEquals("Y", table.getTable().getItem(0).getText(2));
+      assertEquals("LEFT", table.getTable().getItem(0).getText(3));
+      assertEquals("beta", table.getTable().getItem(1).getText(1));
+      assertEquals("N", table.getTable().getItem(1).getText(2));
+      assertEquals("RIGHT", table.getTable().getItem(1).getText(3));
+
+      table.getTable().getItem(0).setText(1, "gamma");
+      table.getTable().getItem(0).setText(2, "N");
+      table.getTable().getItem(0).setText(3, "RIGHT");
+      new TableItem(table.getTable(), SWT.NONE);
+
+      widgets.getWidgetsContents(source, TABBED_PARENT);
+
+      assertSame(original, source.getRows());
+      assertEquals(2, source.getRows().size());
+      assertEquals("gamma", source.getRows().get(0).getName());
+      assertFalse(source.getRows().get(0).isActive());
+      assertEquals(SampleKind.RIGHT, source.getRows().get(0).getKind());
+      assertEquals("beta", source.getRows().get(1).getName());
+
+      table.getTable().getItem(0).setText(3, "");
+      widgets.getWidgetsContents(source, TABBED_PARENT);
+      assertNull(source.getRows().get(0).getKind());
+
+      table.getTable().getItem(0).setText(2, "x");
+      table.getTable().getItem(0).setText(3, "NOPE");
+      widgets.getWidgetsContents(source, TABBED_PARENT);
+      assertFalse(source.getRows().get(0).isActive());
+      assertNull(source.getRows().get(0).getKind());
+
+      source.setRows(null);
+      widgets.setWidgetsContents(source, shell, TABBED_PARENT);
+      widgets.getWidgetsContents(source, TABBED_PARENT);
+      assertNotNull(source.getRows());
+      assertTrue(source.getRows().isEmpty());
+    } finally {
+      shell.dispose();
+    }
+  }
+
+  @Test
+  void lastGridFillsTheParentAndHidingRestoresThatAttachment() {
+    Shell shell = new Shell(display);
+    shell.setLayout(new FormLayout());
+    shell.setSize(800, 600);
+    try {
+      TableSample source = new TableSample();
+      GuiCompositeWidgets widgets = new GuiCompositeWidgets(new Variables());
+      widgets.createCompositeWidgets(source, null, shell, TABBED_PARENT, null);
+
+      CTabFolder folder = findTabFolder(shell);
+      assertNotNull(folder);
+      assertEquals(1, folder.getItemCount());
+      assertEquals("Rows", folder.getItem(0).getText());
+
+      TableView rows = (TableView) widgets.getWidgetsMap().get("rows");
+      TableView more = (TableView) widgets.getWidgetsMap().get("more");
+      FormData rowsData = (FormData) rows.getLayoutData();
+      FormData moreData = (FormData) more.getLayoutData();
+      assertEquals(0, rowsData.left.numerator);
+      assertEquals(100, rowsData.right.numerator);
+      assertTrue(rowsData.height > 0);
+      assertNull(rowsData.bottom);
+      assertEquals(100, moreData.bottom.numerator);
+      assertTrue(moreData.height > 0);
+
+      Label label = (Label) widgets.getLabelsMap().get("rows");
+      FormData labelData = (FormData) label.getLayoutData();
+      assertEquals(100, labelData.right.numerator);
+      assertEquals(0, label.getStyle() & SWT.RIGHT);
+
+      widgets.setWidgetsHidden(source, Set.of("more"));
+      assertFalse(more.getVisible());
+      FormData hidden = (FormData) more.getLayoutData();
+      assertNull(hidden.bottom);
+      assertEquals(0, hidden.height);
+      assertNull(((FormData) rows.getLayoutData()).bottom);
+
+      widgets.setWidgetsHidden(source, Set.of());
+      assertTrue(more.getVisible());
+      FormData restored = (FormData) more.getLayoutData();
+      assertNotNull(restored.bottom);
+      assertEquals(100, restored.bottom.numerator);
+      assertTrue(restored.height > 0);
+    } finally {
+      shell.dispose();
+    }
+  }
+
+  @Test
+  void aFlatGridAlsoFillsItsParent() {
+    Shell shell = new Shell(display);
+    shell.setLayout(new FormLayout());
+    try {
+      FlatTable source = new FlatTable();
+      GuiCompositeWidgets widgets = new GuiCompositeWidgets(new Variables());
+      widgets.createCompositeWidgets(source, null, shell, FLAT_PARENT, null);
+
+      Control table = widgets.getWidgetsMap().get("only");
+      assertInstanceOf(TableView.class, table);
+      FormData data = (FormData) table.getLayoutData();
+      assertEquals(0, data.left.numerator);
+      assertEquals(100, data.right.numerator);
+      assertNotNull(data.bottom);
+      assertEquals(100, data.bottom.numerator);
+      assertTrue(data.height > 0);
+
+      Label label = (Label) widgets.getLabelsMap().get("only");
+      assertEquals(100, ((FormData) label.getLayoutData()).right.numerator);
+      assertEquals(0, label.getStyle() & SWT.RIGHT);
+    } finally {
+      shell.dispose();
+    }
+  }
+
+  @Test
+  void buttonAppendsARowAndRefreshesTheGrid() {
+    Shell shell = new Shell(display);
+    shell.setLayout(new FormLayout());
+    try {
+      TableSample source = new TableSample();
+      GuiCompositeWidgets widgets = new GuiCompositeWidgets(new Variables());
+      widgets.createCompositeWidgets(source, null, shell, TABBED_PARENT, null);
+      widgets.setWidgetsContents(source, shell, TABBED_PARENT);
+
+      Button add = (Button) widgets.getWidgetsMap().get("add");
+      Event event = new Event();
+      event.widget = add;
+      add.notifyListeners(SWT.Selection, event);
+
+      TableView table = (TableView) widgets.getWidgetsMap().get("rows");
+      assertEquals(1, source.getRows().size());
+      assertEquals("added", source.getRows().get(0).getName());
+      assertEquals("added", table.getTable().getItem(0).getText(1));
+      assertEquals("Y", table.getTable().getItem(0).getText(2));
+      assertEquals("LEFT", table.getTable().getItem(0).getText(3));
+    } finally {
+      shell.dispose();
+    }
+  }
+
+  @Test
+  void stringComboItemsAreReadAgainWhenTheGridIsFilled() {
+    Shell shell = new Shell(display);
+    shell.setLayout(new FormLayout());
+    try {
+      ComboHost source = new ComboHost();
+      source.getItems().add(new ChoiceRow("one", SampleKind.LEFT));
+
+      GuiCompositeWidgets widgets = new GuiCompositeWidgets(new Variables());
+      widgets.createCompositeWidgets(source, null, shell, ComboHost.PARENT_ID, 
null);
+      widgets.setWidgetsContents(source, shell, ComboHost.PARENT_ID);
+
+      TableView table = (TableView) widgets.getWidgetsMap().get("items");
+      assertArrayEquals(new String[] {"one", "two"}, 
table.getColumns()[0].getComboValues());
+      assertArrayEquals(new String[] {"LEFT", "RIGHT"}, 
table.getColumns()[1].getComboValues());
+      assertEquals("one", table.getTable().getItem(0).getText(1));
+
+      source.getChoices().clear();
+      source.getChoices().add("three");
+      widgets.setWidgetsContents(source, shell, ComboHost.PARENT_ID);
+
+      assertArrayEquals(new String[] {"three"}, 
table.getColumns()[0].getComboValues());
+      assertArrayEquals(new String[] {"LEFT", "RIGHT"}, 
table.getColumns()[1].getComboValues());
+      assertEquals("one", table.getTable().getItem(0).getText(1));
+    } finally {
+      shell.dispose();
+    }
+  }
+
+  @Test
+  void overloadedSettersMatchTheFieldType() {
+    Shell shell = new Shell(display);
+    shell.setLayout(new FormLayout());
+    try {
+      OverloadHost source = new OverloadHost();
+      GuiCompositeWidgets widgets = new GuiCompositeWidgets(new Variables());
+      widgets.createCompositeWidgets(source, null, shell, 
OverloadHost.PARENT_ID, null);
+      widgets.setWidgetsContents(source, shell, OverloadHost.PARENT_ID);
+
+      TableView table = (TableView) widgets.getWidgetsMap().get("roles");
+      table.getTable().getItem(0).setText(1, "RIGHT");
+      widgets.getWidgetsContents(source, OverloadHost.PARENT_ID);
+
+      assertNotNull(source.getRoles());
+      assertEquals(1, source.getRoles().size());
+      assertEquals(SampleKind.RIGHT, source.getRoles().get(0).getRole());
+      assertFalse(source.getRoles().get(0).stringSetterUsed);
+      assertFalse(source.stringListSetterUsed);
+    } finally {
+      shell.dispose();
+    }
+  }
+
+  private static CTabFolder findTabFolder(Composite parent) {
+    for (Control child : parent.getChildren()) {
+      if (child instanceof CTabFolder folder) {
+        return folder;
+      }
+      if (child instanceof Composite composite) {
+        CTabFolder nested = findTabFolder(composite);
+        if (nested != null) {
+          return nested;
+        }
+      }
+    }
+    return null;
+  }
+
+  private static void register(Class<?> type) throws Exception {
+    GuiRegistry registry = GuiRegistry.getInstance();
+    String parentId = parentId(type);
+    if (parentId == null || registry.findGuiElements(type.getName(), parentId) 
!= null) {
+      return;
+    }
+    for (Field field : type.getDeclaredFields()) {
+      GuiWidgetElement element = field.getAnnotation(GuiWidgetElement.class);
+      if (element != null) {
+        registry.addGuiWidgetElement(type.getName(), element, field);
+      }
+    }
+    for (Method method : type.getDeclaredMethods()) {
+      GuiWidgetElement element = method.getAnnotation(GuiWidgetElement.class);
+      if (element != null) {
+        registry.addGuiWidgetElement(element, method, type.getName(), 
type.getClassLoader());
+      }
+    }
+  }
+
+  private static String parentId(Class<?> type) {
+    for (Field field : type.getDeclaredFields()) {
+      GuiWidgetElement element = field.getAnnotation(GuiWidgetElement.class);
+      if (element != null) {
+        return element.parentId();
+      }
+    }
+    return null;
+  }
+
+  @GuiPlugin
+  @Getter
+  @Setter
+  public static class TableSample {
+    @GuiWidgetElement(
+        id = "add",
+        order = "10",
+        type = GuiElementType.BUTTON,
+        label = "Add",
+        parentId = TABBED_PARENT,
+        group = "Rows",
+        groupOrder = "10",
+        groupType = GuiWidgetGroupType.TABS)
+    public void addRow(TableSample sample) {
+      if (sample.getRows() == null) {
+        sample.setRows(new ArrayList<>());
+      }
+      sample.getRows().add(new SampleRow("added", true, SampleKind.LEFT));
+    }
+
+    @GuiWidgetElement(
+        id = "rows",
+        order = "20",
+        type = GuiElementType.TABLE,
+        label = "Rows",
+        parentId = TABBED_PARENT,
+        group = "Rows",
+        groupOrder = "10",
+        groupType = GuiWidgetGroupType.TABS,
+        tableRows = 5)
+    private List<SampleRow> rows = new ArrayList<>();
+
+    @GuiWidgetElement(
+        id = "more",
+        order = "30",
+        type = GuiElementType.TABLE,
+        label = "More",
+        parentId = TABBED_PARENT,
+        group = "Rows",
+        groupOrder = "10",
+        groupType = GuiWidgetGroupType.TABS,
+        tableRows = 3)
+    private List<NoteRow> more = new ArrayList<>();
+  }
+
+  @GuiPlugin
+  @Getter
+  @Setter
+  public static class FlatTable {
+    @GuiWidgetElement(
+        id = "only",
+        type = GuiElementType.TABLE,
+        label = "Only",
+        parentId = FLAT_PARENT,
+        tableRows = 4)
+    private List<NoteRow> only = new ArrayList<>();
+  }
+
+  /** Custom {@code toString} so a grid that used it would fail the round 
trip. */
+  public enum SampleKind {
+    LEFT,
+    RIGHT;
+
+    @Override
+    public String toString() {
+      return name().toLowerCase();
+    }
+  }
+
+  @Getter
+  @Setter
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class SampleRow {
+    @GuiTableColumn(order = "10", type = GuiTableColumnType.TEXT, label = 
"Name", variables = false)
+    private String name;
+
+    @GuiTableColumn(order = "20", type = GuiTableColumnType.CHECKBOX, label = 
"Active")
+    private boolean active;
+
+    @GuiTableColumn(order = "30", type = GuiTableColumnType.COMBO, label = 
"Kind")
+    private SampleKind kind;
+  }
+
+  @Getter
+  @Setter
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class NoteRow {
+    @GuiTableColumn(order = "10", type = GuiTableColumnType.TEXT, label = 
"Note")
+    private String note;
+  }
+
+  @GuiPlugin
+  @Getter
+  @Setter
+  public static class ComboHost {
+    static final String PARENT_ID = "GuiCompositeWidgetsTableTest-combo";
+
+    private List<String> choices = new ArrayList<>(List.of("one", "two"));
+
+    public List<String> choices(ILogChannel log, IHopMetadataProvider 
metadataProvider) {
+      return choices;
+    }
+
+    @GuiWidgetElement(
+        id = "items",
+        type = GuiElementType.TABLE,
+        label = "Items",
+        parentId = PARENT_ID,
+        tableRows = 3)
+    private List<ChoiceRow> items = new ArrayList<>();
+  }
+
+  @Getter
+  @Setter
+  @NoArgsConstructor
+  @AllArgsConstructor
+  public static class ChoiceRow {
+    @GuiTableColumn(
+        order = "10",
+        type = GuiTableColumnType.COMBO,
+        label = "Choice",
+        comboValuesMethod = "choices")
+    private String choice;
+
+    @GuiTableColumn(order = "20", type = GuiTableColumnType.COMBO, label = 
"Kind")
+    private SampleKind kind;
+  }
+
+  /** {@code setRoles} and {@code setRole} each have a {@code String} 
overload. */
+  @GuiPlugin
+  @Getter
+  public static class OverloadHost {
+    static final String PARENT_ID = "GuiCompositeWidgetsTableTest-overload";
+
+    private boolean stringListSetterUsed;
+
+    @GuiWidgetElement(
+        id = "roles",
+        type = GuiElementType.TABLE,
+        label = "Roles",
+        parentId = PARENT_ID,
+        tableRows = 3)
+    private List<RoleRow> roles;
+
+    public void setRoles(String ignored) {
+      stringListSetterUsed = true;
+    }
+
+    public void setRoles(List<RoleRow> roles) {
+      this.roles = roles;
+    }
+  }
+
+  public static class RoleRow {
+    @GuiTableColumn(order = "10", type = GuiTableColumnType.COMBO, label = 
"Role")
+    private SampleKind role;
+
+    private boolean stringSetterUsed;
+
+    public RoleRow() {}
+
+    public SampleKind getRole() {
+      return role;
+    }
+
+    public void setRole(String text) {
+      stringSetterUsed = true;
+    }
+
+    public void setRole(SampleKind role) {
+      this.role = role;
+    }
+  }
+}
diff --git 
a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiCompositeWidgets.java 
b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiCompositeWidgets.java
index 6a2dc521e6..42e49758da 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/gui/GuiCompositeWidgets.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/gui/GuiCompositeWidgets.java
@@ -18,6 +18,7 @@
 package org.apache.hop.ui.core.gui;
 
 import java.beans.PropertyDescriptor;
+import java.lang.reflect.Constructor;
 import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -39,6 +40,8 @@ import org.apache.hop.core.exception.HopRuntimeException;
 import org.apache.hop.core.gui.plugin.GuiElementType;
 import org.apache.hop.core.gui.plugin.GuiElements;
 import org.apache.hop.core.gui.plugin.GuiRegistry;
+import org.apache.hop.core.gui.plugin.GuiTableColumnElement;
+import org.apache.hop.core.gui.plugin.GuiTableColumnType;
 import org.apache.hop.core.gui.plugin.GuiWidgetGroupType;
 import org.apache.hop.core.gui.plugin.GuiWidgetGroups;
 import org.apache.hop.core.gui.plugin.GuiWidgetMethodInvoker;
@@ -54,9 +57,11 @@ import 
org.apache.hop.metadata.serializer.xml.DialogOkContent;
 import org.apache.hop.ui.core.ConstUi;
 import org.apache.hop.ui.core.PropsUi;
 import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.widget.ColumnInfo;
 import org.apache.hop.ui.core.widget.ComboVar;
 import org.apache.hop.ui.core.widget.MetaSelectionLine;
 import org.apache.hop.ui.core.widget.PasswordTextVar;
+import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.core.widget.TextVar;
 import org.apache.hop.ui.hopgui.HopGui;
 import org.apache.hop.ui.util.SwtSvgImageUtil;
@@ -81,6 +86,7 @@ import org.eclipse.swt.widgets.Event;
 import org.eclipse.swt.widgets.Group;
 import org.eclipse.swt.widgets.Label;
 import org.eclipse.swt.widgets.Link;
+import org.eclipse.swt.widgets.TableItem;
 import org.eclipse.swt.widgets.Text;
 
 /** This class contains the widgets for the GUI elements of a GUI Plugin */
@@ -127,6 +133,13 @@ public class GuiCompositeWidgets {
    */
   private final Map<Control, Integer> collapsedHeights = new HashMap<>();
 
+  /**
+   * {@link FormData#bottom} of a row hidden by {@link #setWidgetsHidden}. A 
grid attaches to the
+   * bottom of its parent, and {@link FormLayout} still honors that attachment 
while the control is
+   * invisible, so the attachment has to come off until the row is shown again.
+   */
+  private final Map<Control, FormAttachment> collapsedBottoms = new 
HashMap<>();
+
   public GuiCompositeWidgets(IVariables variables) {
     this(variables, 0);
   }
@@ -217,7 +230,12 @@ public class GuiCompositeWidgets {
       boolean useNewLayout) {
     List<WidgetGroup> groups = collectGroups(guiElements);
     if (groups.isEmpty()) {
-      addCompositeWidgets(sourceData, parent, guiElements, lastControl, 
useNewLayout);
+      Control last =
+          addCompositeWidgets(sourceData, parent, guiElements, lastControl, 
useNewLayout);
+      // No children leaves last pointing at the control this composite hangs 
under.
+      if (last != lastControl) {
+        stretchLastTable(last);
+      }
       return;
     }
 
@@ -310,6 +328,9 @@ public class GuiCompositeWidgets {
       for (GuiElements child : group.elements) {
         lastInBox = addCompositeWidgets(sourceData, content, child, lastInBox, 
useNewLayout);
       }
+      if (group.extras.isEmpty()) {
+        stretchLastTable(lastInBox);
+      }
       for (Consumer<Composite> extra : group.extras) {
         extra.accept(content);
       }
@@ -350,6 +371,9 @@ public class GuiCompositeWidgets {
       for (GuiElements child : group.elements) {
         last = addCompositeWidgets(sourceData, composite, child, last, 
useNewLayout);
       }
+      if (group.extras.isEmpty()) {
+        stretchLastTable(last);
+      }
       for (Consumer<Composite> extra : group.extras) {
         extra.accept(composite);
       }
@@ -578,6 +602,10 @@ public class GuiCompositeWidgets {
         continue;
       }
       collapsedHeights.putIfAbsent(control, formData.height);
+      if (formData.bottom != null) {
+        collapsedBottoms.putIfAbsent(control, formData.bottom);
+        formData.bottom = null;
+      }
       formData.height = 0;
       formData.top =
           lastVisible == null ? new FormAttachment(0, 0) : new 
FormAttachment(lastVisible, 0);
@@ -589,10 +617,19 @@ public class GuiCompositeWidgets {
     if (control == null || control.isDisposed()) {
       return;
     }
+    if (!(control.getLayoutData() instanceof FormData formData)) {
+      collapsedHeights.remove(control);
+      collapsedBottoms.remove(control);
+      return;
+    }
     Integer height = collapsedHeights.remove(control);
-    if (height != null && control.getLayoutData() instanceof FormData 
formData) {
+    if (height != null) {
       formData.height = height;
     }
+    FormAttachment bottom = collapsedBottoms.remove(control);
+    if (bottom != null) {
+      formData.bottom = bottom;
+    }
   }
 
   /**
@@ -620,8 +657,14 @@ public class GuiCompositeWidgets {
               && lastVisible != null
               && label != null
               && !label.isDisposed();
+      // A grid sits under its own label. Hanging it on the same control as 
that label overlaps
+      // the header.
+      boolean underLabel =
+          element.getType() == GuiElementType.TABLE && label != null && 
!label.isDisposed();
       if (centeredOnLabel) {
         fdWidget.top = new FormAttachment(label, 0, SWT.CENTER);
+      } else if (underLabel) {
+        fdWidget.top = new FormAttachment(label, PropsUi.getMargin() / 2);
       } else if (lastVisible == null) {
         fdWidget.top = new FormAttachment(0, PropsUi.getMargin());
       } else {
@@ -666,10 +709,19 @@ public class GuiCompositeWidgets {
 
       GuiElementType elementType = guiElements.getType();
 
+      // A grid with no columns was rejected while scanning. Leave the row out.
+      if (elementType == GuiElementType.TABLE && 
!hasTableColumns(guiElements)) {
+        LogChannel.UI.logError(
+            "TABLE widget '" + guiElements.getId() + "' has no columns and is 
not shown");
+        return lastControl;
+      }
+
       // Add the label
       // For metadata, button, and link, the label is handled in the widget 
itself
       // For checkbox in new layout, the label is handled in the widget itself
+      // A grid label spans the row: the table needs the full width in either 
layout.
       //
+      boolean tableLabel = elementType == GuiElementType.TABLE;
       if (StringUtils.isNotEmpty(guiElements.getLabel())
           && elementType != GuiElementType.METADATA
           && elementType != GuiElementType.BUTTON
@@ -677,7 +729,7 @@ public class GuiCompositeWidgets {
           && !(useNewLayout && elementType == GuiElementType.CHECKBOX)) {
         // Use new layout (label above) for ConfigPlugin classes, old layout 
(label on left) for
         // others
-        int labelStyle = useNewLayout ? SWT.LEFT : (SWT.RIGHT | SWT.SINGLE);
+        int labelStyle = useNewLayout || tableLabel ? SWT.LEFT : (SWT.RIGHT | 
SWT.SINGLE);
         label = new Label(parent, labelStyle);
         PropsUi.setLook(label);
         label.setText(Const.NVL(guiElements.getLabel(), ""));
@@ -686,8 +738,8 @@ public class GuiCompositeWidgets {
         }
         FormData fdLabel = new FormData();
         fdLabel.left = new FormAttachment(0, 0);
-        if (useNewLayout) {
-          // New layout: label spans full width
+        if (useNewLayout || tableLabel) {
+          // New layout, and every grid: label spans full width
           fdLabel.right = new FormAttachment(100, 0);
         } else {
           // Old layout: label on left side (up to middle percentage)
@@ -727,6 +779,9 @@ public class GuiCompositeWidgets {
         case LINK:
           control = getLinkControl(parent, guiElements, props, lastControl, 
useNewLayout);
           break;
+        case TABLE:
+          control = getTableControl(sourceObject, parent, guiElements, props, 
lastControl, label);
+          break;
         default:
           break;
       }
@@ -1512,6 +1567,11 @@ public class GuiCompositeWidgets {
           return;
         }
 
+        if (guiElements.getType() == GuiElementType.TABLE) {
+          fillTable(control, sourceData, guiElements);
+          return;
+        }
+
         // What's the value?
         //
         Object value = readFieldValue(sourceData, guiElements);
@@ -1553,8 +1613,8 @@ public class GuiCompositeWidgets {
             }
             line.setText(stringValue);
             break;
-          case BUTTON, LINK:
-            // No data to set
+          case BUTTON, LINK, TABLE:
+            // TABLE is filled above. Button and link have no value.
             break;
           default:
             LogChannel.UI.logError(
@@ -1566,7 +1626,7 @@ public class GuiCompositeWidgets {
             break;
         }
 
-      } else {
+      } else if (guiElements.getType() != GuiElementType.TABLE || 
hasTableColumns(guiElements)) {
         LogChannel.UI.logError(
             "Widget not found to set value on for id: "
                 + guiElements.getId()
@@ -1650,6 +1710,11 @@ public class GuiCompositeWidgets {
           return;
         }
 
+        if (guiElements.getType() == GuiElementType.TABLE) {
+          readTable(control, sourceData, guiElements);
+          return;
+        }
+
         // What's the value?
         //
         Object value = null;
@@ -1681,8 +1746,8 @@ public class GuiCompositeWidgets {
             MetaSelectionLine line = (MetaSelectionLine) control;
             value = line.getText();
             break;
-          case BUTTON, LINK:
-            // No data to retrieve from widget
+          case BUTTON, LINK, TABLE:
+            // TABLE is read above. Button and link have no value.
             break;
           default:
             LogChannel.UI.logError(
@@ -1803,7 +1868,7 @@ public class GuiCompositeWidgets {
           e.printStackTrace();
         }
 
-      } else {
+      } else if (guiElements.getType() != GuiElementType.TABLE || 
hasTableColumns(guiElements)) {
         LogChannel.UI.logError(
             "Widget not found to set value on for id: "
                 + guiElements.getId()
@@ -1883,6 +1948,367 @@ public class GuiCompositeWidgets {
     }
   }
 
+  private boolean hasTableColumns(GuiElements guiElements) {
+    return guiElements.getTableRowClass() != null
+        && guiElements.getTableColumns() != null
+        && !guiElements.getTableColumns().isEmpty();
+  }
+
+  /**
+   * The last grid in a parent keeps the row height used when the dialog is 
packed, and also
+   * attaches to the bottom so a stretched tab gives it the space that is left.
+   */
+  private void stretchLastTable(Control last) {
+    if (last instanceof TableView && last.getLayoutData() instanceof FormData 
formData) {
+      formData.bottom = new FormAttachment(100, 0);
+    }
+  }
+
+  private Control getTableControl(
+      Object sourceObject,
+      Composite parent,
+      GuiElements guiElements,
+      PropsUi props,
+      Control lastControl,
+      Label label) {
+    List<GuiTableColumnElement> columns = guiElements.getTableColumns();
+    ColumnInfo[] infos = new ColumnInfo[columns.size()];
+    for (int i = 0; i < columns.size(); i++) {
+      infos[i] = columnInfo(sourceObject, columns.get(i));
+    }
+
+    TableView tableView =
+        new TableView(
+            variables, parent, SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI, 
infos, 1, null, props);
+    tableView.addModifyListener(
+        event -> notifyWidgetModified(new Event(), tableView, 
guiElements.getId()));
+    if (StringUtils.isNotEmpty(guiElements.getToolTip())) {
+      tableView.getTable().setToolTipText(guiElements.getToolTip());
+    }
+    widgetsMap.put(guiElements.getId(), tableView);
+
+    FormData formData = new FormData();
+    formData.left = new FormAttachment(0, 0);
+    formData.right = new FormAttachment(100, 0);
+    if (label != null) {
+      formData.top = new FormAttachment(label, PropsUi.getMargin() / 2);
+    } else if (lastControl != null) {
+      formData.top = new FormAttachment(lastControl, PropsUi.getMargin());
+    } else {
+      formData.top = new FormAttachment(0, PropsUi.getMargin());
+    }
+    formData.height = preferredTableHeight(tableView, props, 
guiElements.getTableRows());
+    tableView.setLayoutData(formData);
+    return tableView;
+  }
+
+  private ColumnInfo columnInfo(Object sourceObject, GuiTableColumnElement 
column) {
+    ColumnInfo info;
+    switch (column.getType()) {
+      case CHECKBOX:
+        info =
+            new ColumnInfo(
+                column.getLabel(), ColumnInfo.COLUMN_TYPE_CCOMBO, new String[] 
{"Y", "N"}, true);
+        info.setUsingVariables(false);
+        break;
+      case COMBO:
+        if (column.getFieldClass() != null && column.getFieldClass().isEnum()) 
{
+          info =
+              new ColumnInfo(
+                  column.getLabel(),
+                  ColumnInfo.COLUMN_TYPE_CCOMBO,
+                  enumNames(column.getFieldClass()),
+                  true);
+          info.setUsingVariables(false);
+        } else {
+          String[] items = new String[0];
+          if (StringUtils.isNotEmpty(column.getComboValuesMethod())) {
+            items = getComboItems(sourceObject, column.getComboValuesMethod());
+          }
+          info = new ColumnInfo(column.getLabel(), 
ColumnInfo.COLUMN_TYPE_CCOMBO, items, false);
+          info.setUsingVariables(column.isVariables());
+        }
+        break;
+      default:
+        info = new ColumnInfo(column.getLabel(), ColumnInfo.COLUMN_TYPE_TEXT, 
false);
+        info.setUsingVariables(column.isVariables());
+        info.setPasswordField(column.isPassword());
+        break;
+    }
+    if (StringUtils.isNotEmpty(column.getToolTip())) {
+      info.setToolTip(column.getToolTip());
+    }
+    if (column.getWidth() > 0) {
+      info.setWidth(column.getWidth());
+    }
+    return info;
+  }
+
+  private String[] enumNames(Class<?> fieldClass) {
+    Object[] constants = fieldClass.getEnumConstants();
+    String[] names = new String[constants.length];
+    for (int i = 0; i < constants.length; i++) {
+      names[i] = ((Enum<?>) constants[i]).name();
+    }
+    return names;
+  }
+
+  private int preferredTableHeight(TableView tableView, PropsUi props, int 
rows) {
+    int rowCount = Math.max(1, rows);
+    int itemHeight = tableView.getTable().getItemHeight();
+    if (itemHeight <= 0) {
+      itemHeight = (int) Math.ceil(22 * props.getZoomFactor());
+    }
+    int header = tableView.getTable().getHeaderHeight();
+    if (header <= 0) {
+      header = itemHeight;
+    }
+    int toolbarHeight = 0;
+    Control toolbar = tableView.getToolbar();
+    if (toolbar != null && !toolbar.isDisposed()) {
+      Point size = toolbar.computeSize(SWT.DEFAULT, SWT.DEFAULT, true);
+      toolbarHeight = Math.max(0, size.y);
+    }
+    return toolbarHeight + header + (rowCount * itemHeight) + 
PropsUi.getMargin();
+  }
+
+  private void fillTable(Control control, Object sourceData, GuiElements 
guiElements) {
+    if (!(control instanceof TableView tableView) || 
!hasTableColumns(guiElements)) {
+      return;
+    }
+    List<GuiTableColumnElement> columns = guiElements.getTableColumns();
+    // String combo items are resolved again on every fill, including the 
refresh after a BUTTON.
+    refreshStringComboColumns(tableView, sourceData, columns);
+
+    Object raw = readFieldValue(sourceData, guiElements);
+    List<?> values;
+    if (raw instanceof List<?> list) {
+      values = list;
+    } else {
+      if (raw != null) {
+        LogChannel.UI.logError(
+            "TABLE widget '" + guiElements.getId() + "' is not a List and is 
shown empty");
+      }
+      values = List.of();
+    }
+
+    tableView.removeAll();
+    while (tableView.getItemCount() < values.size()) {
+      new TableItem(tableView.getTable(), SWT.NONE);
+    }
+    for (int rowIndex = 0; rowIndex < values.size(); rowIndex++) {
+      Object row = values.get(rowIndex);
+      if (row == null) {
+        continue;
+      }
+      TableItem item = tableView.getTable().getItem(rowIndex);
+      for (int columnIndex = 0; columnIndex < columns.size(); columnIndex++) {
+        item.setText(columnIndex + 1, cellText(row, columns.get(columnIndex)));
+      }
+    }
+    tableView.optimizeTableView();
+  }
+
+  private void refreshStringComboColumns(
+      TableView tableView, Object sourceData, List<GuiTableColumnElement> 
columns) {
+    ColumnInfo[] infos = tableView.getColumns();
+    for (int i = 0; i < columns.size() && i < infos.length; i++) {
+      GuiTableColumnElement column = columns.get(i);
+      if (column.getType() == GuiTableColumnType.COMBO
+          && column.getFieldClass() != null
+          && !column.getFieldClass().isEnum()
+          && StringUtils.isNotEmpty(column.getComboValuesMethod())) {
+        infos[i].setComboValues(getComboItems(sourceData, 
column.getComboValuesMethod()));
+      }
+    }
+  }
+
+  private String cellText(Object row, GuiTableColumnElement column) {
+    Object value = readRowValue(row, column);
+    if (value == null) {
+      return "";
+    }
+    if (value instanceof Boolean flag) {
+      return flag ? "Y" : "N";
+    }
+    if (value instanceof Enum<?> enumValue) {
+      return enumValue.name();
+    }
+    return Const.NVL(value.toString(), "");
+  }
+
+  private Object readRowValue(Object row, GuiTableColumnElement column) {
+    try {
+      if (StringUtils.isNotEmpty(column.getGetterMethod())) {
+        Method getter = row.getClass().getMethod(column.getGetterMethod());
+        return getter.invoke(row);
+      }
+    } catch (Exception e) {
+      // Try the bean property below.
+    }
+    try {
+      Method reader = new PropertyDescriptor(column.getFieldName(), 
row.getClass()).getReadMethod();
+      if (reader == null) {
+        return null;
+      }
+      return reader.invoke(row);
+    } catch (Exception e) {
+      LogChannel.UI.logError("Unable to read table column '" + column.getId() 
+ "'", e);
+      return null;
+    }
+  }
+
+  private void readTable(Control control, Object sourceData, GuiElements 
guiElements) {
+    if (!(control instanceof TableView tableView) || 
!hasTableColumns(guiElements)) {
+      return;
+    }
+    Class<?> rowClass = guiElements.getTableRowClass();
+    Constructor<?> constructor;
+    try {
+      constructor = rowClass.getConstructor();
+    } catch (NoSuchMethodException e) {
+      LogChannel.UI.logError(
+          "TABLE widget '"
+              + guiElements.getId()
+              + "' row class "
+              + rowClass.getName()
+              + " needs a public no-arg constructor",
+          e);
+      return;
+    }
+
+    List<GuiTableColumnElement> columns = guiElements.getTableColumns();
+    List<Object> built = new ArrayList<>();
+    try {
+      for (TableItem item : tableView.getNonEmptyItems()) {
+        Object row = constructor.newInstance();
+        for (int columnIndex = 0; columnIndex < columns.size(); columnIndex++) 
{
+          writeCell(row, columns.get(columnIndex), item.getText(columnIndex + 
1));
+        }
+        built.add(row);
+      }
+    } catch (Exception e) {
+      LogChannel.UI.logError(
+          "Unable to read rows of TABLE widget '" + guiElements.getId() + "'", 
e);
+      return;
+    }
+
+    Object raw = readFieldValue(sourceData, guiElements);
+    if (raw instanceof List<?> existing) {
+      try {
+        @SuppressWarnings("unchecked")
+        List<Object> rows = (List<Object>) existing;
+        rows.clear();
+        rows.addAll(built);
+        return;
+      } catch (UnsupportedOperationException e) {
+        LogChannel.UI.logError(
+            "TABLE field '"
+                + guiElements.getFieldName()
+                + "' is not a modifiable List, replacing it",
+            e);
+      }
+    } else if (raw != null) {
+      LogChannel.UI.logError(
+          "TABLE widget '" + guiElements.getId() + "' is not a List, replacing 
the value");
+    }
+    writeList(sourceData, guiElements, built);
+  }
+
+  private void writeCell(Object row, GuiTableColumnElement column, String 
text) {
+    Method setter = findRowSetter(row, column);
+    if (setter == null) {
+      LogChannel.UI.logError("No setter for table column '" + column.getId() + 
"'");
+      return;
+    }
+    Class<?> parameterType = setter.getParameterTypes()[0];
+    Object value;
+    if (parameterType == String.class) {
+      value = text == null ? "" : text;
+    } else if (parameterType == boolean.class || parameterType == 
Boolean.class) {
+      value = "Y".equals(text);
+    } else if (parameterType.isEnum()) {
+      if (StringUtils.isEmpty(text)) {
+        return;
+      }
+      try {
+        value = enumConstant(parameterType, text);
+      } catch (IllegalArgumentException e) {
+        LogChannel.UI.logError(
+            "Ignoring value '"
+                + text
+                + "' for table column '"
+                + column.getId()
+                + "': not a constant of "
+                + parameterType.getName());
+        return;
+      }
+    } else {
+      LogChannel.UI.logError(
+          "Table column '" + column.getId() + "' has unsupported type " + 
parameterType.getName());
+      return;
+    }
+    try {
+      setter.invoke(row, value);
+    } catch (Exception e) {
+      LogChannel.UI.logError("Unable to set table column '" + column.getId() + 
"'", e);
+    }
+  }
+
+  @SuppressWarnings({"unchecked", "rawtypes"})
+  private Object enumConstant(Class<?> parameterType, String text) {
+    return Enum.valueOf((Class) parameterType, text);
+  }
+
+  private Method findRowSetter(Object row, GuiTableColumnElement column) {
+    try {
+      Method setter =
+          methodWithParameter(row.getClass(), column.getSetterMethod(), 
column.getFieldClass());
+      if (setter != null) {
+        return setter;
+      }
+      return new PropertyDescriptor(column.getFieldName(), 
row.getClass()).getWriteMethod();
+    } catch (Exception e) {
+      LogChannel.UI.logError("No setter for table column '" + column.getId() + 
"'", e);
+      return null;
+    }
+  }
+
+  private boolean writeList(Object sourceData, GuiElements guiElements, 
List<Object> rows) {
+    try {
+      Method setter =
+          methodWithParameter(
+              sourceData.getClass(), guiElements.getSetterMethod(), 
guiElements.getFieldClass());
+      if (setter == null) {
+        setter =
+            new PropertyDescriptor(guiElements.getFieldName(), 
sourceData.getClass())
+                .getWriteMethod();
+      }
+      if (setter == null) {
+        LogChannel.UI.logError(
+            "No setter for TABLE field '" + guiElements.getFieldName() + "', 
rows not applied");
+        return false;
+      }
+      setter.invoke(sourceData, rows);
+      return true;
+    } catch (Exception e) {
+      LogChannel.UI.logError("Unable to set TABLE field '" + 
guiElements.getFieldName() + "'", e);
+      return false;
+    }
+  }
+
+  /** Public method with this name whose single parameter is {@code 
parameterType}. */
+  private Method methodWithParameter(Class<?> type, String name, Class<?> 
parameterType) {
+    if (StringUtils.isEmpty(name) || parameterType == null) {
+      return null;
+    }
+    try {
+      return type.getMethod(name, parameterType);
+    } catch (NoSuchMethodException e) {
+      return null;
+    }
+  }
+
   public void setComboValues(String widgetId, String[] fieldNames) {
     Control control = widgetsMap.get(widgetId);
     if (control instanceof Combo combo) {

Reply via email to