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

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


The following commit(s) were added to refs/heads/main by this push:
     new 43103fbefe Fixes #8589 : Lay out the dbt action dialog on tabs (#8594)
43103fbefe is described below

commit 43103fbefefa3c7cfa95476a972d701c3ef9b368
Author: Bart Maertens <[email protected]>
AuthorDate: Fri Sep 25 11:22:31 2026 +0200

    Fixes #8589 : Lay out the dbt action dialog on tabs (#8594)
    
    Build the dialog from @GuiWidgetElement annotations on ActionDbt instead of 
hand-laid FormAttachment rows: dbt project, Selection, Execution, and a 
Variables tab that carries the two name/value tables through 
registerExtraGroup. Serialization keys are unchanged.
    
    The generated operation combo cannot be read-only, so OK refuses an 
unrecognised operation rather than falling back to dbt run.
---
 .../modules/ROOT/pages/workflow/actions/dbt.adoc   |  28 +-
 .../apache/hop/workflow/actions/dbt/ActionDbt.java | 235 +++++++-----
 .../hop/workflow/actions/dbt/ActionDbtDialog.java  | 397 ++++++++-------------
 .../actions/dbt/messages/messages_en_US.properties |  46 ++-
 .../actions/dbt/messages/messages_pt_BR.properties |  29 +-
 .../workflow/actions/dbt/ActionDbtDialogTest.java  | 377 +++++++++++++++++++
 .../actions/dbt/ActionDbtWidgetLayoutTest.java     | 138 +++++++
 7 files changed, 861 insertions(+), 389 deletions(-)

diff --git a/docs/hop-user-manual/modules/ROOT/pages/workflow/actions/dbt.adoc 
b/docs/hop-user-manual/modules/ROOT/pages/workflow/actions/dbt.adoc
index 773c5ede21..9b1b580c47 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/workflow/actions/dbt.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/workflow/actions/dbt.adoc
@@ -42,19 +42,43 @@ To emit lineage, the `openlineage-dbt` package must be 
installed as well; it pro
 
 == Options
 
+The options are organized on four tabs. The action name is above them and 
applies to all of them.
+
+=== dbt project
+
 [options="header", width="90%", cols="1,3"]
 |===
 |Option|Description
-|Action name|The name of the workflow action.
 |dbt project|The xref:metadata-types/dbt-project.adoc[dbt Project] metadata 
object to run against.
 |Operation|The dbt sub-command to run: `run`, `build`, `test`, `seed`, 
`snapshot`, `compile` or `source-freshness`.
 |Target|The dbt target (`--target`). Overrides the default target of the dbt 
project when set.
+|===
+
+=== Selection
+
+[options="header", width="90%", cols="1,3"]
+|===
+|Option|Description
 |--select|Passed to dbt as `--select`, using dbt's node selection syntax. 
Several selectors can be given in one value, separated by spaces.
 |--exclude|Passed to dbt as `--exclude`.
+|--full-refresh|Passed to dbt as `--full-refresh`. dbt only accepts the flag 
for `run`, `build` and `seed`, so it is left out for the other operations.
+|===
+
+=== Execution
+
+[options="header", width="90%", cols="1,3"]
+|===
+|Option|Description
 |--threads|Passed to dbt as `--threads`. Left empty, dbt uses the thread count 
from `profiles.yml`.
 |Timeout in seconds|Wall-clock limit for the dbt run. When it is reached, dbt 
and the processes it started are stopped and the action fails. Empty or `0` 
waits for dbt however long it takes.
-|--full-refresh|Passed to dbt as `--full-refresh`. dbt only accepts the flag 
for `run`, `build` and `seed`, so it is left out for the other operations.
 |Emit OpenLineage|Run dbt through `dbt-ol` and point it at the collector Hop's 
own lineage sink uses. See <<Lineage>>.
+|===
+
+=== Variables
+
+[options="header", width="90%", cols="1,3"]
+|===
+|Option|Description
 |Variables (--vars)|Name/value pairs passed to dbt as `--vars`. See <<dbt 
variables>>.
 |Environment variables|Name/value pairs added to the environment of the dbt 
process. This is where credentials belong: the values are secret-resolved and 
masked in the log.
 |===
diff --git 
a/plugins/actions/dbt/src/main/java/org/apache/hop/workflow/actions/dbt/ActionDbt.java
 
b/plugins/actions/dbt/src/main/java/org/apache/hop/workflow/actions/dbt/ActionDbt.java
index 5629117dbb..7422cd3725 100644
--- 
a/plugins/actions/dbt/src/main/java/org/apache/hop/workflow/actions/dbt/ActionDbt.java
+++ 
b/plugins/actions/dbt/src/main/java/org/apache/hop/workflow/actions/dbt/ActionDbt.java
@@ -26,15 +26,23 @@ import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
+import lombok.Getter;
+import lombok.Setter;
 import org.apache.hop.core.CheckResult;
 import org.apache.hop.core.ICheckResult;
 import org.apache.hop.core.Result;
 import org.apache.hop.core.annotations.Action;
 import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.gui.plugin.GuiElementType;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+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.util.Utils;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.lineage.LineageVariables;
@@ -59,7 +67,26 @@ import org.apache.hop.workflow.action.IAction;
     categoryDescription = 
"i18n:org.apache.hop.workflow:ActionCategory.Category.Scripting",
     keywords = "i18n::ActionDbt.keyword",
     documentationUrl = "/workflow/actions/dbt.html")
