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) {