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);
+ }
+}