+@GuiPlugin
+@Getter
+@Setter
 public class ActionDbt extends ActionBase implements IAction {
+  public static final String GUI_PLUGIN_ELEMENT_PARENT_ID = 
"DBT_ACTION_DIALOG_OPTIONS";
+
+  public static final String WIDGET_PROJECT = "DBT_PROJECT";
+  public static final String WIDGET_OPERATION = "DBT_OPERATION";
+  public static final String WIDGET_TARGET = "DBT_TARGET";
+  public static final String WIDGET_SELECT = "DBT_SELECT";
+  public static final String WIDGET_EXCLUDE = "DBT_EXCLUDE";
+  public static final String WIDGET_FULL_REFRESH = "DBT_FULL_REFRESH";
+  public static final String WIDGET_THREADS = "DBT_THREADS";
+  public static final String WIDGET_TIMEOUT = "DBT_TIMEOUT";
+  public static final String WIDGET_EMIT_OPENLINEAGE = "DBT_EMIT_OPENLINEAGE";
+
+  public static final String GROUP_PROJECT = "i18n::ActionDbt.Group.Project";
+  public static final String GROUP_SELECTION = 
"i18n::ActionDbt.Group.Selection";
+  public static final String GROUP_EXECUTION = 
"i18n::ActionDbt.Group.Execution";
+  public static final String GROUP_VARIABLES = 
"i18n::ActionDbt.Group.Variables";
 
   /** Hop variables read from the OpenLineage sink configuration when 
stitching dbt's lineage. */
   private static final String VAR_OPENLINEAGE_URL = 
"HOP_LINEAGE_OPENLINEAGE_URL";
@@ -82,34 +109,128 @@ public class ActionDbt extends ActionBase implements 
IAction {
   private static final long KILL_GRACE_MS = 5_000L;
 
   /** Name of the referenced dbt-project metadata object. */
+  @GuiWidgetElement(
+      id = WIDGET_PROJECT,
+      order = "0100",
+      type = GuiElementType.METADATA,
+      metadata = DbtProject.class,
+      label = "i18n::ActionDbt.Project.Label",
+      toolTip = "i18n::ActionDbt.Project.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      group = GROUP_PROJECT,
+      groupOrder = "10",
+      groupType = GuiWidgetGroupType.TABS)
   @HopMetadataProperty(key = "dbt_project")
   private String dbtProjectName;
 
+  @GuiWidgetElement(
+      id = WIDGET_OPERATION,
+      order = "0200",
+      type = GuiElementType.COMBO,
+      variables = false,
+      comboValuesMethod = "getOperationCodes",
+      label = "i18n::ActionDbt.Operation.Label",
+      toolTip = "i18n::ActionDbt.Operation.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      group = GROUP_PROJECT,
+      groupOrder = "10",
+      groupType = GuiWidgetGroupType.TABS)
   @HopMetadataProperty(key = "operation")
   private String operation;
 
+  @GuiWidgetElement(
+      id = WIDGET_TARGET,
+      order = "0300",
+      type = GuiElementType.TEXT,
+      label = "i18n::ActionDbt.Target.Label",
+      toolTip = "i18n::ActionDbt.Target.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      group = GROUP_PROJECT,
+      groupOrder = "10",
+      groupType = GuiWidgetGroupType.TABS)
+  @HopMetadataProperty(key = "target")
+  private String target;
+
+  @GuiWidgetElement(
+      id = WIDGET_SELECT,
+      order = "0400",
+      type = GuiElementType.TEXT,
+      label = "i18n::ActionDbt.Select.Label",
+      toolTip = "i18n::ActionDbt.Select.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      group = GROUP_SELECTION,
+      groupOrder = "20",
+      groupType = GuiWidgetGroupType.TABS)
   @HopMetadataProperty(key = "select")
   private String select;
 
+  @GuiWidgetElement(
+      id = WIDGET_EXCLUDE,
+      order = "0500",
+      type = GuiElementType.TEXT,
+      label = "i18n::ActionDbt.Exclude.Label",
+      toolTip = "i18n::ActionDbt.Exclude.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      group = GROUP_SELECTION,
+      groupOrder = "20",
+      groupType = GuiWidgetGroupType.TABS)
   @HopMetadataProperty(key = "exclude")
   private String exclude;
 
-  @HopMetadataProperty(key = "target")
-  private String target;
-
-  @HopMetadataProperty(key = "threads")
-  private String threads;
-
+  @GuiWidgetElement(
+      id = WIDGET_FULL_REFRESH,
+      order = "0600",
+      type = GuiElementType.CHECKBOX,
+      label = "i18n::ActionDbt.FullRefresh.Label",
+      toolTip = "i18n::ActionDbt.FullRefresh.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      group = GROUP_SELECTION,
+      groupOrder = "20",
+      groupType = GuiWidgetGroupType.TABS)
   @HopMetadataProperty(key = "full_refresh")
   private boolean fullRefresh;
 
-  @HopMetadataProperty(key = "emit_openlineage")
-  private boolean emitOpenLineage;
+  @GuiWidgetElement(
+      id = WIDGET_THREADS,
+      order = "0700",
+      type = GuiElementType.TEXT,
+      label = "i18n::ActionDbt.Threads.Label",
+      toolTip = "i18n::ActionDbt.Threads.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      group = GROUP_EXECUTION,
+      groupOrder = "30",
+      groupType = GuiWidgetGroupType.TABS)
+  @HopMetadataProperty(key = "threads")
+  private String threads;
 
   /** Optional wall-clock limit in seconds; blank or 0 waits for dbt 
indefinitely. */
+  @GuiWidgetElement(
+      id = WIDGET_TIMEOUT,
+      order = "0800",
+      type = GuiElementType.TEXT,
+      label = "i18n::ActionDbt.Timeout.Label",
+      toolTip = "i18n::ActionDbt.Timeout.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      group = GROUP_EXECUTION,
+      groupOrder = "30",
+      groupType = GuiWidgetGroupType.TABS)
   @HopMetadataProperty(key = "timeout")
   private String timeout;
 
+  @GuiWidgetElement(
+      id = WIDGET_EMIT_OPENLINEAGE,
+      order = "0900",
+      type = GuiElementType.CHECKBOX,
+      label = "i18n::ActionDbt.EmitOpenLineage.Label",
+      toolTip = "i18n::ActionDbt.EmitOpenLineage.Tooltip",
+      parentId = GUI_PLUGIN_ELEMENT_PARENT_ID,
+      group = GROUP_EXECUTION,
+      groupOrder = "30",
+      groupType = GuiWidgetGroupType.TABS)
+  @HopMetadataProperty(key = "emit_openlineage")
+  private boolean emitOpenLineage;
+
+  /** Rendered as the two tables on the Variables tab, not as annotated 
widgets. */
   @HopMetadataProperty(groupKey = "vars", key = "var")
   private List<DbtNameValue> vars;
 
@@ -127,6 +248,14 @@ public class ActionDbt extends ActionBase implements 
IAction {
     this.envVars = new ArrayList<>();
   }
 
+  /**
+   * The dbt sub-commands the operation combo offers. The signature is the one 
{@code
+   * GuiCompositeWidgets} looks up for {@code comboValuesMethod}.
+   */
+  public List<String> getOperationCodes(ILogChannel log, IHopMetadataProvider 
metadataProvider) {
+    return 
Arrays.stream(DbtOperation.values()).map(DbtOperation::getCode).toList();
+  }
+
   private static List<DbtNameValue> copyPairs(List<DbtNameValue> source) {
     List<DbtNameValue> copy = new ArrayList<>();
     if (source != null) {
@@ -633,94 +762,4 @@ public class ActionDbt extends ActionBase implements 
IAction {
   public boolean isUnconditional() {
     return false;
   }
-
-  // ----- getters / setters -----
-
-  public String getDbtProjectName() {
-    return dbtProjectName;
-  }
-
-  public void setDbtProjectName(String dbtProjectName) {
-    this.dbtProjectName = dbtProjectName;
-  }
-
-  public String getOperation() {
-    return operation;
-  }
-
-  public void setOperation(String operation) {
-    this.operation = operation;
-  }
-
-  public String getSelect() {
-    return select;
-  }
-
-  public void setSelect(String select) {
-    this.select = select;
-  }
-
-  public String getExclude() {
-    return exclude;
-  }
-
-  public void setExclude(String exclude) {
-    this.exclude = exclude;
-  }
-
-  public String getTarget() {
-    return target;
-  }
-
-  public void setTarget(String target) {
-    this.target = target;
-  }
-
-  public String getThreads() {
-    return threads;
-  }
-
-  public void setThreads(String threads) {
-    this.threads = threads;
-  }
-
-  public boolean isFullRefresh() {
-    return fullRefresh;
-  }
-
-  public void setFullRefresh(boolean fullRefresh) {
-    this.fullRefresh = fullRefresh;
-  }
-
-  public boolean isEmitOpenLineage() {
-    return emitOpenLineage;
-  }
-
-  public void setEmitOpenLineage(boolean emitOpenLineage) {
-    this.emitOpenLineage = emitOpenLineage;
-  }
-
-  public String getTimeout() {
-    return timeout;
-  }
-
-  public void setTimeout(String timeout) {
-    this.timeout = timeout;
-  }
-
-  public List<DbtNameValue> getVars() {
-    return vars;
-  }
-
-  public void setVars(List<DbtNameValue> vars) {
-    this.vars = vars;
-  }
-
-  public List<DbtNameValue> getEnvVars() {
-    return envVars;
-  }
-
-  public void setEnvVars(List<DbtNameValue> envVars) {
-    this.envVars = envVars;
-  }
 }
diff --git 
a/plugins/actions/dbt/src/main/java/org/apache/hop/workflow/actions/dbt/ActionDbtDialog.java
 
b/plugins/actions/dbt/src/main/java/org/apache/hop/workflow/actions/dbt/ActionDbtDialog.java
index 64c6bef581..0e9f38bc6d 100644
--- 
a/plugins/actions/dbt/src/main/java/org/apache/hop/workflow/actions/dbt/ActionDbtDialog.java
+++ 
b/plugins/actions/dbt/src/main/java/org/apache/hop/workflow/actions/dbt/ActionDbtDialog.java
@@ -17,52 +17,55 @@
 
 package org.apache.hop.workflow.actions.dbt;
 
+import java.util.List;
 import org.apache.hop.core.Const;
-import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.ui.core.PropsUi;
-import org.apache.hop.ui.core.dialog.ErrorDialog;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.dialog.MessageBox;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgets;
+import org.apache.hop.ui.core.gui.GuiCompositeWidgetsAdapter;
 import org.apache.hop.ui.core.widget.ColumnInfo;
-import org.apache.hop.ui.core.widget.MetaSelectionLine;
 import org.apache.hop.ui.core.widget.TableView;
-import org.apache.hop.ui.core.widget.TextVar;
-import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.apache.hop.ui.workflow.action.ActionDialog;
-import org.apache.hop.ui.workflow.dialog.WorkflowDialog;
 import org.apache.hop.workflow.WorkflowMeta;
 import org.apache.hop.workflow.action.IAction;
 import org.apache.hop.workflow.action.IActionDialog;
 import org.eclipse.swt.SWT;
+import org.eclipse.swt.events.ModifyListener;
 import org.eclipse.swt.layout.FormAttachment;
 import org.eclipse.swt.layout.FormData;
 import org.eclipse.swt.layout.FormLayout;
-import org.eclipse.swt.widgets.Button;
 import org.eclipse.swt.widgets.Combo;
-import org.eclipse.swt.widgets.Display;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
 import org.eclipse.swt.widgets.Label;
 import org.eclipse.swt.widgets.Shell;
 import org.eclipse.swt.widgets.TableItem;
-import org.eclipse.swt.widgets.Text;
 
+/**
+ * The fields of this dialog are built from the {@code @GuiWidgetElement} 
annotations on {@link
+ * ActionDbt}, which lays them out on the "dbt project", "Selection" and 
"Execution" tabs. The two
+ * name/value tables are not annotated widgets, so they are added to a fourth 
tab through {@link
+ * GuiCompositeWidgets#registerExtraGroup}.
+ */
 public class ActionDbtDialog extends ActionDialog implements IActionDialog {
 
   private static final Class<?> PKG = ActionDbt.class;
 
+  /**
+   * Height of each name/value table. FormLayout reads it when the shell is 
packed, which is what
+   * decides the size the dialog opens at; the bottom attachment on the second 
table then takes over
+   * when the dialog is resized.
+   */
+  private static final int TABLE_HEIGHT = 150;
+
   private ActionDbt action;
   private boolean changed;
 
-  private Text wName;
-  private MetaSelectionLine<DbtProject> wProject;
-  private Combo wOperation;
-  private TextVar wTarget;
-  private TextVar wSelect;
-  private TextVar wExclude;
-  private TextVar wThreads;
-  private TextVar wTimeout;
-  private Button wFullRefresh;
-  private Button wEmitOpenLineage;
+  private GuiCompositeWidgets widgets;
   private TableView wVars;
   private TableView wEnvVars;
 
@@ -77,256 +80,135 @@ public class ActionDbtDialog extends ActionDialog 
implements IActionDialog {
 
   @Override
   public IAction open() {
-    Shell parent = getParent();
-
-    shell = new Shell(parent, SWT.DIALOG_TRIM | SWT.MIN | SWT.MAX | 
SWT.RESIZE);
-    PropsUi.setLook(shell);
-
-    // Create MetaSelectionLine BEFORE calling WorkflowDialog.setShellImage() 
to avoid NPE
-    // The VFS files-cache gets consumed/cleared when loading the shell image,
-    // so we need to initialize MetaSelectionLine (which also requires VFS) 
first.
-    wProject =
-        new MetaSelectionLine<>(
-            variables,
-            metadataProvider,
-            DbtProject.class,
-            shell,
-            SWT.BORDER,
-            BaseMessages.getString(PKG, "ActionDbtDialog.Project.Label"),
-            null);
-    PropsUi.setLook(wProject);
-
-    // NOW it's safe to load the shell image
-    WorkflowDialog.setShellImage(shell, action);
-
+    createShell(BaseMessages.getString(PKG, "ActionDbtDialog.Title"), action);
     changed = action.hasChanged();
 
-    FormLayout formLayout = new FormLayout();
-    formLayout.marginWidth = PropsUi.getFormMargin();
-    formLayout.marginHeight = PropsUi.getFormMargin();
-    shell.setLayout(formLayout);
-    shell.setText(BaseMessages.getString(PKG, "ActionDbtDialog.Title"));
+    buildButtonBar().ok(e -> ok()).cancel(e -> cancel()).build();
+
+    // The tab folder fills everything between the action name line and the 
button bar.
+    //
+    Composite area = new Composite(shell, SWT.NONE);
+    PropsUi.setLook(area);
+    area.setLayout(new FormLayout());
+    FormData fdArea = new FormData();
+    fdArea.left = new FormAttachment(0, 0);
+    fdArea.top = new FormAttachment(wSpacer, margin);
+    fdArea.right = new FormAttachment(100, 0);
+    fdArea.bottom = new FormAttachment(wOk, -2 * margin);
+    area.setLayoutData(fdArea);
+
+    widgets = new GuiCompositeWidgets(variables);
+    widgets.registerExtraGroup(
+        BaseMessages.getString(PKG, "ActionDbt.Group.Variables"), "40", null, 
this::addTables);
+    widgets.setWidgetsListener(
+        new GuiCompositeWidgetsAdapter() {
+          @Override
+          public void widgetModified(
+              GuiCompositeWidgets compositeWidgets, Control changedWidget, 
String widgetId) {
+            if (!loading) {
+              action.setChanged();
+            }
+          }
+        });
+    widgets.createCompositeWidgets(
+        action, null, area, ActionDbt.GUI_PLUGIN_ELEMENT_PARENT_ID, null);
 
-    int margin = PropsUi.getMargin();
-    int middle = props.getMiddlePct();
+    getData();
+    action.setChanged(changed);
+    focusActionName();
 
-    // Action name
-    Label wlName = new Label(shell, SWT.RIGHT);
-    wlName.setText(BaseMessages.getString(PKG, 
"ActionDbtDialog.ActionName.Label"));
-    PropsUi.setLook(wlName);
-    FormData fdlName = new FormData();
-    fdlName.left = new FormAttachment(0, 0);
-    fdlName.right = new FormAttachment(middle, -margin);
-    fdlName.top = new FormAttachment(0, margin);
-    wlName.setLayoutData(fdlName);
-    wName = new Text(shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER);
-    PropsUi.setLook(wName);
-    FormData fdName = new FormData();
-    fdName.left = new FormAttachment(middle, 0);
-    fdName.right = new FormAttachment(100, 0);
-    fdName.top = new FormAttachment(0, margin);
-    wName.setLayoutData(fdName);
+    BaseDialog.defaultShellHandling(shell, c -> ok(), c -> cancel());
 
-    // dbt project (metadata reference)
-    FormData fdProject = new FormData();
-    fdProject.left = new FormAttachment(0, 0);
-    fdProject.right = new FormAttachment(100, 0);
-    fdProject.top = new FormAttachment(wName, margin);
-    wProject.setLayoutData(fdProject);
-    try {
-      wProject.fillItems();
-    } catch (HopException e) {
-      new ErrorDialog(shell, "Error", "Error listing dbt projects", e);
-    }
-
-    // Operation
-    wOperation =
-        addCombo("ActionDbtDialog.Operation.Label", wProject, middle, margin, 
operationCodes());
-    wTarget = addTextVar("ActionDbtDialog.Target.Label", wOperation, middle, 
margin);
-    wSelect = addTextVar("ActionDbtDialog.Select.Label", wTarget, middle, 
margin);
-    wExclude = addTextVar("ActionDbtDialog.Exclude.Label", wSelect, middle, 
margin);
-    wThreads = addTextVar("ActionDbtDialog.Threads.Label", wExclude, middle, 
margin);
-    wTimeout = addTextVar("ActionDbtDialog.Timeout.Label", wThreads, middle, 
margin);
-    wFullRefresh = addCheck("ActionDbtDialog.FullRefresh.Label", wTimeout, 
middle, margin);
-    wEmitOpenLineage =
-        addCheck("ActionDbtDialog.EmitOpenLineage.Label", wFullRefresh, 
middle, margin);
+    return action;
+  }
 
-    // Buttons (built first so the tables can anchor to them)
-    Button wOk = new Button(shell, SWT.PUSH);
-    wOk.setText(BaseMessages.getString(PKG, "ActionDbtDialog.Ok.Button"));
-    wOk.addListener(SWT.Selection, e -> ok());
-    Button wCancel = new Button(shell, SWT.PUSH);
-    wCancel.setText(BaseMessages.getString(PKG, 
"ActionDbtDialog.Cancel.Button"));
-    wCancel.addListener(SWT.Selection, e -> cancel());
-    BaseTransformDialog.positionBottomButtons(shell, new Button[] {wOk, 
wCancel}, margin, null);
+  /** The two name/value tables of the Variables tab, stacked on the tab's own 
composite. */
+  private void addTables(Composite parent) {
+    ModifyListener lsMod = e -> action.setChanged();
 
-    // Vars table
-    Label wlVars = new Label(shell, SWT.LEFT);
-    wlVars.setText(BaseMessages.getString(PKG, "ActionDbtDialog.Vars.Label"));
+    Label wlVars = new Label(parent, SWT.LEFT);
+    wlVars.setText(BaseMessages.getString(PKG, "ActionDbt.Vars.Label"));
     PropsUi.setLook(wlVars);
     FormData fdlVars = new FormData();
     fdlVars.left = new FormAttachment(0, 0);
-    fdlVars.top = new FormAttachment(wEmitOpenLineage, margin);
+    fdlVars.top = new FormAttachment(0, margin);
     wlVars.setLayoutData(fdlVars);
-    wVars = pairTable(wlVars, action.getVars().size(), margin);
-    FormData fdVars = (FormData) wVars.getLayoutData();
-    fdVars.bottom = new FormAttachment(wEmitOpenLineage, 120 + margin);
-    wVars.setLayoutData(fdVars);
-
-    // Env vars table
-    Label wlEnv = new Label(shell, SWT.LEFT);
-    wlEnv.setText(BaseMessages.getString(PKG, 
"ActionDbtDialog.EnvVars.Label"));
-    PropsUi.setLook(wlEnv);
-    FormData fdlEnv = new FormData();
-    fdlEnv.left = new FormAttachment(0, 0);
-    fdlEnv.top = new FormAttachment(wVars, margin);
-    wlEnv.setLayoutData(fdlEnv);
-    wEnvVars = pairTable(wlEnv, action.getEnvVars().size(), margin);
-    FormData fdEnv = (FormData) wEnvVars.getLayoutData();
-    fdEnv.bottom = new FormAttachment(wOk, -2 * margin);
-    wEnvVars.setLayoutData(fdEnv);
-
-    getData();
-
-    BaseTransformDialog.setSize(shell);
-    shell.open();
-    Display display = parent.getDisplay();
-    while (!shell.isDisposed()) {
-      if (!display.readAndDispatch()) {
-        display.sleep();
-      }
-    }
-    return action;
-  }
-
-  private String[] operationCodes() {
-    DbtOperation[] ops = DbtOperation.values();
-    String[] codes = new String[ops.length];
-    for (int i = 0; i < ops.length; i++) {
-      codes[i] = ops[i].getCode();
-    }
-    return codes;
-  }
-
-  private Combo addCombo(
-      String labelKey,
-      org.eclipse.swt.widgets.Control top,
-      int middle,
-      int margin,
-      String[] items) {
-    Label label = new Label(shell, SWT.RIGHT);
-    label.setText(BaseMessages.getString(PKG, labelKey));
-    PropsUi.setLook(label);
-    FormData fdl = new FormData();
-    fdl.left = new FormAttachment(0, 0);
-    fdl.right = new FormAttachment(middle, -margin);
-    fdl.top = new FormAttachment(top, margin);
-    label.setLayoutData(fdl);
-    Combo combo = new Combo(shell, SWT.SINGLE | SWT.LEFT | SWT.BORDER | 
SWT.READ_ONLY);
-    combo.setItems(items);
-    PropsUi.setLook(combo);
-    FormData fd = new FormData();
-    fd.left = new FormAttachment(middle, 0);
-    fd.right = new FormAttachment(100, 0);
-    fd.top = new FormAttachment(top, margin);
-    combo.setLayoutData(fd);
-    return combo;
-  }
 
-  private TextVar addTextVar(
-      String labelKey, org.eclipse.swt.widgets.Control top, int middle, int 
margin) {
-    Label label = new Label(shell, SWT.RIGHT);
-    label.setText(BaseMessages.getString(PKG, labelKey));
-    PropsUi.setLook(label);
-    FormData fdl = new FormData();
-    fdl.left = new FormAttachment(0, 0);
-    fdl.right = new FormAttachment(middle, -margin);
-    fdl.top = new FormAttachment(top, margin);
-    label.setLayoutData(fdl);
-    TextVar text = new TextVar(variables, shell, SWT.SINGLE | SWT.LEFT | 
SWT.BORDER);
-    PropsUi.setLook(text);
-    FormData fd = new FormData();
-    fd.left = new FormAttachment(middle, 0);
-    fd.right = new FormAttachment(100, 0);
-    fd.top = new FormAttachment(top, margin);
-    text.setLayoutData(fd);
-    return text;
-  }
+    wVars = pairTable(parent, action.getVars().size(), lsMod);
+    FormData fdVars = new FormData();
+    fdVars.left = new FormAttachment(0, 0);
+    fdVars.top = new FormAttachment(wlVars, margin);
+    fdVars.right = new FormAttachment(100, 0);
+    fdVars.height = TABLE_HEIGHT;
+    wVars.setLayoutData(fdVars);
 
-  private Button addCheck(
-      String labelKey, org.eclipse.swt.widgets.Control top, int middle, int 
margin) {
-    Label label = new Label(shell, SWT.RIGHT);
-    label.setText(BaseMessages.getString(PKG, labelKey));
-    PropsUi.setLook(label);
-    FormData fdl = new FormData();
-    fdl.left = new FormAttachment(0, 0);
-    fdl.right = new FormAttachment(middle, -margin);
-    fdl.top = new FormAttachment(top, margin);
-    label.setLayoutData(fdl);
-    Button button = new Button(shell, SWT.CHECK);
-    PropsUi.setLook(button);
-    FormData fd = new FormData();
-    fd.left = new FormAttachment(middle, 0);
-    fd.right = new FormAttachment(100, 0);
-    fd.top = new FormAttachment(top, margin);
-    button.setLayoutData(fd);
-    return button;
+    Label wlEnvVars = new Label(parent, SWT.LEFT);
+    wlEnvVars.setText(BaseMessages.getString(PKG, "ActionDbt.EnvVars.Label"));
+    PropsUi.setLook(wlEnvVars);
+    FormData fdlEnvVars = new FormData();
+    fdlEnvVars.left = new FormAttachment(0, 0);
+    fdlEnvVars.top = new FormAttachment(wVars, margin);
+    wlEnvVars.setLayoutData(fdlEnvVars);
+
+    wEnvVars = pairTable(parent, action.getEnvVars().size(), lsMod);
+    FormData fdEnvVars = new FormData();
+    fdEnvVars.left = new FormAttachment(0, 0);
+    fdEnvVars.top = new FormAttachment(wlEnvVars, margin);
+    fdEnvVars.right = new FormAttachment(100, 0);
+    fdEnvVars.bottom = new FormAttachment(100, 0);
+    fdEnvVars.height = TABLE_HEIGHT;
+    wEnvVars.setLayoutData(fdEnvVars);
   }
 
-  private TableView pairTable(org.eclipse.swt.widgets.Control top, int rows, 
int margin) {
+  private TableView pairTable(Composite parent, int rows, ModifyListener 
lsMod) {
     ColumnInfo[] columns =
         new ColumnInfo[] {
           new ColumnInfo(
-              BaseMessages.getString(PKG, "ActionDbtDialog.Column.Name"),
+              BaseMessages.getString(PKG, "ActionDbt.Column.Name"),
               ColumnInfo.COLUMN_TYPE_TEXT,
               false),
           new ColumnInfo(
-              BaseMessages.getString(PKG, "ActionDbtDialog.Column.Value"),
+              BaseMessages.getString(PKG, "ActionDbt.Column.Value"),
               ColumnInfo.COLUMN_TYPE_TEXT,
               false)
         };
-    TableView table =
-        new TableView(
-            variables,
-            shell,
-            SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
-            columns,
-            rows,
-            null,
-            props);
-    FormData fd = new FormData();
-    fd.left = new FormAttachment(0, 0);
-    fd.right = new FormAttachment(100, 0);
-    fd.top = new FormAttachment(top, margin);
-    table.setLayoutData(fd);
-    return table;
+    return new TableView(
+        variables,
+        parent,
+        SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI,
+        columns,
+        rows,
+        lsMod,
+        props);
   }
 
   private void getData() {
     wName.setText(Const.NVL(action.getName(), ""));
-    wProject.setText(Const.NVL(action.getDbtProjectName(), ""));
-    wOperation.setText(DbtOperation.fromCode(action.getOperation()).getCode());
-    wTarget.setText(Const.NVL(action.getTarget(), ""));
-    wSelect.setText(Const.NVL(action.getSelect(), ""));
-    wExclude.setText(Const.NVL(action.getExclude(), ""));
-    wThreads.setText(Const.NVL(action.getThreads(), ""));
-    wTimeout.setText(Const.NVL(action.getTimeout(), ""));
-    wFullRefresh.setSelection(action.isFullRefresh());
-    wEmitOpenLineage.setSelection(action.isEmitOpenLineage());
-    for (int i = 0; i < action.getVars().size(); i++) {
-      DbtNameValue v = action.getVars().get(i);
-      TableItem item = wVars.table.getItem(i);
-      item.setText(1, Const.NVL(v.getName(), ""));
-      item.setText(2, Const.NVL(v.getValue(), ""));
+    widgets.setWidgetsContents(action, shell, 
ActionDbt.GUI_PLUGIN_ELEMENT_PARENT_ID);
+    fillTable(wVars, action.getVars());
+    fillTable(wEnvVars, action.getEnvVars());
+  }
+
+  private void fillTable(TableView table, List<DbtNameValue> pairs) {
+    for (int i = 0; i < pairs.size(); i++) {
+      DbtNameValue pair = pairs.get(i);
+      TableItem item = table.table.getItem(i);
+      item.setText(1, Const.NVL(pair.getName(), ""));
+      item.setText(2, Const.NVL(pair.getValue(), ""));
     }
-    for (int i = 0; i < action.getEnvVars().size(); i++) {
-      DbtNameValue v = action.getEnvVars().get(i);
-      TableItem item = wEnvVars.table.getItem(i);
-      item.setText(1, Const.NVL(v.getName(), ""));
-      item.setText(2, Const.NVL(v.getValue(), ""));
+    table.optimizeTableView();
+  }
+
+  private void readTable(TableView table, List<DbtNameValue> pairs) {
+    pairs.clear();
+    for (TableItem item : table.getNonEmptyItems()) {
+      pairs.add(new DbtNameValue(item.getText(1), item.getText(2)));
     }
-    wVars.optimizeTableView();
-    wEnvVars.optimizeTableView();
+  }
+
+  @Override
+  protected void onActionNameModified() {
+    action.setChanged();
   }
 
   private void cancel() {
@@ -339,26 +221,27 @@ public class ActionDbtDialog extends ActionDialog 
implements IActionDialog {
     if (Utils.isEmpty(wName.getText())) {
       return;
     }
-    action.setName(wName.getText());
-    action.setDbtProjectName(wProject.getText());
-    action.setOperation(wOperation.getText());
-    action.setTarget(wTarget.getText());
-    action.setSelect(wSelect.getText());
-    action.setExclude(wExclude.getText());
-    action.setThreads(wThreads.getText());
-    action.setTimeout(wTimeout.getText());
-    action.setFullRefresh(wFullRefresh.getSelection());
-    action.setEmitOpenLineage(wEmitOpenLineage.getSelection());
-
-    action.getVars().clear();
-    for (TableItem item : wVars.getNonEmptyItems()) {
-      action.getVars().add(new DbtNameValue(item.getText(1), item.getText(2)));
-    }
-    action.getEnvVars().clear();
-    for (TableItem item : wEnvVars.getNonEmptyItems()) {
-      action.getEnvVars().add(new DbtNameValue(item.getText(1), 
item.getText(2)));
+    String operation = operationText();
+    if (!Utils.isEmpty(operation) && DbtOperation.fromNullableCode(operation) 
== null) {
+      // The generated combo cannot be read-only, and an unrecognised 
operation would quietly run
+      // 'dbt run' instead of what was typed.
+      MessageBox box = new MessageBox(shell, SWT.OK | SWT.ICON_ERROR);
+      box.setText(BaseMessages.getString(PKG, 
"ActionDbt.UnknownOperation.Title"));
+      box.setMessage(BaseMessages.getString(PKG, 
"ActionDbt.UnknownOperation.Message", operation));
+      box.open();
+      return;
     }
+    action.setName(wName.getText());
+    widgets.getWidgetsContents(action, ActionDbt.GUI_PLUGIN_ELEMENT_PARENT_ID);
+    readTable(wVars, action.getVars());
+    readTable(wEnvVars, action.getEnvVars());
     action.setChanged();
     dispose();
   }
+
+  /** What the operation combo shows; empty when the widget could not be 
built. */
+  private String operationText() {
+    Control control = widgets.getWidgetsMap().get(ActionDbt.WIDGET_OPERATION);
+    return control instanceof Combo combo ? combo.getText() : "";
+  }
 }
diff --git 
a/plugins/actions/dbt/src/main/resources/org/apache/hop/workflow/actions/dbt/messages/messages_en_US.properties
 
b/plugins/actions/dbt/src/main/resources/org/apache/hop/workflow/actions/dbt/messages/messages_en_US.properties
index 988ef6f4db..23d428fe1a 100644
--- 
a/plugins/actions/dbt/src/main/resources/org/apache/hop/workflow/actions/dbt/messages/messages_en_US.properties
+++ 
b/plugins/actions/dbt/src/main/resources/org/apache/hop/workflow/actions/dbt/messages/messages_en_US.properties
@@ -30,19 +30,33 @@ DbtProjectEditor.Test.Running=Running dbt debug...
 DbtProjectEditor.Test.Error.Title=Error
 
 ActionDbtDialog.Title=dbt
-ActionDbtDialog.ActionName.Label=Action name
-ActionDbtDialog.Project.Label=dbt project
-ActionDbtDialog.Operation.Label=Operation
-ActionDbtDialog.Target.Label=Target (overrides project default)
-ActionDbtDialog.Select.Label=--select
-ActionDbtDialog.Exclude.Label=--exclude
-ActionDbtDialog.Threads.Label=--threads
-ActionDbtDialog.FullRefresh.Label=--full-refresh
-ActionDbtDialog.Timeout.Label=Timeout in seconds (empty = no limit)
-ActionDbtDialog.EmitOpenLineage.Label=Emit OpenLineage (stitch into Hop 
lineage)
-ActionDbtDialog.Vars.Label=Variables (--vars)
-ActionDbtDialog.EnvVars.Label=Environment variables (secrets injected into the 
dbt run)
-ActionDbtDialog.Column.Name=Name
-ActionDbtDialog.Column.Value=Value
-ActionDbtDialog.Ok.Button=OK
-ActionDbtDialog.Cancel.Button=Cancel
+
+ActionDbt.Group.Project=dbt project
+ActionDbt.Group.Selection=Selection
+ActionDbt.Group.Execution=Execution
+ActionDbt.Group.Variables=Variables
+
+ActionDbt.Project.Label=dbt project
+ActionDbt.Project.Tooltip=The dbt project metadata object to run against
+ActionDbt.Operation.Label=Operation
+ActionDbt.Operation.Tooltip=The dbt sub-command to run
+ActionDbt.Target.Label=Target
+ActionDbt.Target.Tooltip=The dbt target to run against. Overrides the default 
target of the dbt project when set.
+ActionDbt.Select.Label=--select
+ActionDbt.Select.Tooltip=Passed to dbt as --select, using the node selection 
syntax of dbt. Several selectors can be given in one value, separated by spaces.
+ActionDbt.Exclude.Label=--exclude
+ActionDbt.Exclude.Tooltip=Passed to dbt as --exclude
+ActionDbt.FullRefresh.Label=--full-refresh
+ActionDbt.FullRefresh.Tooltip=Passed to dbt as --full-refresh. dbt only 
accepts the flag for run, build and seed, so it is left out for the other 
operations.
+ActionDbt.Threads.Label=--threads
+ActionDbt.Threads.Tooltip=Passed to dbt as --threads. Left empty, dbt uses the 
thread count from profiles.yml.
+ActionDbt.Timeout.Label=Timeout in seconds
+ActionDbt.Timeout.Tooltip=Wall-clock limit for the dbt run. Empty or 0 waits 
for dbt however long it takes.
+ActionDbt.EmitOpenLineage.Label=Emit OpenLineage
+ActionDbt.EmitOpenLineage.Tooltip=Run dbt through dbt-ol and stitch its 
lineage into the Hop lineage graph
+ActionDbt.Vars.Label=Variables (--vars)
+ActionDbt.EnvVars.Label=Environment variables (secrets injected into the dbt 
run)
+ActionDbt.Column.Name=Name
+ActionDbt.Column.Value=Value
+ActionDbt.UnknownOperation.Title=Unknown operation
+ActionDbt.UnknownOperation.Message=''{0}'' is not a dbt operation. Pick one 
from the list.
diff --git 
a/plugins/actions/dbt/src/main/resources/org/apache/hop/workflow/actions/dbt/messages/messages_pt_BR.properties
 
b/plugins/actions/dbt/src/main/resources/org/apache/hop/workflow/actions/dbt/messages/messages_pt_BR.properties
index 6fcf62537e..e791c024a7 100644
--- 
a/plugins/actions/dbt/src/main/resources/org/apache/hop/workflow/actions/dbt/messages/messages_pt_BR.properties
+++ 
b/plugins/actions/dbt/src/main/resources/org/apache/hop/workflow/actions/dbt/messages/messages_pt_BR.properties
@@ -31,19 +31,16 @@ DbtProjectEditor.Label.DbtOlExecutable=Dbt-ol executável 
(OpenLineage)
 DbtProjectEditor.Test.Running=Executando depuração de dbt...
 DbtProjectEditor.Test.Error.Title=Erro
 ActionDbtDialog.Title=Dbt
-ActionDbtDialog.ActionName.Label=Nome da ação.
-ActionDbtDialog.Project.Label=Projeto DBT
-ActionDbtDialog.Operation.Label=Operação
-ActionDbtDialog.Target.Label=Alvo (sobrescreve do projeto padrão)
-ActionDbtDialog.Select.Label=...selecionar
-ActionDbtDialog.Exclude.Label=--excluir
-ActionDbtDialog.Threads.Label=- Threads.
-ActionDbtDialog.FullRefresh.Label=-Refresh completo
-ActionDbtDialog.Timeout.Label=Tempo limite em segundos (vazio = sem limite)
-ActionDbtDialog.EmitOpenLineage.Label=Emitir OpenLineage
-ActionDbtDialog.Vars.Label=Variáveis
-ActionDbtDialog.EnvVars.Label=Variáveis ambientais (segredos injetados na 
execução DBT)
-ActionDbtDialog.Column.Name=Nome
-ActionDbtDialog.Column.Value=Valor
-ActionDbtDialog.Ok.Button=Certo.
-ActionDbtDialog.Cancel.Button=Cancelar
+ActionDbt.Project.Label=Projeto DBT
+ActionDbt.Operation.Label=Operação
+ActionDbt.Target.Label=Alvo (sobrescreve do projeto padrão)
+ActionDbt.Select.Label=...selecionar
+ActionDbt.Exclude.Label=--excluir
+ActionDbt.Threads.Label=- Threads.
+ActionDbt.FullRefresh.Label=-Refresh completo
+ActionDbt.Timeout.Label=Tempo limite em segundos (vazio = sem limite)
+ActionDbt.EmitOpenLineage.Label=Emitir OpenLineage
+ActionDbt.Vars.Label=Variáveis
+ActionDbt.EnvVars.Label=Variáveis ambientais (segredos injetados na execução 
DBT)
+ActionDbt.Column.Name=Nome
+ActionDbt.Column.Value=Valor
diff --git 
a/plugins/actions/dbt/src/test/java/org/apache/hop/workflow/actions/dbt/ActionDbtDialogTest.java
 
b/plugins/actions/dbt/src/test/java/org/apache/hop/workflow/actions/dbt/ActionDbtDialogTest.java
new file mode 100644
index 0000000000..72e7c11e1b
--- /dev/null
+++ 
b/plugins/actions/dbt/src/test/java/org/apache/hop/workflow/actions/dbt/ActionDbtDialogTest.java
@@ -0,0 +1,377 @@
+/*
+ * 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.workflow.actions.dbt;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.gui.plugin.GuiRegistry;
+import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
+import org.apache.hop.ui.core.widget.TableView;
+import org.apache.hop.ui.hopgui.HopGui;
+import org.apache.hop.ui.hopgui.HopGuiImpl;
+import org.apache.hop.ui.testing.SwtBotTestBase;
+import org.apache.hop.workflow.WorkflowMeta;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.swt.graphics.Rectangle;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Shell;
+import org.eclipse.swtbot.swt.finder.SWTBot;
+import org.eclipse.swtbot.swt.finder.waits.DefaultCondition;
+import org.eclipse.swtbot.swt.finder.widgets.SWTBotShell;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+/**
+ * End-to-end coverage for the dbt action dialog, which {@link
+ * org.apache.hop.ui.core.gui.GuiCompositeWidgets} builds from the annotations 
on {@link ActionDbt}.
+ * Issue #8589: the fields used to be laid out on the shell itself, which left 
the two name/value
+ * tables squeezed under a stack of eight rows.
+ *
+ * <p>Tagged {@code uitest} so it is skipped when there is no display. Wrap 
Maven with {@code
+ * tools/with-isolated-display.sh} so the dialog does not steal focus.
+ */
+@Tag("uitest")
+class ActionDbtDialogTest extends SwtBotTestBase {
+
+  private static final String DIALOG_TITLE = "dbt";
+  private static final String PROJECT_NAME = "demo-project";
+  private static final List<String> EXPECTED_TABS =
+      List.of("dbt project", "Selection", "Execution", "Variables");
+
+  /**
+   * The annotated widgets are looked up in the registry by class name. The 
unit-test JVM does not
+   * always scan the plugin classes, so register them the way the {@code 
GuiPluginType} scan does.
+   */
+  @BeforeAll
+  static void registerActionWidgets() {
+    GuiRegistry registry = GuiRegistry.getInstance();
+    if (registry.findGuiElements(ActionDbt.class.getName(), 
ActionDbt.GUI_PLUGIN_ELEMENT_PARENT_ID)
+        != null) {
+      return;
+    }
+    for (Field field : ActionDbt.class.getDeclaredFields()) {
+      GuiWidgetElement element = field.getAnnotation(GuiWidgetElement.class);
+      if (element != null) {
+        registry.addGuiWidgetElement(ActionDbt.class.getName(), element, 
field);
+      }
+    }
+  }
+
+  /**
+   * The metadata selection line reads its provider from the running Hop GUI, 
which does not exist
+   * here. Stand a mock in for it, holding the dbt project the action refers 
to.
+   */
+  @BeforeAll
+  static void installHopGui() throws Exception {
+    MemoryMetadataProvider metadataProvider = new MemoryMetadataProvider();
+    DbtProject project = new DbtProject();
+    project.setName(PROJECT_NAME);
+    metadataProvider.getSerializer(DbtProject.class).save(project);
+
+    Variables variables = new Variables();
+    HopGui hopGui = mock(HopGui.class);
+    when(hopGui.getMetadataProvider())
+        .thenReturn(new MultiMetadataProvider(variables, metadataProvider));
+    when(hopGui.getVariables()).thenReturn(variables);
+    setHopGui(hopGui);
+  }
+
+  @AfterAll
+  static void removeHopGui() throws Exception {
+    setHopGui(null);
+  }
+
+  private static void setHopGui(HopGui hopGui) throws Exception {
+    Field instance = HopGuiImpl.class.getDeclaredField("instance");
+    instance.setAccessible(true);
+    instance.set(null, hopGui);
+  }
+
+  @Test
+  void theOptionsAreLaidOutOnFourTabsAboveTheButtons() {
+    List<String> tabs = new ArrayList<>();
+    List<String> tablesOutsideTheVariablesTab = new ArrayList<>();
+    List<Rectangle> folderBounds = new ArrayList<>();
+    List<Rectangle> okBounds = new ArrayList<>();
+
+    withDialog(
+        parent ->
+            new ActionDbtDialog(parent, actionWithValues(), new 
WorkflowMeta(), new Variables())
+                .open(),
+        bot -> {
+          SWTBotShell dialogShell = dialogShell(bot);
+          display.syncExec(
+              () -> {
+                Shell shell = dialogShell.widget;
+                shell.layout(true, true);
+
+                // The action image is loaded before the metadata widget is 
built, which is the
+                // order #8020 worked around; the dbt project line below still 
fills.
+                assertNotNull(shell.getImage(), "The dbt action image was not 
loaded");
+
+                CTabFolder folder = findTabFolder(shell);
+                assertNotNull(folder, "The options are not laid out on tabs");
+                for (int i = 0; i < folder.getItemCount(); i++) {
+                  tabs.add(folder.getItem(i).getText());
+                }
+                folderBounds.add(displayBounds(folder));
+
+                // Both tables belong to the last tab, not to the shell.
+                Control variablesTab = folder.getItem(folder.getItemCount() - 
1).getControl();
+                for (TableView table : collectTables(shell)) {
+                  if (!isDescendantOf(table, variablesTab)) {
+                    tablesOutsideTheVariablesTab.add(table.toString());
+                  }
+                }
+
+                Button ok = findOkButton(shell);
+                assertNotNull(ok, "The dialog has no OK button");
+                okBounds.add(displayBounds(ok));
+              });
+          
dialogShell.bot().button(buttonLabel("System.Button.Cancel")).click();
+        });
+
+    assertEquals(EXPECTED_TABS, tabs);
+    assertTrue(
+        tablesOutsideTheVariablesTab.isEmpty(),
+        "Every name/value table belongs on the Variables tab: " + 
tablesOutsideTheVariablesTab);
+    assertFalse(
+        folderBounds.get(0).intersects(okBounds.get(0)),
+        "The OK button overlaps the options, which is what issue #8589 is 
about");
+    assertTrue(
+        okBounds.get(0).y >= folderBounds.get(0).y + 
folderBounds.get(0).height,
+        "The OK button should sit below the options");
+  }
+
+  /**
+   * The point of issue #8589: at the size the dialog opens with - no saved 
geometry, so {@code
+   * BaseTransformDialog.setSize} packs it - both name/value tables have to be 
usable. A table that
+   * is only given the leftovers of a packed layout collapses to a header 
strip.
+   */
+  @Test
+  void bothTablesOpenWithAUsableHeight() {
+    List<Integer> heights = new ArrayList<>();
+    List<Point> shellSize = new ArrayList<>();
+
+    withDialog(
+        parent ->
+            new ActionDbtDialog(parent, actionWithValues(), new 
WorkflowMeta(), new Variables())
+                .open(),
+        bot -> {
+          SWTBotShell dialogShell = dialogShell(bot);
+          display.syncExec(
+              () -> {
+                Shell shell = dialogShell.widget;
+                shellSize.add(shell.getSize());
+                CTabFolder folder = findTabFolder(shell);
+                folder.setSelection(folder.getItemCount() - 1);
+                shell.layout(true, true);
+                for (TableView table : collectTables(shell)) {
+                  heights.add(table.getBounds().height);
+                }
+              });
+          
dialogShell.bot().button(buttonLabel("System.Button.Cancel")).click();
+        });
+
+    assertEquals(2, heights.size(), "Expected the variables and the 
environment table");
+    for (int height : heights) {
+      assertTrue(
+          height >= 80,
+          "A name/value table opens "
+              + height
+              + "px high in a "
+              + shellSize.get(0)
+              + " dialog, which is not usable");
+    }
+  }
+
+  @Test
+  void okKeepsEveryValueOfTheAction() {
+    ActionDbt action = actionWithValues();
+
+    withDialog(
+        parent -> new ActionDbtDialog(parent, action, new WorkflowMeta(), new 
Variables()).open(),
+        bot -> 
dialogShell(bot).bot().button(buttonLabel("System.Button.OK")).click());
+
+    assertEquals("dbt build", action.getName());
+    assertEquals(PROJECT_NAME, action.getDbtProjectName());
+    assertEquals(DbtOperation.BUILD.getCode(), action.getOperation());
+    assertEquals("prod", action.getTarget());
+    assertEquals("tag:daily", action.getSelect());
+    assertEquals("tag:slow", action.getExclude());
+    assertEquals("4", action.getThreads());
+    assertEquals("900", action.getTimeout());
+    assertTrue(action.isFullRefresh());
+    assertTrue(action.isEmitOpenLineage());
+    assertEquals(1, action.getVars().size());
+    assertEquals("run_date", action.getVars().get(0).getName());
+    assertEquals("2026-01-31", action.getVars().get(0).getValue());
+    assertEquals(1, action.getEnvVars().size());
+    assertEquals("DBT_PASSWORD", action.getEnvVars().get(0).getName());
+    assertEquals("secret", action.getEnvVars().get(0).getValue());
+  }
+
+  /**
+   * The generated operation combo cannot be read-only, so a typo would 
otherwise be stored and run
+   * as {@code dbt run} - {@link DbtOperation#fromCode} falls back to it for 
anything it does not
+   * recognise. The dialog refuses to close instead.
+   */
+  @Test
+  void okRefusesAnUnknownOperation() {
+    ActionDbt action = actionWithValues();
+
+    withDialog(
+        parent -> new ActionDbtDialog(parent, action, new WorkflowMeta(), new 
Variables()).open(),
+        bot -> {
+          SWTBotShell dialogShell = dialogShell(bot);
+          display.syncExec(() -> 
operationCombo(dialogShell.widget).setText("buidl"));
+          dialogShell.bot().button(buttonLabel("System.Button.OK")).click();
+
+          SWTBotShell complaint =
+              bot.shell(
+                  BaseMessages.getString(ActionDbt.class, 
"ActionDbt.UnknownOperation.Title"));
+          complaint.bot().button(buttonLabel("System.Button.OK")).click();
+
+          // The dbt dialog is still open, so nothing was written back.
+          
dialogShell.bot().button(buttonLabel("System.Button.Cancel")).click();
+        });
+
+    assertEquals(DbtOperation.BUILD.getCode(), action.getOperation());
+  }
+
+  /** The combo holding the dbt operations, found by its contents rather than 
by index. */
+  private static Combo operationCombo(Composite parent) {
+    for (Control child : parent.getChildren()) {
+      if (child instanceof Combo combo
+          && List.of(combo.getItems()).contains(DbtOperation.BUILD.getCode())) 
{
+        return combo;
+      }
+      if (child instanceof Composite composite) {
+        Combo found = operationCombo(composite);
+        if (found != null) {
+          return found;
+        }
+      }
+    }
+    return null;
+  }
+
+  private static ActionDbt actionWithValues() {
+    ActionDbt action = new ActionDbt("dbt build");
+    action.setDbtProjectName(PROJECT_NAME);
+    action.setOperation(DbtOperation.BUILD.getCode());
+    action.setTarget("prod");
+    action.setSelect("tag:daily");
+    action.setExclude("tag:slow");
+    action.setThreads("4");
+    action.setTimeout("900");
+    action.setFullRefresh(true);
+    action.setEmitOpenLineage(true);
+    action.getVars().add(new DbtNameValue("run_date", "2026-01-31"));
+    action.getEnvVars().add(new DbtNameValue("DBT_PASSWORD", "secret"));
+    return action;
+  }
+
+  private SWTBotShell dialogShell(SWTBot bot) {
+    SWTBotShell dialogShell = bot.shell(DIALOG_TITLE);
+    bot.waitUntil(
+        new DefaultCondition() {
+          @Override
+          public boolean test() {
+            return dialogShell.isOpen() && dialogShell.isVisible();
+          }
+
+          @Override
+          public String getFailureMessage() {
+            return "The " + DIALOG_TITLE + " dialog never became visible";
+          }
+        });
+    dialogShell.activate();
+    return dialogShell;
+  }
+
+  private static CTabFolder findTabFolder(Composite parent) {
+    for (Control child : parent.getChildren()) {
+      if (child instanceof CTabFolder folder) {
+        return folder;
+      }
+      if (child instanceof Composite composite) {
+        CTabFolder found = findTabFolder(composite);
+        if (found != null) {
+          return found;
+        }
+      }
+    }
+    return null;
+  }
+
+  private static Button findOkButton(Composite parent) {
+    String okLabel = buttonLabel("System.Button.OK");
+    for (Control child : parent.getChildren()) {
+      if (child instanceof Button button && 
okLabel.equals(button.getText().replace("&", ""))) {
+        return button;
+      }
+    }
+    return null;
+  }
+
+  private static List<TableView> collectTables(Composite parent) {
+    List<TableView> tables = new ArrayList<>();
+    for (Control child : parent.getChildren()) {
+      if (child instanceof TableView tableView) {
+        tables.add(tableView);
+      } else if (child instanceof Composite composite) {
+        tables.addAll(collectTables(composite));
+      }
+    }
+    return tables;
+  }
+
+  private static boolean isDescendantOf(Control control, Control ancestor) {
+    for (Composite parent = control.getParent(); parent != null; parent = 
parent.getParent()) {
+      if (parent == ancestor) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private static Rectangle displayBounds(Control control) {
+    Rectangle bounds = control.getBounds();
+    Point origin = control.getParent().toDisplay(bounds.x, bounds.y);
+    return new Rectangle(origin.x, origin.y, bounds.width, bounds.height);
+  }
+}
diff --git 
a/plugins/actions/dbt/src/test/java/org/apache/hop/workflow/actions/dbt/ActionDbtWidgetLayoutTest.java
 
b/plugins/actions/dbt/src/test/java/org/apache/hop/workflow/actions/dbt/ActionDbtWidgetLayoutTest.java
new file mode 100644
index 0000000000..24b723a88a
--- /dev/null
+++ 
b/plugins/actions/dbt/src/test/java/org/apache/hop/workflow/actions/dbt/ActionDbtWidgetLayoutTest.java
@@ -0,0 +1,138 @@
+/*
+ * 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.workflow.actions.dbt;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.gui.plugin.GuiElements;
+import org.apache.hop.core.gui.plugin.GuiRegistry;
+import org.apache.hop.core.gui.plugin.GuiWidgetElement;
+import org.apache.hop.core.gui.plugin.GuiWidgetGroupType;
+import org.apache.hop.core.gui.plugin.GuiWidgetGroups;
+import org.apache.hop.core.util.TranslateUtil;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The dbt dialog is generated from the {@link GuiWidgetElement} annotations 
on {@link ActionDbt},
+ * so a property without an annotation silently disappears from the dialog, a 
mistyped label
+ * silently renders as {@code !Some.Key!} and a wrong group quietly moves a 
field to another tab.
+ * This test covers the annotations rather than the SWT layout, so it runs 
without a display.
+ */
+class ActionDbtWidgetLayoutTest {
+
+  /** The two name/value tables the dialog adds itself through {@code 
registerExtraGroup}. */
+  private static final List<String> HANDLED_OUTSIDE_THE_ANNOTATIONS = 
List.of("vars", "envVars");
+
+  /**
+   * The unit-test JVM does not scan the plugin jars, so register the 
annotated fields the same way
+   * the {@code GuiPluginType} scan does at runtime.
+   */
+  @BeforeAll
+  static void registerActionWidgets() {
+    GuiRegistry registry = GuiRegistry.getInstance();
+    if (registry.findGuiElements(ActionDbt.class.getName(), 
ActionDbt.GUI_PLUGIN_ELEMENT_PARENT_ID)
+        != null) {
+      return;
+    }
+    for (Field field : ActionDbt.class.getDeclaredFields()) {
+      GuiWidgetElement element = field.getAnnotation(GuiWidgetElement.class);
+      if (element != null) {
+        registry.addGuiWidgetElement(ActionDbt.class.getName(), element, 
field);
+      }
+    }
+  }
+
+  @Test
+  void everyPersistedPropertyIsOnTheDialog() {
+    for (Field field : ActionDbt.class.getDeclaredFields()) {
+      if (field.getAnnotation(HopMetadataProperty.class) == null
+          || HANDLED_OUTSIDE_THE_ANNOTATIONS.contains(field.getName())) {
+        continue;
+      }
+      assertNotNull(
+          field.getAnnotation(GuiWidgetElement.class),
+          "Property "
+              + field.getName()
+              + " is persisted but has no @GuiWidgetElement, so it is missing 
from the dialog");
+    }
+  }
+
+  @Test
+  void everyWidgetTextResolves() {
+    for (Field field : ActionDbt.class.getDeclaredFields()) {
+      GuiWidgetElement widget = field.getAnnotation(GuiWidgetElement.class);
+      if (widget == null) {
+        continue;
+      }
+      assertResolves(field.getName(), "label", widget.label());
+      assertResolves(field.getName(), "toolTip", widget.toolTip());
+      assertResolves(field.getName(), "group", widget.group());
+    }
+    // The fourth tab has no annotated fields: its label is resolved by the 
dialog itself.
+    assertResolves("the variables tab", "group", ActionDbt.GROUP_VARIABLES);
+  }
+
+  @Test
+  void fieldsAreLaidOutOnTabsInTheExpectedOrder() {
+    GuiElements elements =
+        GuiRegistry.getInstance()
+            .findGuiElements(ActionDbt.class.getName(), 
ActionDbt.GUI_PLUGIN_ELEMENT_PARENT_ID);
+    assertNotNull(elements, "No widgets are registered for the dbt action");
+    assertTrue(GuiWidgetGroups.hasGroups(elements.getChildren()), "The fields 
are not grouped");
+    assertFalse(
+        GuiWidgetGroups.hasMixedTypes(elements.getChildren()),
+        "Mixed group types fall back to tabs instead of using the declared 
one");
+    assertEquals(GuiWidgetGroupType.TABS, 
GuiWidgetGroups.typeOf(elements.getChildren()));
+
+    List<GuiWidgetGroups.Bucket> buckets = 
GuiWidgetGroups.from(elements.getChildren(), "General");
+    assertEquals(
+        List.of(
+            TranslateUtil.translate(ActionDbt.GROUP_PROJECT, ActionDbt.class),
+            TranslateUtil.translate(ActionDbt.GROUP_SELECTION, 
ActionDbt.class),
+            TranslateUtil.translate(ActionDbt.GROUP_EXECUTION, 
ActionDbt.class)),
+        buckets.stream().map(GuiWidgetGroups.Bucket::getLabel).toList(),
+        "The annotated fields should be laid out on three tabs, in this 
order");
+
+    assertEquals(List.of("dbtProjectName", "operation", "target"), 
fieldNames(buckets.get(0)));
+    assertEquals(List.of("select", "exclude", "fullRefresh"), 
fieldNames(buckets.get(1)));
+    assertEquals(List.of("threads", "timeout", "emitOpenLineage"), 
fieldNames(buckets.get(2)));
+  }
+
+  private static List<String> fieldNames(GuiWidgetGroups.Bucket bucket) {
+    List<String> names = new ArrayList<>();
+    for (GuiElements element : bucket.getElements()) {
+      names.add(element.getFieldName());
+    }
+    return names;
+  }
+
+  private static void assertResolves(String fieldName, String what, String 
value) {
+    String translated = TranslateUtil.translate(value, ActionDbt.class);
+    assertFalse(
+        translated.startsWith("!") && translated.endsWith("!"),
+        "The " + what + " of " + fieldName + " does not resolve: " + 
translated);
+  }
+}

Reply via email to