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 885d58e539 Cleanup the metadata perspective, fixes #7319 (#7320)
885d58e539 is described below
commit 885d58e5390397856f7bf468272ecd48b03d1e77
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Fri Jun 19 16:07:53 2026 +0200
Cleanup the metadata perspective, fixes #7319 (#7320)
---
.../org/apache/hop/core/database/DatabaseMeta.java | 2 +
.../core/variables/resolver/VariableResolver.java | 2 +
.../org/apache/hop/metadata/api/HopMetadata.java | 9 +
.../hop/metadata/api/HopMetadataCategory.java | 57 +
docs/hop-user-manual/modules/ROOT/nav.adoc | 4 +-
.../pages/metadata-types/azure-authentication.adoc | 4 +-
.../google-storage-authentication.adoc | 2 +-
.../modules/ROOT/pages/metadata-types/index.adoc | 4 +-
.../hop/datastream/metadata/DataStreamMeta.java | 2 +
.../hop/execution/ExecutionInfoLocation.java | 2 +
.../execution/profiling/ExecutionDataProfile.java | 2 +
.../org/apache/hop/partition/PartitionSchema.java | 2 +
.../pipeline/config/PipelineRunConfiguration.java | 2 +
.../java/org/apache/hop/server/HopServerMeta.java | 2 +
.../workflow/config/WorkflowRunConfiguration.java | 2 +
.../org/apache/hop/www/service/WebService.java | 2 +
.../apache/hop/beam/metadata/FileDefinition.java | 2 +
.../org/apache/hop/www/async/AsyncWebService.java | 2 +
.../hop/mail/metadata/MailServerConnection.java | 2 +
.../hop/reflection/pipeline/meta/PipelineLog.java | 2 +
.../hop/reflection/probe/meta/PipelineProbe.java | 2 +
.../hop/reflection/workflow/meta/WorkflowLog.java | 2 +
.../apache/hop/metadata/rest/RestConnection.java | 2 +
.../staticschema/metadata/SchemaDefinition.java | 2 +
.../main/java/org/apache/hop/testing/DataSet.java | 2 +
.../org/apache/hop/testing/PipelineUnitTest.java | 2 +
.../org/apache/hop/vfs/s3/metadata/S3Meta.java | 2 +
.../vfs/azure/metadatatype/AzureMetadataType.java | 2 +
.../messages/messages_en_US.properties | 3 +-
.../cassandra/metadata/CassandraConnection.java | 2 +
.../gs/metadatatype/GoogleStorageMetadataType.java | 2 +
.../messages/messages_en_US.properties | 3 +-
.../apache/hop/vfs/minio/metadata/MinioMeta.java | 2 +
.../hop/mongo/metadata/MongoDbConnection.java | 2 +
.../org/apache/hop/neo4j/model/GraphModel.java | 2 +
.../org/apache/hop/neo4j/shared/NeoConnection.java | 2 +
.../metadata/salesforce/SalesforceConnection.java | 2 +
.../hop/vfs/webdav/metadata/WebDavConnection.java | 2 +
.../org/apache/hop/splunk/SplunkConnection.java | 2 +
.../perspective/metadata/MetadataCategories.java | 142 +++
.../perspective/metadata/MetadataOverview.java | 190 +++
.../perspective/metadata/MetadataPerspective.java | 1294 ++++++++++++++++----
.../metadata/messages/messages_en_US.properties | 26 +-
.../metadata/MetadataCategoriesTest.java | 86 ++
44 files changed, 1625 insertions(+), 261 deletions(-)
diff --git a/core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java
b/core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java
index 6c93b73fc9..12e65b8e6e 100644
--- a/core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java
+++ b/core/src/main/java/org/apache/hop/core/database/DatabaseMeta.java
@@ -51,6 +51,7 @@ import org.apache.hop.core.variables.IVariables;
import org.apache.hop.i18n.BaseMessages;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -65,6 +66,7 @@ import org.apache.hop.metadata.api.IHopMetadataProvider;
name = "i18n::DatabaseMeta.name",
description = "i18n::DatabaseMeta.description",
image = "ui/images/database.svg",
+ category = HopMetadataCategory.CONNECTIONS,
documentationUrl = "/metadata-types/rdbms-connection.html",
hopMetadataPropertyType = HopMetadataPropertyType.RDBMS_CONNECTION,
supportsGlobalReplace = true)
diff --git
a/core/src/main/java/org/apache/hop/core/variables/resolver/VariableResolver.java
b/core/src/main/java/org/apache/hop/core/variables/resolver/VariableResolver.java
index ca40b0ef15..c8749eb898 100644
---
a/core/src/main/java/org/apache/hop/core/variables/resolver/VariableResolver.java
+++
b/core/src/main/java/org/apache/hop/core/variables/resolver/VariableResolver.java
@@ -22,6 +22,7 @@ import lombok.Getter;
import lombok.Setter;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -30,6 +31,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::VariableResolver.name",
description = "i18n::VariableResolver.Description",
image = "ui/images/variable.svg",
+ category = HopMetadataCategory.VARIABLES,
documentationUrl = "/metadata-types/variable-resolver/")
@Getter
@Setter
diff --git a/core/src/main/java/org/apache/hop/metadata/api/HopMetadata.java
b/core/src/main/java/org/apache/hop/metadata/api/HopMetadata.java
index 9945b58130..3cf6f87de4 100644
--- a/core/src/main/java/org/apache/hop/metadata/api/HopMetadata.java
+++ b/core/src/main/java/org/apache/hop/metadata/api/HopMetadata.java
@@ -43,6 +43,15 @@ public @interface HopMetadata {
String image() default "ui/images/folder.svg";
+ /**
+ * The category this metadata type belongs to. Used purely for presentation
(e.g. grouping types
+ * under a common heading in the metadata perspective). Use one of the
stable ids defined in
+ * {@link HopMetadataCategory}. An empty value (the default) groups the type
under "Other".
+ *
+ * @return the category id for this metadata type
+ */
+ String category() default "";
+
String documentationUrl() default "";
/**
diff --git
a/core/src/main/java/org/apache/hop/metadata/api/HopMetadataCategory.java
b/core/src/main/java/org/apache/hop/metadata/api/HopMetadataCategory.java
new file mode 100644
index 0000000000..9d575014ba
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/metadata/api/HopMetadataCategory.java
@@ -0,0 +1,57 @@
+/*
+ * 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.metadata.api;
+
+/**
+ * Stable category ids referenced by {@link HopMetadata#category()}. These ids
only group metadata
+ * types for presentation; the human readable label, display order and icon
are resolved in the UI
+ * layer so that this {@code core} class carries no presentation concerns.
+ */
+public final class HopMetadataCategory {
+
+ /** Database, NoSQL, graph and other data-source connections. */
+ public static final String CONNECTIONS = "connections";
+
+ /** Virtual File System / cloud storage connections (S3, Azure, GCS, MinIO,
WebDAV, ...). */
+ public static final String FILE_STORAGE = "file-storage";
+
+ /** Pipeline and workflow run configurations. */
+ public static final String RUN_CONFIG = "run-config";
+
+ /** Hop servers and published web services. */
+ public static final String SERVERS = "servers";
+
+ /** Execution information locations and data profiling. */
+ public static final String EXECUTION = "execution";
+
+ /** Pipeline/workflow logging, probes and log readers. */
+ public static final String LOGGING = "logging";
+
+ /** Unit tests and data sets. */
+ public static final String TESTING = "testing";
+
+ /** Schema, partition and file definitions describing data shape. */
+ public static final String DATA_DEFINITION = "data-definition";
+
+ /** Variable resolvers. */
+ public static final String VARIABLES = "variables";
+
+ private HopMetadataCategory() {
+ // Constants holder, do not instantiate.
+ }
+}
diff --git a/docs/hop-user-manual/modules/ROOT/nav.adoc
b/docs/hop-user-manual/modules/ROOT/nav.adoc
index 16fd712938..e8d04fc058 100644
--- a/docs/hop-user-manual/modules/ROOT/nav.adoc
+++ b/docs/hop-user-manual/modules/ROOT/nav.adoc
@@ -437,13 +437,13 @@ under the License.
** xref:logging/logging-reflection.adoc[Logging reflection]
* xref:metadata-types/index.adoc[Metadata Types]
** xref:metadata-types/async-web-service.adoc[]
-** xref:metadata-types/azure-authentication.adoc[Azure Authentication]
+** xref:metadata-types/azure-authentication.adoc[Azure Connection]
** xref:metadata-types/beam-file-definition.adoc[Beam File Definition]
** xref:metadata-types/cassandra/cassandra-connection.adoc[Cassandra
Connection]
** xref:metadata-types/data-set.adoc[Data Set]
** xref:metadata-types/execution-data-profile.adoc[]
** xref:metadata-types/execution-information-location.adoc[]
-** xref:metadata-types/google-storage-authentication.adoc[Google Storage
Authentication]
+** xref:metadata-types/google-storage-authentication.adoc[Google Storage
Connection]
** xref:metadata-types/hop-server.adoc[Hop Server]
** xref:metadata-types/mail-server-connection.adoc[Mail Server Connection]
** xref:metadata-types/minio-connection.adoc[Minio (S3) Connection]
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/azure-authentication.adoc
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/azure-authentication.adoc
index aef779419c..f5fc5340e6 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/azure-authentication.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/azure-authentication.adoc
@@ -18,11 +18,11 @@ under the License.
:page-pagination:
:description: This is used to register multiple Azure blob storage accounts
-= Azure Authentication
+= Azure Connection
== Description
This metadata type can be used to add a new Azure Storage file system to your
project. This allows you to connect to multiple storage accounts at the same
time.
-Using an Azure authentication to connect to a blob store requires you to
specify the container name in the path. For example, if your Azure
authentication is named az, you would need to specify the container as
**az:/containerName**.
+Using an Azure connection to connect to a blob store requires you to specify
the container name in the path. For example, if your Azure connection is named
az, you would need to specify the container as **az:/containerName**.
== Options
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/google-storage-authentication.adoc
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/google-storage-authentication.adoc
index ddab5f7cbe..8ad6b64478 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/google-storage-authentication.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/google-storage-authentication.adoc
@@ -18,7 +18,7 @@ under the License.
:page-pagination:
:description: This is used to register multiple Google cloud storage accounts
-= Google Storage Authentication
+= Google Storage Connection
== Description
This metadata type can be used to add a new Google Cloud Storage file system
to your project. This allows you to connect to multiple storage accounts at the
same time.
diff --git a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/index.adoc
b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/index.adoc
index 2252c38e26..ca4b5ecd1a 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/metadata-types/index.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/metadata-types/index.adoc
@@ -36,14 +36,14 @@ By default, Hop contains the following metadata types:
* xref:hop-server/async-web-service.adoc[Asynchronous Web Service]: Execute
and query a workflow asynchronously through a web service.
-* xref:metadata-types/azure-authentication.adoc[Azure Blob Storage
Authentication]: A Azure Blob Storage connection type.
+* xref:metadata-types/azure-authentication.adoc[Azure Connection]: An Azure
Blob Storage connection type.
* xref:metadata-types/beam-file-definition.adoc[Beam File Definition]:
Describes a file layout in a Beam Pipeline
* xref:metadata-types/cassandra/cassandra-connection.adoc[Cassandra
Connection]: Describes a connection to a Cassandra cluster
* xref:metadata-types/data-set.adoc[Data Set]: This defines a data set, a
static pre-defined collection of rows
xref:metadata-types/data-stream/data-stream.adoc[Data Stream]: A Data Stream
can be used for Inter Process Communication (IPC) or fast data serialization.
* xref:metadata-types/execution-data-profile.adoc[Execution Data Profile]:
Collects and profiles data as it flows through a pipeline using configurable
samplers for insight into value ranges, nulls, and row samples.
* xref:metadata-types/execution-information-location.adoc[Execution
Information Location]: Defines where and how Apache Hop stores execution
metadata, supporting local files, remote servers, Neo4j, or Elastic for later
inspection and analysis.
-* xref:metadata-types/google-storage-authentication.adoc[Google Storage
Authentication]: A Google Cloud Storage connection type.
+* xref:metadata-types/google-storage-authentication.adoc[Google Storage
Connection]: A Google Cloud Storage connection type.
* xref:metadata-types/hop-server.adoc[Hop Server]: Defines a Hop Server
* xref:metadata-types/mongodb-connection.adoc[MongoDB Connection]: Describes a
MongoDB connection
* xref:metadata-types/mail-server-connection.adoc[Mail Server Connection]:
Describes a mail server connection
diff --git
a/engine/src/main/java/org/apache/hop/datastream/metadata/DataStreamMeta.java
b/engine/src/main/java/org/apache/hop/datastream/metadata/DataStreamMeta.java
index 53b2a8b52f..67ad1c9abc 100644
---
a/engine/src/main/java/org/apache/hop/datastream/metadata/DataStreamMeta.java
+++
b/engine/src/main/java/org/apache/hop/datastream/metadata/DataStreamMeta.java
@@ -23,6 +23,7 @@ import lombok.Setter;
import org.apache.hop.datastream.plugin.IDataStream;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -32,6 +33,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::DataStream.name",
description = "i18n::DataStream.description",
image = "ui/images/arrow-stream.svg",
+ category = HopMetadataCategory.DATA_DEFINITION,
documentationUrl = "/metadata-types/data-stream.html",
hopMetadataPropertyType = HopMetadataPropertyType.DATA_STREAM,
supportsGlobalReplace = true)
diff --git
a/engine/src/main/java/org/apache/hop/execution/ExecutionInfoLocation.java
b/engine/src/main/java/org/apache/hop/execution/ExecutionInfoLocation.java
index fc1b44db0c..0c66151fa3 100644
--- a/engine/src/main/java/org/apache/hop/execution/ExecutionInfoLocation.java
+++ b/engine/src/main/java/org/apache/hop/execution/ExecutionInfoLocation.java
@@ -21,6 +21,7 @@ package org.apache.hop.execution;
import org.apache.hop.execution.profiling.ExecutionDataProfile;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -30,6 +31,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::ExecutionInfoLocation.name",
description = "i18n::ExecutionInfoLocation.description",
image = "ui/images/location.svg",
+ category = HopMetadataCategory.EXECUTION,
documentationUrl = "/metadata-types/execution-information-location.html",
hopMetadataPropertyType = HopMetadataPropertyType.EXEC_INFO_LOCATION,
supportsGlobalReplace = true)
diff --git
a/engine/src/main/java/org/apache/hop/execution/profiling/ExecutionDataProfile.java
b/engine/src/main/java/org/apache/hop/execution/profiling/ExecutionDataProfile.java
index 90e1a00f03..9d36867d5a 100644
---
a/engine/src/main/java/org/apache/hop/execution/profiling/ExecutionDataProfile.java
+++
b/engine/src/main/java/org/apache/hop/execution/profiling/ExecutionDataProfile.java
@@ -23,6 +23,7 @@ import java.util.List;
import org.apache.hop.execution.sampler.IExecutionDataSampler;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -36,6 +37,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::ExecutionDataProfile.name",
description = "i18n::ExecutionDataProfile.description",
image = "ui/images/analyzer.svg",
+ category = HopMetadataCategory.EXECUTION,
documentationUrl = "/metadata-types/execution-data-profile.html",
hopMetadataPropertyType = HopMetadataPropertyType.EXEC_INFO_DATA_PROFILE,
supportsGlobalReplace = true)
diff --git a/engine/src/main/java/org/apache/hop/partition/PartitionSchema.java
b/engine/src/main/java/org/apache/hop/partition/PartitionSchema.java
index 5aba63b386..0ae21954e4 100644
--- a/engine/src/main/java/org/apache/hop/partition/PartitionSchema.java
+++ b/engine/src/main/java/org/apache/hop/partition/PartitionSchema.java
@@ -23,6 +23,7 @@ import org.apache.hop.core.Const;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -36,6 +37,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::PartitionSchema.name",
description = "i18n::PartitionSchema.description",
image = "ui/images/partition_schema.svg",
+ category = HopMetadataCategory.DATA_DEFINITION,
documentationUrl = "/metadata-types/partition-schema.html",
hopMetadataPropertyType = HopMetadataPropertyType.PARTITION_SCHEMA)
public class PartitionSchema extends HopMetadataBase implements Cloneable,
IHopMetadata {
diff --git
a/engine/src/main/java/org/apache/hop/pipeline/config/PipelineRunConfiguration.java
b/engine/src/main/java/org/apache/hop/pipeline/config/PipelineRunConfiguration.java
index 799b430543..1099df1894 100644
---
a/engine/src/main/java/org/apache/hop/pipeline/config/PipelineRunConfiguration.java
+++
b/engine/src/main/java/org/apache/hop/pipeline/config/PipelineRunConfiguration.java
@@ -26,6 +26,7 @@ import org.apache.hop.core.variables.IVariables;
import org.apache.hop.execution.profiling.ExecutionDataProfile;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -36,6 +37,7 @@ import org.apache.hop.metadata.api.IHopMetadataProvider;
name = "i18n::PipelineRunConfiguration.name",
description = "i18n::PipelineRunConfiguration.description",
image = "ui/images/pipeline_run_config.svg",
+ category = HopMetadataCategory.RUN_CONFIG,
documentationUrl = "/metadata-types/pipeline-run-config.html",
hopMetadataPropertyType = HopMetadataPropertyType.PIPELINE_RUN_CONFIG,
supportsGlobalReplace = true)
diff --git a/engine/src/main/java/org/apache/hop/server/HopServerMeta.java
b/engine/src/main/java/org/apache/hop/server/HopServerMeta.java
index ee5935210e..1b3052c003 100644
--- a/engine/src/main/java/org/apache/hop/server/HopServerMeta.java
+++ b/engine/src/main/java/org/apache/hop/server/HopServerMeta.java
@@ -74,6 +74,7 @@ import org.apache.hop.core.xml.XmlHandler;
import org.apache.hop.i18n.BaseMessages;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -104,6 +105,7 @@ import org.w3c.dom.Node;
name = "i18n::HopServer.name",
description = "i18n::HopServer.description",
image = "ui/images/server.svg",
+ category = HopMetadataCategory.SERVERS,
documentationUrl = "/metadata-types/hop-server.html",
hopMetadataPropertyType = HopMetadataPropertyType.SERVER_DEFINITION,
supportsGlobalReplace = true)
diff --git
a/engine/src/main/java/org/apache/hop/workflow/config/WorkflowRunConfiguration.java
b/engine/src/main/java/org/apache/hop/workflow/config/WorkflowRunConfiguration.java
index 23cce5bd3c..c6a45bcd85 100644
---
a/engine/src/main/java/org/apache/hop/workflow/config/WorkflowRunConfiguration.java
+++
b/engine/src/main/java/org/apache/hop/workflow/config/WorkflowRunConfiguration.java
@@ -20,6 +20,7 @@ package org.apache.hop.workflow.config;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -30,6 +31,7 @@ import org.apache.hop.metadata.api.IHopMetadataProvider;
name = "i18n::WorkflowRunConfiguration.name",
description = "i18n::WorkflowRunConfiguration.description",
image = "ui/images/workflow_run_config.svg",
+ category = HopMetadataCategory.RUN_CONFIG,
documentationUrl = "/metadata-types/workflow-run-config.html",
hopMetadataPropertyType = HopMetadataPropertyType.WORKFLOW_RUN_CONFIG,
supportsGlobalReplace = true)
diff --git a/engine/src/main/java/org/apache/hop/www/service/WebService.java
b/engine/src/main/java/org/apache/hop/www/service/WebService.java
index 9d3171146b..be91d079b4 100644
--- a/engine/src/main/java/org/apache/hop/www/service/WebService.java
+++ b/engine/src/main/java/org/apache/hop/www/service/WebService.java
@@ -22,6 +22,7 @@ import lombok.Getter;
import lombok.Setter;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -31,6 +32,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::WebService.name",
description = "i18n::WebService.description",
image = "ui/images/webservice.svg",
+ category = HopMetadataCategory.SERVERS,
documentationUrl = "/metadata-types/web-service.html",
hopMetadataPropertyType = HopMetadataPropertyType.SERVER_WEB_SERVICE)
@Getter
diff --git
a/plugins/engines/beam/src/main/java/org/apache/hop/beam/metadata/FileDefinition.java
b/plugins/engines/beam/src/main/java/org/apache/hop/beam/metadata/FileDefinition.java
index 11f096b29c..db7cb4e9cd 100644
---
a/plugins/engines/beam/src/main/java/org/apache/hop/beam/metadata/FileDefinition.java
+++
b/plugins/engines/beam/src/main/java/org/apache/hop/beam/metadata/FileDefinition.java
@@ -28,6 +28,7 @@ import org.apache.hop.core.row.IValueMeta;
import org.apache.hop.core.row.RowMeta;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -37,6 +38,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::BeamFileDefinition.name",
description = "i18n::BeamFileDefinition.description",
image = "ui/images/folder.svg",
+ category = HopMetadataCategory.DATA_DEFINITION,
documentationUrl = "/metadata-types/beam-file-definition.html",
hopMetadataPropertyType = HopMetadataPropertyType.BEAM_FILE_DEFINITION)
public class FileDefinition extends HopMetadataBase implements Serializable,
IHopMetadata {
diff --git
a/plugins/misc/async/src/main/java/org/apache/hop/www/async/AsyncWebService.java
b/plugins/misc/async/src/main/java/org/apache/hop/www/async/AsyncWebService.java
index 0cdd2410d6..90ac371afb 100644
---
a/plugins/misc/async/src/main/java/org/apache/hop/www/async/AsyncWebService.java
+++
b/plugins/misc/async/src/main/java/org/apache/hop/www/async/AsyncWebService.java
@@ -26,6 +26,7 @@ import org.apache.hop.core.Const;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -35,6 +36,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::AsyncWebService.name",
description = "i18n::AsyncWebService.description",
image = "ui/images/server.svg",
+ category = HopMetadataCategory.SERVERS,
documentationUrl = "/metadata-types/async-web-service.html",
hopMetadataPropertyType = HopMetadataPropertyType.SERVER_WEB_SERVICE_ASYNC)
@Getter
diff --git
a/plugins/misc/mail/src/main/java/org/apache/hop/mail/metadata/MailServerConnection.java
b/plugins/misc/mail/src/main/java/org/apache/hop/mail/metadata/MailServerConnection.java
index fdda98e231..bd26967841 100644
---
a/plugins/misc/mail/src/main/java/org/apache/hop/mail/metadata/MailServerConnection.java
+++
b/plugins/misc/mail/src/main/java/org/apache/hop/mail/metadata/MailServerConnection.java
@@ -66,6 +66,7 @@ import org.apache.hop.i18n.BaseMessages;
import org.apache.hop.mail.common.MailConst;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -79,6 +80,7 @@ import org.eclipse.angus.mail.pop3.POP3SSLStore;
name = "i18n::MailServerConnection.name",
description = "i18n::MailServerConnection.description",
image = "mail.svg",
+ category = HopMetadataCategory.CONNECTIONS,
documentationUrl = "/metadata-types/mail-server-connection.html",
hopMetadataPropertyType = HopMetadataPropertyType.MAIL_SERVER_CONNECTION,
supportsGlobalReplace = true)
diff --git
a/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/pipeline/meta/PipelineLog.java
b/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/pipeline/meta/PipelineLog.java
index 42e81f361f..98f743f950 100644
---
a/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/pipeline/meta/PipelineLog.java
+++
b/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/pipeline/meta/PipelineLog.java
@@ -25,6 +25,7 @@ import lombok.Setter;
import org.apache.hop.core.logging.LogLevel;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -34,6 +35,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::PipelineLog.name",
description = "i18n::PipelineLog.description",
image = "pipeline-log.svg",
+ category = HopMetadataCategory.LOGGING,
documentationUrl = "/metadata-types/pipeline-log.html",
hopMetadataPropertyType = HopMetadataPropertyType.PIPELINE_LOG)
@Getter
diff --git
a/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/probe/meta/PipelineProbe.java
b/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/probe/meta/PipelineProbe.java
index 28ef66b13b..8cf078d0be 100644
---
a/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/probe/meta/PipelineProbe.java
+++
b/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/probe/meta/PipelineProbe.java
@@ -22,6 +22,7 @@ import java.util.ArrayList;
import java.util.List;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -31,6 +32,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::PipelineProbe.name",
description = "i18n::PipelineProbe.desciption",
image = "probe.svg",
+ category = HopMetadataCategory.LOGGING,
documentationUrl = "/metadata-types/pipeline-probe.html",
hopMetadataPropertyType = HopMetadataPropertyType.PIPELINE_PROBE)
public class PipelineProbe extends HopMetadataBase implements IHopMetadata {
diff --git
a/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/workflow/meta/WorkflowLog.java
b/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/workflow/meta/WorkflowLog.java
index 69d3ccd0f9..14c348a793 100644
---
a/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/workflow/meta/WorkflowLog.java
+++
b/plugins/misc/reflection/src/main/java/org/apache/hop/reflection/workflow/meta/WorkflowLog.java
@@ -25,6 +25,7 @@ import lombok.Setter;
import org.apache.hop.core.logging.LogLevel;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -34,6 +35,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::WorkflowLog.name",
description = "i18n::WorkflowLog.description",
image = "workflow-log.svg",
+ category = HopMetadataCategory.LOGGING,
documentationUrl = "/metadata-types/workflow-log.html",
hopMetadataPropertyType = HopMetadataPropertyType.WORKFLOW_LOG)
@Getter
diff --git
a/plugins/misc/rest/src/main/java/org/apache/hop/metadata/rest/RestConnection.java
b/plugins/misc/rest/src/main/java/org/apache/hop/metadata/rest/RestConnection.java
index 53faf5cd87..f79abe626e 100644
---
a/plugins/misc/rest/src/main/java/org/apache/hop/metadata/rest/RestConnection.java
+++
b/plugins/misc/rest/src/main/java/org/apache/hop/metadata/rest/RestConnection.java
@@ -46,6 +46,7 @@ import org.apache.hop.core.util.Utils;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -59,6 +60,7 @@ import
org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;
name = "i18n::RestConnection.name",
description = "i18n::RestConnection.description",
image = "rest.svg",
+ category = HopMetadataCategory.CONNECTIONS,
documentationUrl = "/metadata-types/rest-connection.html",
hopMetadataPropertyType = HopMetadataPropertyType.REST_CONNECTION,
supportsGlobalReplace = true)
diff --git
a/plugins/misc/static-schema/src/main/java/org/apache/hop/staticschema/metadata/SchemaDefinition.java
b/plugins/misc/static-schema/src/main/java/org/apache/hop/staticschema/metadata/SchemaDefinition.java
index a5b2e72d10..6fcc629eaa 100644
---
a/plugins/misc/static-schema/src/main/java/org/apache/hop/staticschema/metadata/SchemaDefinition.java
+++
b/plugins/misc/static-schema/src/main/java/org/apache/hop/staticschema/metadata/SchemaDefinition.java
@@ -28,6 +28,7 @@ import org.apache.hop.core.row.IValueMeta;
import org.apache.hop.core.row.RowMeta;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -37,6 +38,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::SchemaDefinition.Name",
description = "i18n::SchemaDefinition.Description",
image = "ui/images/folder.svg",
+ category = HopMetadataCategory.DATA_DEFINITION,
documentationUrl = "/metadata-types/static-schema-definition.html",
hopMetadataPropertyType = HopMetadataPropertyType.STATIC_SCHEMA_DEFINITION)
public class SchemaDefinition extends HopMetadataBase implements Serializable,
IHopMetadata {
diff --git
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSet.java
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSet.java
index a2f5984086..d6032005a3 100644
--- a/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSet.java
+++ b/plugins/misc/testing/src/main/java/org/apache/hop/testing/DataSet.java
@@ -31,6 +31,7 @@ import org.apache.hop.core.row.value.ValueMetaFactory;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -40,6 +41,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::DataSet.name",
description = "i18n::DataSet.description",
image = "dataset.svg",
+ category = HopMetadataCategory.TESTING,
documentationUrl = "/metadata-types/data-set.html",
hopMetadataPropertyType = HopMetadataPropertyType.PIPELINE_DATA_SET,
supportsGlobalReplace = true)
diff --git
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/PipelineUnitTest.java
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/PipelineUnitTest.java
index b4634ca3e5..3144d8e23d 100644
---
a/plugins/misc/testing/src/main/java/org/apache/hop/testing/PipelineUnitTest.java
+++
b/plugins/misc/testing/src/main/java/org/apache/hop/testing/PipelineUnitTest.java
@@ -32,6 +32,7 @@ import org.apache.hop.core.variables.IVariables;
import org.apache.hop.core.vfs.HopVfs;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -44,6 +45,7 @@ import org.apache.hop.testing.util.DataSetConst;
name = "i18n::PipelineUnitTest.name",
description = "i18n::PipelineUnitTest.description",
image = "Test_tube_icon.svg",
+ category = HopMetadataCategory.TESTING,
documentationUrl = "/metadata-types/pipeline-unit-test.html",
hopMetadataPropertyType = HopMetadataPropertyType.PIPELINE_UNIT_TEST,
supportsGlobalReplace = true)
diff --git
a/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3Meta.java
b/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3Meta.java
index 2b3fe399b0..3f63f15b0c 100644
--- a/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3Meta.java
+++ b/plugins/tech/aws/src/main/java/org/apache/hop/vfs/s3/metadata/S3Meta.java
@@ -22,6 +22,7 @@ import lombok.Setter;
import org.apache.hop.core.gui.plugin.GuiPlugin;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -34,6 +35,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::S3Meta.name",
description = "i18n::S3Meta.description",
image = "s3.svg",
+ category = HopMetadataCategory.FILE_STORAGE,
documentationUrl = "/metadata-types/s3-connection.html",
hopMetadataPropertyType = HopMetadataPropertyType.VFS_S3_CONNECTION)
public class S3Meta extends HopMetadataBase implements Serializable,
IHopMetadata {
diff --git
a/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataType.java
b/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataType.java
index 5ad39f001d..535c8fbefb 100644
---
a/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataType.java
+++
b/plugins/tech/azure/src/main/java/org/apache/hop/vfs/azure/metadatatype/AzureMetadataType.java
@@ -21,6 +21,7 @@ import lombok.Getter;
import lombok.Setter;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -30,6 +31,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::AzureMetadataType.Name",
description = "i18n::AzureMetadataType.Description",
image = "ui/images/authentication.svg",
+ category = HopMetadataCategory.FILE_STORAGE,
documentationUrl = "/metadata-types/azure-authentication.html",
hopMetadataPropertyType = HopMetadataPropertyType.VFS_AZURE_CONNECTION)
@Getter
diff --git
a/plugins/tech/azure/src/main/resources/org/apache/hop/vfs/azure/metadatatype/messages/messages_en_US.properties
b/plugins/tech/azure/src/main/resources/org/apache/hop/vfs/azure/metadatatype/messages/messages_en_US.properties
index 44fc1bb5a5..dc56684b92 100644
---
a/plugins/tech/azure/src/main/resources/org/apache/hop/vfs/azure/metadatatype/messages/messages_en_US.properties
+++
b/plugins/tech/azure/src/main/resources/org/apache/hop/vfs/azure/metadatatype/messages/messages_en_US.properties
@@ -17,7 +17,8 @@
#
#
-AzureMetadataType.Name=Azure Authentication
+AzureMetadataType.Name=Azure Connection
+AzureMetadataType.Description=Named Azure Blob Storage connection for VFS
AzureMetadataTypeEditor.Name.Label=Name
AzureMetadataTypeEditor.Description.Label=Description
AzureMetadataTypeEditor.StorageAccountName.Label=Storage Account Name
diff --git
a/plugins/tech/cassandra/src/main/java/org/apache/hop/databases/cassandra/metadata/CassandraConnection.java
b/plugins/tech/cassandra/src/main/java/org/apache/hop/databases/cassandra/metadata/CassandraConnection.java
index 5313bb8ac0..1fc8a61aac 100644
---
a/plugins/tech/cassandra/src/main/java/org/apache/hop/databases/cassandra/metadata/CassandraConnection.java
+++
b/plugins/tech/cassandra/src/main/java/org/apache/hop/databases/cassandra/metadata/CassandraConnection.java
@@ -32,6 +32,7 @@ import org.apache.hop.databases.cassandra.spi.Keyspace;
import org.apache.hop.databases.cassandra.util.CassandraUtils;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -42,6 +43,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::CassandraConnection.name",
description = "i18n::CassandraConnection.description",
image = "Cassandra_logo.svg",
+ category = HopMetadataCategory.CONNECTIONS,
documentationUrl = "/metadata-types/cassandra/cassandra-connection.html",
hopMetadataPropertyType = HopMetadataPropertyType.CASSANDRA_CONNECTION,
supportsGlobalReplace = true)
diff --git
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
b/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
index 61b5fd0e20..e60c2e048d 100644
---
a/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
+++
b/plugins/tech/google/src/main/java/org/apache/hop/vfs/gs/metadatatype/GoogleStorageMetadataType.java
@@ -21,6 +21,7 @@ import lombok.Getter;
import lombok.Setter;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -30,6 +31,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::GoogleStorageMetadataType.Name",
description = "i18n::GoogleStorageMetadataType.Description",
image = "ui/images/authentication.svg",
+ category = HopMetadataCategory.FILE_STORAGE,
documentationUrl = "/metadata-types/google-storage-authentication.html",
hopMetadataPropertyType = HopMetadataPropertyType.VFS_GCP_CONNECTION)
@Getter
diff --git
a/plugins/tech/google/src/main/resources/org/apache/hop/vfs/gs/metadatatype/messages/messages_en_US.properties
b/plugins/tech/google/src/main/resources/org/apache/hop/vfs/gs/metadatatype/messages/messages_en_US.properties
index ff08597ce6..eb4016d470 100644
---
a/plugins/tech/google/src/main/resources/org/apache/hop/vfs/gs/metadatatype/messages/messages_en_US.properties
+++
b/plugins/tech/google/src/main/resources/org/apache/hop/vfs/gs/metadatatype/messages/messages_en_US.properties
@@ -17,7 +17,8 @@
#
#
-GoogleStorageMetadataType.Name=Google Storage Authentication
+GoogleStorageMetadataType.Name=Google Storage Connection
+GoogleStorageMetadataType.Description=Named Google Cloud Storage connection
for VFS
GoogleStorageMetadataTypeEditor.Name.Label=Name
GoogleStorageMetadataTypeEditor.Description.Label=Description
GoogleStorageMetadataTypeEditor.StorageAccountName.Label=Storage Account Name
diff --git
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMeta.java
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMeta.java
index b74708b9f9..9be104c995 100644
---
a/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMeta.java
+++
b/plugins/tech/minio/src/main/java/org/apache/hop/vfs/minio/metadata/MinioMeta.java
@@ -24,6 +24,7 @@ import org.apache.hop.core.gui.plugin.GuiPlugin;
import org.apache.hop.core.gui.plugin.GuiWidgetElement;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -36,6 +37,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::MinioMeta.Name",
description = "i18n::MinioMeta.Description",
image = "minio.svg",
+ category = HopMetadataCategory.FILE_STORAGE,
documentationUrl = "/metadata-types/minio-connection.html",
hopMetadataPropertyType = HopMetadataPropertyType.VFS_MINIO_CONNECTION)
public class MinioMeta extends HopMetadataBase implements Serializable,
IHopMetadata {
diff --git
a/plugins/tech/mongodb/src/main/java/org/apache/hop/mongo/metadata/MongoDbConnection.java
b/plugins/tech/mongodb/src/main/java/org/apache/hop/mongo/metadata/MongoDbConnection.java
index a381ce82bb..9f6f256523 100644
---
a/plugins/tech/mongodb/src/main/java/org/apache/hop/mongo/metadata/MongoDbConnection.java
+++
b/plugins/tech/mongodb/src/main/java/org/apache/hop/mongo/metadata/MongoDbConnection.java
@@ -31,6 +31,7 @@ import org.apache.hop.core.util.Utils;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -48,6 +49,7 @@ import org.apache.hop.mongo.wrapper.MongoClientWrapperFactory;
name = "i18n::MongoDbConnection.name",
description = "i18n::MongoDbConnection.description",
image = "MongoDB_Leaf_FullColor_RGB.svg",
+ category = HopMetadataCategory.CONNECTIONS,
documentationUrl = "/metadata-types/mongodb-connection.html",
hopMetadataPropertyType = HopMetadataPropertyType.MONGODB_CONNECTION,
supportsGlobalReplace = true)
diff --git
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/model/GraphModel.java
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/model/GraphModel.java
index 6828293102..2c9fd6991f 100644
---
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/model/GraphModel.java
+++
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/model/GraphModel.java
@@ -28,6 +28,7 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -40,6 +41,7 @@ import org.json.simple.parser.JSONParser;
name = "i18n::GraphModel.name",
description = "i18n::GraphModel.description",
image = "neo4j_logo.svg",
+ category = HopMetadataCategory.DATA_DEFINITION,
documentationUrl = "/metadata-types/neo4j/neo4j-graphmodel.html",
hopMetadataPropertyType = HopMetadataPropertyType.GRAPH_MODEL)
public class GraphModel extends HopMetadataBase implements IHopMetadata {
diff --git
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/shared/NeoConnection.java
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/shared/NeoConnection.java
index 651a64c88c..fb55feec08 100644
---
a/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/shared/NeoConnection.java
+++
b/plugins/tech/neo4j/src/main/java/org/apache/hop/neo4j/shared/NeoConnection.java
@@ -38,6 +38,7 @@ import org.apache.hop.core.util.Utils;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -57,6 +58,7 @@ import org.neo4j.driver.Value;
name = "i18n::NeoConnection.name",
description = "i18n::NeoConnection.description",
image = "neo4j_logo.svg",
+ category = HopMetadataCategory.CONNECTIONS,
documentationUrl = "/metadata-types/neo4j/neo4j-connection.html",
hopMetadataPropertyType = HopMetadataPropertyType.GRAPH_CONNECTION)
public class NeoConnection extends HopMetadataBase implements IHopMetadata {
diff --git
a/plugins/tech/salesforce/src/main/java/org/apache/hop/metadata/salesforce/SalesforceConnection.java
b/plugins/tech/salesforce/src/main/java/org/apache/hop/metadata/salesforce/SalesforceConnection.java
index 0fc3894c60..c41252bc2a 100644
---
a/plugins/tech/salesforce/src/main/java/org/apache/hop/metadata/salesforce/SalesforceConnection.java
+++
b/plugins/tech/salesforce/src/main/java/org/apache/hop/metadata/salesforce/SalesforceConnection.java
@@ -23,6 +23,7 @@ import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -32,6 +33,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::SalesforceConnection.name",
description = "i18n::SalesforceConnection.description",
image = "salesforce.svg",
+ category = HopMetadataCategory.CONNECTIONS,
documentationUrl = "/metadata-types/salesforce-connection.html",
hopMetadataPropertyType = HopMetadataPropertyType.SALESFORCE_CONNECTION,
supportsGlobalReplace = true)
diff --git
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnection.java
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnection.java
index 0357decc10..69b3461f6d 100644
---
a/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnection.java
+++
b/plugins/tech/webdav/src/main/java/org/apache/hop/vfs/webdav/metadata/WebDavConnection.java
@@ -21,6 +21,7 @@ import lombok.Getter;
import lombok.Setter;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -38,6 +39,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::WebDavConnection.Name",
description = "i18n::WebDavConnection.Description",
image = "ui/images/authentication.svg",
+ category = HopMetadataCategory.FILE_STORAGE,
documentationUrl = "/metadata-types/webdav-connection.html",
hopMetadataPropertyType = HopMetadataPropertyType.VFS_WEBDAV_CONNECTION)
public class WebDavConnection extends HopMetadataBase implements Serializable,
IHopMetadata {
diff --git
a/plugins/transforms/splunk/src/main/java/org/apache/hop/splunk/SplunkConnection.java
b/plugins/transforms/splunk/src/main/java/org/apache/hop/splunk/SplunkConnection.java
index 9b1508dbbe..6a5de4fd06 100644
---
a/plugins/transforms/splunk/src/main/java/org/apache/hop/splunk/SplunkConnection.java
+++
b/plugins/transforms/splunk/src/main/java/org/apache/hop/splunk/SplunkConnection.java
@@ -26,6 +26,7 @@ import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.HopMetadataBase;
+import org.apache.hop.metadata.api.HopMetadataCategory;
import org.apache.hop.metadata.api.HopMetadataProperty;
import org.apache.hop.metadata.api.HopMetadataPropertyType;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -35,6 +36,7 @@ import org.apache.hop.metadata.api.IHopMetadata;
name = "i18n::SplunkConnection.name",
description = "i18n::SplunkConnection.description",
image = "splunk.svg",
+ category = HopMetadataCategory.CONNECTIONS,
documentationUrl = "/metadata-types/splunk-connection.html",
hopMetadataPropertyType = HopMetadataPropertyType.SPLUNK_CONNECTION,
supportsGlobalReplace = true)
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataCategories.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataCategories.java
new file mode 100644
index 0000000000..9cc258f091
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataCategories.java
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.perspective.metadata;
+
+import java.util.List;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.metadata.api.HopMetadataCategory;
+
+/**
+ * Presentation registry for metadata categories. The {@code core} layer only
carries a stable
+ * category id on each {@code @HopMetadata} type (see {@link
HopMetadataCategory}); the human
+ * readable label, the display order and the icon shown in the metadata
perspective are resolved
+ * here so that no presentation concern leaks into {@code core}.
+ *
+ * <p>Categories declared below are shown in declaration order. An unknown
(but non-empty) category
+ * id sorts after all known categories (alphabetically by id); the empty
category id is the "Other"
+ * bucket and always sorts last.
+ */
+public final class MetadataCategories {
+
+ private static final Class<?> PKG = MetadataPerspective.class; // i18n
+
+ /** The empty category id: types without a category land in the "Other"
bucket. */
+ public static final String OTHER = "";
+
+ private static final String OTHER_IMAGE = "ui/images/metadata.svg";
+
+ /** A known category: its id, the i18n key for its label and the icon shown
in the tree. */
+ private record CategoryInfo(String id, String labelKey, String image) {}
+
+ /** Declaration order is the display order in the tree, the overview and the
new-type menu. */
+ private static final List<CategoryInfo> ORDERED =
+ List.of(
+ new CategoryInfo(
+ HopMetadataCategory.CONNECTIONS,
+ "MetadataPerspective.Category.Connections",
+ "ui/images/database.svg"),
+ new CategoryInfo(
+ HopMetadataCategory.FILE_STORAGE,
+ "MetadataPerspective.Category.FileStorage",
+ "ui/images/location.svg"),
+ new CategoryInfo(
+ HopMetadataCategory.RUN_CONFIG,
+ "MetadataPerspective.Category.RunConfig",
+ "ui/images/gear.svg"),
+ new CategoryInfo(
+ HopMetadataCategory.SERVERS,
+ "MetadataPerspective.Category.Servers",
+ "ui/images/server.svg"),
+ new CategoryInfo(
+ HopMetadataCategory.EXECUTION,
+ "MetadataPerspective.Category.Execution",
+ "ui/images/analyzer.svg"),
+ new CategoryInfo(
+ HopMetadataCategory.LOGGING,
+ "MetadataPerspective.Category.Logging",
+ "ui/images/log.svg"),
+ new CategoryInfo(
+ HopMetadataCategory.TESTING,
+ "MetadataPerspective.Category.Testing",
+ "ui/images/catalog.svg"),
+ new CategoryInfo(
+ HopMetadataCategory.DATA_DEFINITION,
+ "MetadataPerspective.Category.DataDefinition",
+ "ui/images/partition_schema.svg"),
+ new CategoryInfo(
+ HopMetadataCategory.VARIABLES,
+ "MetadataPerspective.Category.Variables",
+ "ui/images/variable.svg"));
+
+ private MetadataCategories() {
+ // Utility class
+ }
+
+ /** Normalizes a (possibly null) category id to either a known/unknown id or
{@link #OTHER}. */
+ public static String normalize(String categoryId) {
+ return Utils.isEmpty(categoryId) ? OTHER : categoryId;
+ }
+
+ private static CategoryInfo find(String categoryId) {
+ for (CategoryInfo info : ORDERED) {
+ if (info.id().equals(categoryId)) {
+ return info;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Sort key for a category id. Known categories keep their declaration
order; unknown non-empty
+ * ids come next; the "Other" bucket is always last.
+ */
+ public static int orderOf(String categoryId) {
+ String id = normalize(categoryId);
+ if (OTHER.equals(id)) {
+ return Integer.MAX_VALUE;
+ }
+ for (int i = 0; i < ORDERED.size(); i++) {
+ if (ORDERED.get(i).id().equals(id)) {
+ return i;
+ }
+ }
+ // Unknown but non-empty category: after all known ones, before "Other".
+ return ORDERED.size();
+ }
+
+ /** The translated label for a category id, suitable for display as a
tree/overview heading. */
+ public static String labelFor(String categoryId) {
+ String id = normalize(categoryId);
+ if (OTHER.equals(id)) {
+ return BaseMessages.getString(PKG, "MetadataPerspective.Category.Other");
+ }
+ CategoryInfo info = find(id);
+ if (info != null) {
+ return BaseMessages.getString(PKG, info.labelKey());
+ }
+ // Unknown category id from a third-party plugin: show the raw id rather
than nothing.
+ return id;
+ }
+
+ /** The icon path for a category id, falling back to a generic metadata
icon. */
+ public static String imageFor(String categoryId) {
+ CategoryInfo info = find(normalize(categoryId));
+ return info != null ? info.image() : OTHER_IMAGE;
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataOverview.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataOverview.java
new file mode 100644
index 0000000000..9f6cc37602
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataOverview.java
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.perspective.metadata;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.ui.core.ConstUi;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CLabel;
+import org.eclipse.swt.custom.ScrolledComposite;
+import org.eclipse.swt.layout.FillLayout;
+import org.eclipse.swt.layout.GridData;
+import org.eclipse.swt.layout.GridLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Label;
+
+/**
+ * Landing page shown in the metadata perspective whenever no editor tab is
open. It presents the
+ * full catalog of metadata types (including types that have no items yet,
which are hidden in the
+ * tree) grouped by category, each with its description, item count and a
"New" button. It therefore
+ * doubles as the discovery surface for the perspective.
+ */
+public class MetadataOverview extends Composite {
+
+ private static final Class<?> PKG = MetadataPerspective.class; // i18n
+
+ /** Immutable view of a single metadata type, used to render one card. */
+ public record TypeCard(
+ String categoryId,
+ String key,
+ String name,
+ String description,
+ String image,
+ ClassLoader imageClassLoader,
+ int count) {}
+
+ private final MetadataPerspective perspective;
+ private final ScrolledComposite scrolledComposite;
+ private Composite content;
+
+ public MetadataOverview(Composite parent, MetadataPerspective perspective) {
+ super(parent, SWT.NONE);
+ this.perspective = perspective;
+ setLayout(new FillLayout());
+ PropsUi.setLook(this);
+
+ scrolledComposite = new ScrolledComposite(this, SWT.V_SCROLL |
SWT.H_SCROLL);
+ scrolledComposite.setExpandHorizontal(true);
+ scrolledComposite.setExpandVertical(true);
+ PropsUi.setLook(scrolledComposite);
+ }
+
+ /** Rebuilds the overview from the given cards (grouped and ordered by
category). */
+ public void setCards(List<TypeCard> cards) {
+ if (content != null && !content.isDisposed()) {
+ content.dispose();
+ }
+
+ content = new Composite(scrolledComposite, SWT.NONE);
+ GridLayout layout = new GridLayout(1, false);
+ layout.marginWidth = 2 * PropsUi.getFormMargin();
+ layout.marginHeight = 2 * PropsUi.getFormMargin();
+ layout.verticalSpacing = PropsUi.getMargin();
+ content.setLayout(layout);
+ PropsUi.setLook(content);
+
+ Label title = new Label(content, SWT.NONE);
+ title.setText(BaseMessages.getString(PKG,
"MetadataPerspective.Overview.Title"));
+ title.setFont(GuiResource.getInstance().getFontLarge());
+ PropsUi.setLook(title);
+
+ Label subtitle = new Label(content, SWT.WRAP);
+ subtitle.setText(BaseMessages.getString(PKG,
"MetadataPerspective.Overview.Subtitle"));
+ subtitle.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+ PropsUi.setLook(subtitle);
+
+ // Group the cards by category id and order the categories the same way
the tree does.
+ Map<String, List<TypeCard>> cardsByCategory = new LinkedHashMap<>();
+ for (TypeCard card : cards) {
+ cardsByCategory.computeIfAbsent(card.categoryId(), k -> new
ArrayList<>()).add(card);
+ }
+ List<String> categoryIds = new ArrayList<>(cardsByCategory.keySet());
+ categoryIds.sort(
+ Comparator.comparingInt(MetadataCategories::orderOf)
+ .thenComparing(MetadataCategories::labelFor));
+
+ for (String categoryId : categoryIds) {
+ // CLabel (not Label) so the icon and the category name are shown
together; a plain SWT Label
+ // renders only the image when both an image and text are set.
+ CLabel categoryLabel = new CLabel(content, SWT.NONE);
+ categoryLabel.setText(MetadataCategories.labelFor(categoryId));
+ categoryLabel.setImage(
+ GuiResource.getInstance()
+ .getImage(
+ MetadataCategories.imageFor(categoryId),
+ getClass().getClassLoader(),
+ ConstUi.SMALL_ICON_SIZE,
+ ConstUi.SMALL_ICON_SIZE));
+ categoryLabel.setFont(GuiResource.getInstance().getFontMediumBold());
+ GridData categoryGd = new GridData(SWT.FILL, SWT.CENTER, true, false);
+ categoryGd.verticalIndent = PropsUi.getMargin();
+ categoryLabel.setLayoutData(categoryGd);
+ PropsUi.setLook(categoryLabel);
+
+ for (TypeCard card : cardsByCategory.get(categoryId)) {
+ createCard(content, card);
+ }
+ }
+
+ scrolledComposite.setContent(content);
+ scrolledComposite.setMinSize(content.computeSize(SWT.DEFAULT,
SWT.DEFAULT));
+ content.layout();
+ scrolledComposite.layout();
+ }
+
+ private void createCard(Composite parent, TypeCard card) {
+ Composite row = new Composite(parent, SWT.NONE);
+ GridLayout rowLayout = new GridLayout(3, false);
+ rowLayout.marginWidth = PropsUi.getMargin();
+ rowLayout.marginHeight = PropsUi.getMargin();
+ row.setLayout(rowLayout);
+ GridData rowGd = new GridData(SWT.FILL, SWT.CENTER, true, false);
+ rowGd.horizontalIndent = ConstUi.MEDIUM_ICON_SIZE;
+ row.setLayoutData(rowGd);
+ PropsUi.setLook(row);
+
+ Label icon = new Label(row, SWT.NONE);
+ icon.setImage(
+ GuiResource.getInstance()
+ .getImage(
+ card.image(),
+ card.imageClassLoader(),
+ ConstUi.MEDIUM_ICON_SIZE,
+ ConstUi.MEDIUM_ICON_SIZE));
+ icon.setLayoutData(new GridData(SWT.LEFT, SWT.TOP, false, false));
+ PropsUi.setLook(icon);
+
+ Composite textBlock = new Composite(row, SWT.NONE);
+ GridLayout textLayout = new GridLayout(1, false);
+ textLayout.marginWidth = 0;
+ textLayout.marginHeight = 0;
+ textBlock.setLayout(textLayout);
+ textBlock.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+ PropsUi.setLook(textBlock);
+
+ Label name = new Label(textBlock, SWT.NONE);
+ name.setText(card.name() + " (" + card.count() + ")");
+ name.setFont(GuiResource.getInstance().getFontBold());
+ name.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false));
+ PropsUi.setLook(name);
+ // Clicking the name navigates to the type in the tree (when it is visible
there).
+ name.addListener(SWT.MouseUp, e -> perspective.selectType(card.key()));
+
+ if (card.description() != null && !card.description().isEmpty()) {
+ Label description = new Label(textBlock, SWT.WRAP);
+ description.setText(card.description());
+ description.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true,
false));
+ PropsUi.setLook(description);
+ }
+
+ Button newButton = new Button(row, SWT.PUSH);
+ newButton.setText(BaseMessages.getString(PKG,
"MetadataPerspective.Overview.New.Button"));
+ newButton.setLayoutData(new GridData(SWT.RIGHT, SWT.CENTER, false, false));
+ newButton.addListener(
+ SWT.Selection, e ->
perspective.createNewMetadataFromOverview(card.key()));
+ PropsUi.setLook(newButton);
+ }
+}
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
index e79132e93d..a1b452f6f1 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataPerspective.java
@@ -20,8 +20,12 @@ package org.apache.hop.ui.hopgui.perspective.metadata;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
+import java.util.Map;
import java.util.Set;
import lombok.Getter;
import org.apache.hop.core.Const;
@@ -37,6 +41,9 @@ import org.apache.hop.core.plugins.PluginRegistry;
import org.apache.hop.core.search.ISearchable;
import org.apache.hop.core.util.TranslateUtil;
import org.apache.hop.core.util.Utils;
+import org.apache.hop.history.AuditList;
+import org.apache.hop.history.AuditManager;
+import org.apache.hop.history.IAuditManager;
import org.apache.hop.i18n.BaseMessages;
import org.apache.hop.metadata.api.HopMetadata;
import org.apache.hop.metadata.api.IHopMetadata;
@@ -59,6 +66,7 @@ import org.apache.hop.ui.core.dialog.ErrorDialog;
import org.apache.hop.ui.core.dialog.MessageBox;
import org.apache.hop.ui.core.gui.GuiResource;
import org.apache.hop.ui.core.gui.GuiToolbarWidgets;
+import org.apache.hop.ui.core.gui.HopNamespace;
import org.apache.hop.ui.core.gui.IToolbarContainer;
import org.apache.hop.ui.core.metadata.MetadataEditor;
import org.apache.hop.ui.core.metadata.MetadataFileType;
@@ -90,6 +98,7 @@ import org.eclipse.swt.custom.CTabFolder2Adapter;
import org.eclipse.swt.custom.CTabFolderEvent;
import org.eclipse.swt.custom.CTabItem;
import org.eclipse.swt.custom.SashForm;
+import org.eclipse.swt.custom.StackLayout;
import org.eclipse.swt.custom.TreeEditor;
import org.eclipse.swt.dnd.DND;
import org.eclipse.swt.dnd.DragSource;
@@ -100,6 +109,7 @@ import org.eclipse.swt.dnd.DropTargetAdapter;
import org.eclipse.swt.dnd.DropTargetEvent;
import org.eclipse.swt.graphics.GC;
import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.graphics.Point;
import org.eclipse.swt.graphics.Rectangle;
import org.eclipse.swt.layout.FormAttachment;
import org.eclipse.swt.layout.FormData;
@@ -133,8 +143,22 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
public static final Class<?> PKG = MetadataPerspective.class; // i18n
private static final String METADATA_PERSPECTIVE_TREE = "Metadata
perspective tree";
+ /**
+ * Audit-trail key under which the project's (possibly empty) virtual
folders are stored so they
+ * survive refresh and restart. Each entry is {@code typeKey +
FOLDER_AUDIT_SEPARATOR + path}.
+ */
+ private static final String FOLDER_AUDIT_TYPE = "metadata-virtual-folders";
+
+ private static final String FOLDER_AUDIT_SEPARATOR = "\t";
+
+ /**
+ * Audit-trail key under which the tree-panel sash weights are stored
(global, not per-project).
+ */
+ private static final String SASH_WEIGHTS_AUDIT_TYPE =
"metadata-perspective-tree-width";
+
public static final String GUI_PLUGIN_TOOLBAR_PARENT_ID =
"MetadataPerspective-Toolbar";
+ public static final String TOOLBAR_ITEM_NEW_TYPE =
"MetadataPerspective-Toolbar-09000-NewType";
public static final String TOOLBAR_ITEM_NEW =
"MetadataPerspective-Toolbar-10000-New";
public static final String TOOLBAR_ITEM_EDIT =
"MetadataPerspective-Toolbar-10010-Edit";
public static final String TOOLBAR_ITEM_DUPLICATE =
"MetadataPerspective-Toolbar-10030-Duplicate";
@@ -144,15 +168,31 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
"MetadataPerspective-Toolbar-10060-ExpandAll";
public static final String TOOLBAR_ITEM_COLLAPSE_ALL =
"MetadataPerspective-Toolbar-10070-CollapseAll";
+ public static final String TOOLBAR_ITEM_SHOW_EMPTY =
+ "MetadataPerspective-Toolbar-10080-ShowEmpty";
public static final String TOOLBAR_ITEM_REFRESH =
"MetadataPerspective-Toolbar-10100-Refresh";
private static final String KEY_HELP = "Help";
private static final String KEY_TYPE = "type";
public static final String FILE = "File";
public static final String FOLDER = "Folder";
+
+ /** A metadata type node (groups the items of one {@code @HopMetadata}
type). */
+ public static final String TYPE = "MetadataItem";
+
+ /** A category header node grouping several metadata types. */
+ public static final String CATEGORY = "Category";
+
public static final String VIRTUAL_PATH = "virtualPath";
public static final String ERROR = "Error";
+ /** Icons for the "show empty types" toggle button (icon reflects the action
it will perform). */
+ private static final String IMAGE_SHOW_ALL = "ui/images/show-all.svg";
+
+ private static final String IMAGE_SHOW_SELECTED =
"ui/images/show-selected.svg";
+
+ private static final int FILTER_DEBOUNCE_MS = 250;
+
@Getter private static MetadataPerspective instance;
private HopGui hopGui;
@@ -160,10 +200,31 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
private Tree tree;
private TreeEditor treeEditor;
private CTabFolder tabFolder;
+ private Composite editorArea;
+ private StackLayout editorStackLayout;
+ private MetadataOverview overview;
private GuiToolbarWidgets toolBarWidgets;
private Text searchText;
+ private Label resultCountLabel;
private String currentSearchFilter = "";
+ /** When {@code false} (the default) metadata types and categories with no
items are hidden. */
+ private boolean showEmptyTypes = false;
+
+ /**
+ * In-memory view of all metadata types and their items; loaded once per
{@link #reloadModel()}.
+ */
+ private final List<MetadataTypeModel> typeModels = new ArrayList<>();
+
+ /** Stable node ids whose default expand state has been seeded into
TreeMemory this session. */
+ private final Set<String> treeStateSeeded = new HashSet<>();
+
+ /** Debounced search action so we don't rebuild the tree on every keystroke.
*/
+ private final Runnable filterRunnable = this::applyFilter;
+
+ /** Debounced save of the tree-panel sash weights so we don't write on every
drag pixel. */
+ private final Runnable sashWeightsSaver = this::saveSashWeights;
+
private final List<MetadataEditor<?>> editors = new ArrayList<>();
private final MetadataFileType metadataFileType;
@@ -212,19 +273,26 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
sash.setLayoutData(new FormDataBuilder().fullSize().result());
createTree(sash);
- createTabFolder(sash);
+
+ // The right-hand side stacks the editor tab folder and an
overview/landing page; the overview
+ // is shown whenever no editor tab is open.
+ editorArea = new Composite(sash, SWT.NONE);
+ editorStackLayout = new StackLayout();
+ editorArea.setLayout(editorStackLayout);
+ PropsUi.setLook(editorArea);
+ overview = new MetadataOverview(editorArea, this);
+ createTabFolder(editorArea);
+ editorStackLayout.topControl = overview;
+ editorArea.layout();
sash.setWeights(new int[] {20, 80});
+ // Restore the saved tree-panel width and persist it whenever the divider
is dragged.
+ loadSashWeights();
+ editorArea.addListener(SWT.Resize, e -> scheduleSaveSashWeights());
this.refresh();
this.updateSelection();
- // Set the top level items in the tree to be expanded
- //
- for (TreeItem item : tree.getItems()) {
- TreeMemory.getInstance().storeExpanded(METADATA_PERSPECTIVE_TREE, item,
true);
- }
-
// refresh the metadata when it changes.
//
hopGui
@@ -273,12 +341,22 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
// Add search listener
searchText.addListener(SWT.Modify, e -> filterTree());
+ // Result count shown under the search box while a filter is active (empty
otherwise)
+ resultCountLabel = new Label(treeComposite, SWT.NONE);
+ PropsUi.setLook(resultCountLabel);
+ FormData countFormData = new FormData();
+ countFormData.left = new FormAttachment(0, 0);
+ countFormData.top = new FormAttachment(searchText, 0);
+ countFormData.right = new FormAttachment(100, 0);
+ countFormData.height = 0; // collapsed until a search filter is active
(see updateResultCount)
+ resultCountLabel.setLayoutData(countFormData);
+
// Create a composite with toolbar and tree for the border
Composite composite = new Composite(treeComposite, SWT.BORDER);
composite.setLayout(new FormLayout());
FormData layoutData = new FormData();
layoutData.left = new FormAttachment(0, 0);
- layoutData.top = new FormAttachment(searchText, PropsUi.getMargin());
+ layoutData.top = new FormAttachment(resultCountLabel, PropsUi.getMargin());
layoutData.right = new FormAttachment(100, 0);
layoutData.bottom = new FormAttachment(100, 0);
composite.setLayoutData(layoutData);
@@ -306,12 +384,8 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
SWT.DefaultSelection,
event -> {
TreeItem treeItem = tree.getSelection()[0];
- if (treeItem != null && treeItem.getData(KEY_TYPE).equals(FILE)) {
- if (treeItem.getParentItem() == null) {
- onNewMetadata();
- } else {
- onEditMetadata();
- }
+ if (treeItem != null && FILE.equals(treeItem.getData(KEY_TYPE))) {
+ onEditMetadata();
}
});
@@ -329,7 +403,7 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
MenuItem menuItem;
switch ((String) treeItem.getData(KEY_TYPE)) {
- case "MetadataItem", FOLDER:
+ case TYPE:
menuItem = new MenuItem(menu, SWT.POP_UP);
menuItem.setText(BaseMessages.getString(PKG,
"MetadataPerspective.Menu.New"));
menuItem.addListener(SWT.Selection, e -> onNewMetadata());
@@ -338,6 +412,24 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
menuItem.addListener(SWT.Selection, e -> createNewFolder());
new MenuItem(menu, SWT.SEPARATOR);
break;
+ case FOLDER:
+ menuItem = new MenuItem(menu, SWT.POP_UP);
+ menuItem.setText(BaseMessages.getString(PKG,
"MetadataPerspective.Menu.New"));
+ menuItem.addListener(SWT.Selection, e -> onNewMetadata());
+ menuItem = new MenuItem(menu, SWT.POP_UP);
+ menuItem.setText(BaseMessages.getString(PKG,
"MetadataPerspective.Menu.NewFolder"));
+ menuItem.addListener(SWT.Selection, e -> createNewFolder());
+ new MenuItem(menu, SWT.SEPARATOR);
+ menuItem = new MenuItem(menu, SWT.POP_UP);
+ menuItem.setText(
+ BaseMessages.getString(PKG,
"MetadataPerspective.Menu.DeleteFolder"));
+ menuItem.setImage(GuiResource.getInstance().getImageDelete());
+ menuItem.addListener(SWT.Selection, e -> onDeleteFolder());
+ new MenuItem(menu, SWT.SEPARATOR);
+ break;
+ case CATEGORY:
+ addNewTypeMenuItemsForCategory(menu, (String)
treeItem.getData());
+ break;
case FILE:
menuItem = new MenuItem(menu, SWT.POP_UP);
menuItem.setText(BaseMessages.getString(PKG,
"MetadataPerspective.Menu.Edit"));
@@ -367,10 +459,13 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
break;
}
- menuItem = new MenuItem(menu, SWT.POP_UP);
- menuItem.setText(BaseMessages.getString(PKG,
"MetadataPerspective.Menu.Help"));
- menuItem.setImage(GuiResource.getInstance().getImageHelp());
- menuItem.addListener(SWT.Selection, e -> onHelpMetadata());
+ // Help is not meaningful on a category header.
+ if (!CATEGORY.equals(treeItem.getData(KEY_TYPE))) {
+ menuItem = new MenuItem(menu, SWT.POP_UP);
+ menuItem.setText(BaseMessages.getString(PKG,
"MetadataPerspective.Menu.Help"));
+ menuItem.setImage(GuiResource.getInstance().getImageHelp());
+ menuItem.addListener(SWT.Selection, e -> onHelpMetadata());
+ }
tree.setMenu(menu);
menu.setVisible(true);
@@ -390,8 +485,9 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
treeEditor.horizontalAlignment = SWT.LEFT;
treeEditor.grabHorizontal = true;
- // Remember tree node expanded/Collapsed
- TreeMemory.addTreeListener(tree, METADATA_PERSPECTIVE_TREE);
+ // Remember expand/collapse within the session (shared TreeMemory, keyed
by stable node ids).
+ tree.addListener(SWT.Expand, e -> recordTreeState((TreeItem) e.item,
true));
+ tree.addListener(SWT.Collapse, e -> recordTreeState((TreeItem) e.item,
false));
// Drag and drop: reorganize within tree (same type only) and drag to
canvas to open
createTreeDragSource(tree);
@@ -403,11 +499,18 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
* class item.
*/
private String getObjectKey(TreeItem item) {
- TreeItem root = item;
- while (root.getParentItem() != null) {
- root = root.getParentItem();
+ // Walk up to the nearest node that carries a metadata type key: a type
node or a folder node
+ // (both store the key in their data). Category and file nodes do not, so
we keep climbing.
+ // Returns null when no type ancestor exists (e.g. a category header is
selected).
+ TreeItem current = item;
+ while (current != null) {
+ String nodeType = (String) current.getData(KEY_TYPE);
+ if (TYPE.equals(nodeType) || FOLDER.equals(nodeType)) {
+ return (String) current.getData();
+ }
+ current = current.getParentItem();
}
- return (String) root.getData();
+ return null;
}
private void createTreeDragSource(Tree tree) {
@@ -591,7 +694,7 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
SWT.Selection,
e -> {
if (sash.getMaximizedControl() == null) {
- sash.setMaximizedControl(tabFolder);
+ sash.setMaximizedControl(editorArea);
item.setImage(GuiResource.getInstance().getImageMinimizePanel());
} else {
sash.setMaximizedControl(null);
@@ -683,6 +786,69 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
tabFolder.setSelection(tabItem);
editor.setFocus();
+
+ // An editor is open: show the tab folder instead of the overview page.
+ showEditors();
+ }
+
+ /** Shows the editor tab folder (used when at least one editor tab is open).
*/
+ private void showEditors() {
+ if (editorStackLayout == null || editorArea == null ||
editorArea.isDisposed()) {
+ return;
+ }
+ if (editorStackLayout.topControl != tabFolder) {
+ editorStackLayout.topControl = tabFolder;
+ editorArea.layout();
+ }
+ }
+
+ /**
+ * Shows the overview/landing page (used when no editor tab is open) and
refreshes its content.
+ */
+ private void showOverview() {
+ if (editorStackLayout == null || editorArea == null ||
editorArea.isDisposed()) {
+ return;
+ }
+ if (overview != null && !overview.isDisposed()) {
+ overview.setCards(buildOverviewCards());
+ }
+ if (editorStackLayout.topControl != overview) {
+ editorStackLayout.topControl = overview;
+ editorArea.layout();
+ }
+ }
+
+ /** Builds the overview cards (one per metadata type) from the cached model.
*/
+ private List<MetadataOverview.TypeCard> buildOverviewCards() {
+ List<MetadataOverview.TypeCard> cards = new ArrayList<>();
+ for (MetadataTypeModel typeModel : typeModels) {
+ cards.add(
+ new MetadataOverview.TypeCard(
+ typeModel.categoryId,
+ typeModel.key,
+ typeModel.typeName,
+ typeModel.description,
+ typeModel.image,
+ typeModel.metadataClass.getClassLoader(),
+ typeModel.items.size()));
+ }
+ return cards;
+ }
+
+ /** Selects (and reveals) the tree node for the given metadata type, if it
is currently shown. */
+ public void selectType(String key) {
+ TreeItem typeItem = findTypeItem(key);
+ if (typeItem != null) {
+ typeItem.setExpanded(true);
+ tree.setSelection(typeItem);
+ tree.showSelection();
+ updateSelection();
+ }
+ }
+
+ /** Creates a new metadata item of the given type from the overview page. */
+ public void createNewMetadataFromOverview(String key) {
+ createMetadataOfType(key, "");
}
/**
@@ -748,6 +914,101 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
}
}
+ /**
+ * Global "new" button: opens a category-grouped menu of every metadata type
so a new item can be
+ * created regardless of the current tree selection (including types whose
node is currently
+ * hidden because it has no items).
+ */
+ @GuiToolbarElement(
+ root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+ id = TOOLBAR_ITEM_NEW_TYPE,
+ toolTip = "i18n::MetadataPerspective.ToolbarElement.NewType.Tooltip",
+ image = "ui/images/add.svg")
+ public void onNewMetadataType() {
+ Menu menu = new Menu(tree);
+ addNewTypeMenuItems(menu, null);
+ // Position the drop-down just below the toolbar button.
+ ToolItem toolItem = toolBarWidgets.findToolItem(TOOLBAR_ITEM_NEW_TYPE);
+ if (toolItem != null && !toolItem.getParent().isDisposed()) {
+ ToolBar toolBar = toolItem.getParent();
+ Rectangle bounds = toolItem.getBounds();
+ Point location = toolBar.toDisplay(bounds.x, bounds.y + bounds.height);
+ menu.setLocation(location);
+ }
+ menu.setVisible(true);
+ }
+
+ /**
+ * Adds one "New <type>" item per metadata type to {@code menu},
optionally restricted to a
+ * single category. Types are grouped by category (in the configured
category order, with a
+ * separator between groups) and keep the model's name order within a group.
+ */
+ private void addNewTypeMenuItems(Menu menu, String onlyCategoryId) {
+ List<String> categoryIds = new ArrayList<>();
+ for (MetadataTypeModel typeModel : typeModels) {
+ if ((onlyCategoryId == null ||
onlyCategoryId.equals(typeModel.categoryId))
+ && !categoryIds.contains(typeModel.categoryId)) {
+ categoryIds.add(typeModel.categoryId);
+ }
+ }
+ categoryIds.sort(
+ Comparator.comparingInt(MetadataCategories::orderOf)
+ .thenComparing(MetadataCategories::labelFor));
+
+ boolean needSeparator = false;
+ for (String categoryId : categoryIds) {
+ if (needSeparator) {
+ new MenuItem(menu, SWT.SEPARATOR);
+ }
+ for (MetadataTypeModel typeModel : typeModels) {
+ if (!typeModel.categoryId.equals(categoryId)) {
+ continue;
+ }
+ MenuItem typeMenuItem = new MenuItem(menu, SWT.POP_UP);
+ typeMenuItem.setText(
+ BaseMessages.getString(PKG, "MetadataPerspective.Menu.NewOfType",
typeModel.typeName));
+ typeMenuItem.setImage(
+ GuiResource.getInstance()
+ .getImage(
+ typeModel.image,
+ typeModel.metadataClass.getClassLoader(),
+ ConstUi.SMALL_ICON_SIZE,
+ ConstUi.SMALL_ICON_SIZE));
+ String key = typeModel.key;
+ typeMenuItem.addListener(SWT.Selection, e -> createMetadataOfType(key,
""));
+ }
+ needSeparator = true;
+ }
+ }
+
+ /** Adds "New <type>" items for a single category (used by the
category context menu). */
+ private void addNewTypeMenuItemsForCategory(Menu menu, String categoryId) {
+ addNewTypeMenuItems(menu, categoryId);
+ }
+
+ /**
+ * Toggle button controlling whether metadata types and categories with no
items are shown in the
+ * tree. The icon swaps to reflect the action a click will perform.
+ */
+ @GuiToolbarElement(
+ root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
+ id = TOOLBAR_ITEM_SHOW_EMPTY,
+ toolTip = "i18n::MetadataPerspective.ToolbarElement.ShowEmpty.Tooltip",
+ image = IMAGE_SHOW_ALL)
+ public void onToggleShowEmpty() {
+ showEmptyTypes = !showEmptyTypes;
+ toolBarWidgets.setToolbarItemImage(
+ TOOLBAR_ITEM_SHOW_EMPTY, showEmptyTypes ? IMAGE_SHOW_SELECTED :
IMAGE_SHOW_ALL);
+ toolBarWidgets.setToolbarItemToolTip(
+ TOOLBAR_ITEM_SHOW_EMPTY,
+ BaseMessages.getString(
+ PKG,
+ showEmptyTypes
+ ? "MetadataPerspective.ToolbarElement.HideEmpty.Tooltip"
+ : "MetadataPerspective.ToolbarElement.ShowEmpty.Tooltip"));
+ renderTree();
+ }
+
@GuiToolbarElement(
root = GUI_PLUGIN_TOOLBAR_PARENT_ID,
id = TOOLBAR_ITEM_NEW,
@@ -759,34 +1020,32 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
}
TreeItem treeItem = tree.getSelection()[0];
- if (treeItem != null) {
- String objectKey;
- if (treeItem.getParentItem() == null) {
- objectKey = (String) treeItem.getData();
- } else {
- objectKey = (String) treeItem.getParentItem().getData();
- }
-
- try {
- IHopMetadataProvider metadataProvider = hopGui.getMetadataProvider();
- Class<IHopMetadata> metadataClass =
metadataProvider.getMetadataClassForKey(objectKey);
- MetadataManager<IHopMetadata> manager =
- new MetadataManager<>(
- HopGui.getInstance().getVariables(),
- metadataProvider,
- metadataClass,
- hopGui.getShell());
-
- manager.newMetadataWithEditor((String) treeItem.getData(VIRTUAL_PATH));
+ if (treeItem == null) {
+ return;
+ }
+ String objectKey = getObjectKey(treeItem);
+ if (objectKey == null) {
+ // A category header (or label) is selected: there is no single type to
create here. Use the
+ // global "new" button (or its menu) to create an item of an arbitrary
type.
+ return;
+ }
+ createMetadataOfType(objectKey, Const.NVL((String)
treeItem.getData(VIRTUAL_PATH), ""));
+ }
- hopGui.getEventsHandler().fire(HopGuiEvents.MetadataCreated.name());
- } catch (Exception e) {
- new ErrorDialog(
- getShell(),
- BaseMessages.getString(PKG,
"MetadataPerspective.CreateMetadata.Error.Header"),
- BaseMessages.getString(PKG,
"MetadataPerspective.CreateMetadata.Error.Message"),
- e);
- }
+ /**
+ * Creates a new metadata item of the given type at the given virtual path
and opens its editor.
+ */
+ private void createMetadataOfType(String objectKey, String virtualPath) {
+ try {
+ MetadataManager<IHopMetadata> manager = getMetadataManager(objectKey);
+ manager.newMetadataWithEditor(Const.NVL(virtualPath, ""));
+ hopGui.getEventsHandler().fire(HopGuiEvents.MetadataCreated.name());
+ } catch (Exception e) {
+ new ErrorDialog(
+ getShell(),
+ BaseMessages.getString(PKG,
"MetadataPerspective.CreateMetadata.Error.Header"),
+ BaseMessages.getString(PKG,
"MetadataPerspective.CreateMetadata.Error.Message"),
+ e);
}
refresh();
}
@@ -1368,8 +1627,12 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
TreeItem treeItem = tree.getSelection()[0];
- // No delete on folder
- if (!treeItem.getData(KEY_TYPE).equals(FILE)) {
+ // Folders have their own delete handling; categories/labels can't be
deleted.
+ if (FOLDER.equals(treeItem.getData(KEY_TYPE))) {
+ onDeleteFolder();
+ return;
+ }
+ if (!FILE.equals(treeItem.getData(KEY_TYPE))) {
return;
}
@@ -1581,29 +1844,21 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
}
public void onHelpMetadata() {
-
if (tree.getSelectionCount() != 1) {
return;
}
- String objectKey = null;
- TreeItem treeItem = tree.getSelection()[0];
- if (treeItem != null) {
- if (treeItem.getParentItem() != null) {
- treeItem = treeItem.getParentItem();
- }
- objectKey = (String) treeItem.getData();
+ String objectKey = getObjectKey(tree.getSelection()[0]);
+ if (objectKey == null) {
+ return;
}
-
- if (objectKey != null) {
- try {
- MetadataManager<IHopMetadata> manager = getMetadataManager(objectKey);
- HopMetadata annotation =
manager.getManagedClass().getAnnotation(HopMetadata.class);
- IPlugin plugin =
- PluginRegistry.getInstance().getPlugin(MetadataPluginType.class,
annotation.key());
- HelpUtils.openHelp(getShell(), plugin);
- } catch (Exception ex) {
- new ErrorDialog(getShell(), ERROR, "Error opening URL", ex);
- }
+ try {
+ MetadataManager<IHopMetadata> manager = getMetadataManager(objectKey);
+ HopMetadata annotation =
manager.getManagedClass().getAnnotation(HopMetadata.class);
+ IPlugin plugin =
+ PluginRegistry.getInstance().getPlugin(MetadataPluginType.class,
annotation.key());
+ HelpUtils.openHelp(getShell(), plugin);
+ } catch (Exception ex) {
+ new ErrorDialog(getShell(), ERROR, "Error opening URL", ex);
}
}
@@ -1651,6 +1906,7 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
try {
for (TreeItem item : tree.getItems()) {
expandTreeItem(item, true);
+ recordAllTreeState(item);
}
} finally {
tree.setRedraw(true);
@@ -1671,6 +1927,7 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
try {
for (TreeItem item : tree.getItems()) {
expandTreeItem(item, false);
+ recordAllTreeState(item);
}
} finally {
tree.setRedraw(true);
@@ -1685,139 +1942,382 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
@GuiKeyboardShortcut(key = SWT.F5)
@GuiOsxKeyboardShortcut(key = SWT.F5)
public void refresh() {
- try {
- tree.setRedraw(false);
- tree.removeAll();
+ reloadModel();
+ renderTree();
+ // Keep the overview catalog in sync when it is the visible panel (no
editor open).
+ if (overview != null
+ && !overview.isDisposed()
+ && editorStackLayout != null
+ && editorStackLayout.topControl == overview) {
+ overview.setCards(buildOverviewCards());
+ }
+ }
- // top level: object key
- //
+ /**
+ * Loads the metadata model from disk into memory: one {@link
MetadataTypeModel} per metadata type
+ * with its items (name + virtual path). This is the only place that reads
metadata objects from
+ * the provider, so search filtering (handled entirely by {@link
#renderTree()}) never touches
+ * disk.
+ */
+ private void reloadModel() {
+ typeModels.clear();
+ try {
IHopMetadataProvider metadataProvider = hopGui.getMetadataProvider();
- List<Class<IHopMetadata>> metadataClasses =
metadataProvider.getMetadataClasses();
- // Sort by name
- Collections.sort(
- metadataClasses,
- (cl1, cl2) -> {
- HopMetadata a1 = HopMetadataUtil.getHopMetadataAnnotation(cl1);
- HopMetadata a2 = HopMetadataUtil.getHopMetadataAnnotation(cl2);
- return a1.name().compareTo(a2.name());
- });
-
- for (Class<IHopMetadata> metadataClass : metadataClasses) {
+ for (Class<IHopMetadata> metadataClass :
metadataProvider.getMetadataClasses()) {
HopMetadata annotation =
HopMetadataUtil.getHopMetadataAnnotation(metadataClass);
- Image image =
- GuiResource.getInstance()
- .getImage(
- annotation.image(),
- metadataClass.getClassLoader(),
- ConstUi.SMALL_ICON_SIZE,
- ConstUi.SMALL_ICON_SIZE);
-
- TreeItem classItem = new TreeItem(tree, SWT.NONE);
- classItem.setText(
- 0, Const.NVL(TranslateUtil.translate(annotation.name(),
metadataClass), ""));
- classItem.setImage(image);
- classItem.setExpanded(true);
- classItem.setData(annotation.key());
- classItem.setData(KEY_HELP, annotation.description());
- classItem.setData(VIRTUAL_PATH, "");
- classItem.setData(KEY_TYPE, "MetadataItem");
-
- // level 1: object names
- //
+ if (annotation == null) {
+ continue;
+ }
+ MetadataTypeModel typeModel =
+ new MetadataTypeModel(
+ annotation.key(),
+ MetadataCategories.normalize(annotation.category()),
+ Const.NVL(TranslateUtil.translate(annotation.name(),
metadataClass), ""),
+ Const.NVL(TranslateUtil.translate(annotation.description(),
metadataClass), ""),
+ annotation.image(),
+ metadataClass);
+
IHopMetadataSerializer<IHopMetadata> serializer =
metadataProvider.getSerializer(metadataClass);
List<String> names = serializer.listObjectNames();
Collections.sort(names);
-
- for (final String name : names) {
- // Apply filter - skip non-matching items
- if (!matchesFilter(name)) {
- continue;
- }
-
- IHopMetadata hopMetadata;
+ for (String name : names) {
+ String virtualPath;
try {
- hopMetadata = serializer.load(name);
+ virtualPath = Const.NVL(serializer.load(name).getVirtualPath(),
"");
} catch (HopException e) {
- // Ignore missing metadata items
+ // Ignore missing/corrupt metadata items
LogChannel.GENERAL.logError("Error loading metadata object:" +
name);
continue;
}
- TreeItem parentItem = classItem;
-
- if (!Utils.isEmpty(hopMetadata.getVirtualPath())) {
- List<String> folders =
- new
ArrayList<>(Arrays.asList(hopMetadata.getVirtualPath().split("/")));
- // remove empty elements
- folders.removeAll(Arrays.asList("", null));
-
- for (String folder : folders) {
- TreeItem alreadyExists = null;
- if (!folder.isEmpty()) {
- // check if folder already exists on this level
- alreadyExists = null;
- for (TreeItem childItem : parentItem.getItems()) {
- if (childItem.getData(KEY_TYPE).equals(FOLDER)
- && childItem.getText().equals(folder)) {
- alreadyExists = childItem;
- }
- }
+ typeModel.items.add(new MetadataItemModel(name, virtualPath));
+ }
+ typeModels.add(typeModel);
+ }
+ // Attach explicitly-created (persisted) virtual folders so empty ones
survive
+ // refresh/restart.
+ loadPersistedFolders();
+ // Sort the types by their (translated) display name.
+ typeModels.sort(Comparator.comparing(typeModel -> typeModel.typeName));
+ } catch (Exception e) {
+ new ErrorDialog(
+ getShell(),
+ BaseMessages.getString(PKG,
"MetadataPerspective.RefreshMetadata.Error.Header"),
+ BaseMessages.getString(PKG,
"MetadataPerspective.RefreshMetadata.Error.Message"),
+ e);
+ }
+ }
- if (alreadyExists != null) {
- parentItem = alreadyExists;
- } else {
- TreeItem folderItem = new TreeItem(parentItem, SWT.NONE);
- folderItem.setText(folder);
- folderItem.setData(annotation.key());
-
folderItem.setImage(GuiResource.getInstance().getImageFolder());
- folderItem.setData(
- VIRTUAL_PATH,
- folderItem.getParentItem().getData(VIRTUAL_PATH) + "/" +
folder);
- folderItem.setData(KEY_TYPE, FOLDER);
-
- // Expand folders when filtering to show matches
- if (!Utils.isEmpty(currentSearchFilter)) {
- folderItem.setExpanded(true);
- }
+ /**
+ * Reads the project's persisted virtual folders from the audit trail and
attaches each path to
+ * its metadata type model, so explicitly-created (and possibly empty)
folders are rendered.
+ */
+ private void loadPersistedFolders() {
+ Map<String, MetadataTypeModel> byKey = new LinkedHashMap<>();
+ for (MetadataTypeModel typeModel : typeModels) {
+ byKey.put(typeModel.key, typeModel);
+ }
+ try {
+ AuditList list =
+ AuditManager.getActive().retrieveList(getAuditNamespace(),
FOLDER_AUDIT_TYPE);
+ if (list == null || list.getNames() == null) {
+ return;
+ }
+ for (String entry : list.getNames()) {
+ int sep = entry.indexOf(FOLDER_AUDIT_SEPARATOR);
+ if (sep < 0) {
+ continue;
+ }
+ MetadataTypeModel typeModel = byKey.get(entry.substring(0, sep));
+ String path = entry.substring(sep + FOLDER_AUDIT_SEPARATOR.length());
+ if (typeModel != null
+ && !Utils.isEmpty(path)
+ && !typeModel.folderVirtualPaths.contains(path)) {
+ typeModel.folderVirtualPaths.add(path);
+ }
+ }
+ } catch (Exception e) {
+ LogChannel.UI.logError("Error reading metadata virtual folders from the
audit trail", e);
+ }
+ }
- parentItem = folderItem;
- }
- }
- }
- }
+ /** Persists an (empty) virtual folder path for a metadata type in the
project's audit trail. */
+ private void persistFolder(String typeKey, String virtualPath) {
+ try {
+ IAuditManager auditManager = AuditManager.getActive();
+ String namespace = getAuditNamespace();
+ AuditList list = auditManager.retrieveList(namespace, FOLDER_AUDIT_TYPE);
+ if (list == null || list.getNames() == null) {
+ list = new AuditList(new ArrayList<>());
+ }
+ String entry = typeKey + FOLDER_AUDIT_SEPARATOR + virtualPath;
+ if (!list.getNames().contains(entry)) {
+ list.getNames().add(entry);
+ auditManager.storeList(namespace, FOLDER_AUDIT_TYPE, list);
+ }
+ } catch (Exception e) {
+ LogChannel.UI.logError("Error storing metadata virtual folder in the
audit trail", e);
+ }
+ }
- TreeItem item = new TreeItem(parentItem, SWT.NONE);
- item.setText(0, Const.NVL(name, ""));
- item.setData(VIRTUAL_PATH, parentItem.getData(VIRTUAL_PATH));
- item.setData(KEY_TYPE, FILE);
- MetadataEditor<?> editor = this.findEditor(annotation.key(), name);
- if (editor != null && editor.hasChanged()) {
- item.setFont(GuiResource.getInstance().getFontBold());
- }
+ /** The audit group for the current project (falls back to the default
HopGui namespace). */
+ private static String getAuditNamespace() {
+ return Const.NVL(HopNamespace.getNamespace(),
HopGui.DEFAULT_HOP_GUI_NAMESPACE);
+ }
+
+ /** Restores the saved tree-panel sash weights (a global layout preference),
if any. */
+ private void loadSashWeights() {
+ if (sash == null || sash.isDisposed()) {
+ return;
+ }
+ try {
+ AuditList list =
+ AuditManager.getActive()
+ .retrieveList(HopGui.DEFAULT_HOP_GUI_NAMESPACE,
SASH_WEIGHTS_AUDIT_TYPE);
+ if (list == null || list.getNames() == null || list.getNames().size() <
2) {
+ return;
+ }
+ int[] weights = new int[list.getNames().size()];
+ for (int i = 0; i < weights.length; i++) {
+ weights[i] = Integer.parseInt(list.getNames().get(i).trim());
+ }
+ sash.setWeights(weights);
+ } catch (Exception e) {
+ LogChannel.UI.logError("Error reading the metadata tree-panel width from
the audit trail", e);
+ }
+ }
+
+ /** Debounced trigger to save the sash weights after the divider stops
moving. */
+ private void scheduleSaveSashWeights() {
+ if (sash == null || sash.isDisposed()) {
+ return;
+ }
+ Display display = sash.getDisplay();
+ display.timerExec(-1, sashWeightsSaver);
+ display.timerExec(400, sashWeightsSaver);
+ }
+
+ /** Persists the current tree-panel sash weights (global, not per-project).
*/
+ private void saveSashWeights() {
+ if (sash == null || sash.isDisposed()) {
+ return;
+ }
+ int[] weights = sash.getWeights();
+ if (weights == null || weights.length < 2) {
+ return;
+ }
+ try {
+ List<String> values = new ArrayList<>();
+ for (int weight : weights) {
+ values.add(Integer.toString(weight));
+ }
+ AuditManager.getActive()
+ .storeList(
+ HopGui.DEFAULT_HOP_GUI_NAMESPACE, SASH_WEIGHTS_AUDIT_TYPE, new
AuditList(values));
+ } catch (Exception e) {
+ LogChannel.UI.logError("Error storing the metadata tree-panel width in
the audit trail", e);
+ }
+ }
+
+ /**
+ * Deletes the selected virtual folder: any items inside it (or its
sub-folders) are moved up to
+ * the parent folder, and the folder (plus any persisted empty sub-folders)
is removed from the
+ * audit trail. Items are never deleted here.
+ */
+ public void onDeleteFolder() {
+ if (tree.getSelectionCount() != 1) {
+ return;
+ }
+ TreeItem treeItem = tree.getSelection()[0];
+ if (!FOLDER.equals(treeItem.getData(KEY_TYPE))) {
+ return;
+ }
+ String typeKey = getObjectKey(treeItem);
+ String folderPath = Const.NVL((String) treeItem.getData(VIRTUAL_PATH), "");
+ if (typeKey == null || folderPath.isEmpty()) {
+ return;
+ }
+
+ String parentPath = parentPath(folderPath);
+ List<MetadataItemModel> affected = new ArrayList<>();
+ MetadataTypeModel typeModel = findTypeModel(typeKey);
+ if (typeModel != null) {
+ for (MetadataItemModel item : typeModel.items) {
+ if (isUnderFolder(item.virtualPath, folderPath)) {
+ affected.add(item);
}
}
+ }
- // Remove empty class items (those with no children after filtering)
- if (!Utils.isEmpty(currentSearchFilter)) {
- List<TreeItem> itemsToRemove = new ArrayList<>();
- for (TreeItem classItem : tree.getItems()) {
- if (classItem.getItemCount() == 0) {
- itemsToRemove.add(classItem);
+ MessageBox confirm = new MessageBox(getShell(), SWT.ICON_QUESTION |
SWT.YES | SWT.NO);
+ confirm.setText(BaseMessages.getString(PKG,
"MetadataPerspective.DeleteFolder.Title"));
+ confirm.setMessage(
+ affected.isEmpty()
+ ? BaseMessages.getString(
+ PKG, "MetadataPerspective.DeleteFolder.Confirm",
treeItem.getText())
+ : BaseMessages.getString(
+ PKG,
+ "MetadataPerspective.DeleteFolder.ConfirmWithItems",
+ treeItem.getText(),
+ affected.size()));
+ if ((confirm.open() & SWT.YES) == 0) {
+ return;
+ }
+
+ try {
+ if (!affected.isEmpty()) {
+ IHopMetadataProvider provider = hopGui.getMetadataProvider();
+ IHopMetadataSerializer<IHopMetadata> serializer =
+ provider.getSerializer(provider.getMetadataClassForKey(typeKey));
+ for (MetadataItemModel item : affected) {
+ IHopMetadata metadata = serializer.load(item.name);
+ // The affected paths all start with folderPath, so swap that prefix
for the parent path.
+ metadata.setVirtualPath(parentPath +
item.virtualPath.substring(folderPath.length()));
+ serializer.save(metadata);
+ }
+ hopGui.getEventsHandler().fire(HopGuiEvents.MetadataChanged.name());
+ }
+ removePersistedFoldersUnder(typeKey, folderPath);
+ refresh();
+ updateSelection();
+ } catch (Exception e) {
+ new ErrorDialog(
+ getShell(),
+ BaseMessages.getString(PKG,
"MetadataPerspective.DeleteFolder.Error.Header"),
+ BaseMessages.getString(PKG,
"MetadataPerspective.DeleteFolder.Error.Message"),
+ e);
+ }
+ }
+
+ /** Returns the parent of a virtual folder path ({@code "/a/b" -> "/a"},
{@code "/a" -> ""}). */
+ private static String parentPath(String folderPath) {
+ int idx = folderPath.lastIndexOf('/');
+ return idx <= 0 ? "" : folderPath.substring(0, idx);
+ }
+
+ /**
+ * True when {@code itemPath} is the folder itself or sits inside it (or one
of its sub-folders).
+ */
+ private static boolean isUnderFolder(String itemPath, String folderPath) {
+ String path = Const.NVL(itemPath, "");
+ return path.equals(folderPath) || path.startsWith(folderPath + "/");
+ }
+
+ private MetadataTypeModel findTypeModel(String typeKey) {
+ for (MetadataTypeModel typeModel : typeModels) {
+ if (typeModel.key.equals(typeKey)) {
+ return typeModel;
+ }
+ }
+ return null;
+ }
+
+ /** Drops the given folder and any persisted sub-folder beneath it from the
audit trail. */
+ private void removePersistedFoldersUnder(String typeKey, String folderPath) {
+ try {
+ IAuditManager auditManager = AuditManager.getActive();
+ String namespace = getAuditNamespace();
+ AuditList list = auditManager.retrieveList(namespace, FOLDER_AUDIT_TYPE);
+ if (list == null || list.getNames() == null ||
list.getNames().isEmpty()) {
+ return;
+ }
+ String prefix = typeKey + FOLDER_AUDIT_SEPARATOR;
+ boolean changed =
+ list.getNames()
+ .removeIf(
+ entry -> {
+ if (!entry.startsWith(prefix)) {
+ return false;
+ }
+ String path = entry.substring(prefix.length());
+ return path.equals(folderPath) ||
path.startsWith(folderPath + "/");
+ });
+ if (changed) {
+ auditManager.storeList(namespace, FOLDER_AUDIT_TYPE, list);
+ }
+ } catch (Exception e) {
+ LogChannel.UI.logError("Error removing metadata virtual folder from the
audit trail", e);
+ }
+ }
+
+ /**
+ * Rebuilds the tree from the in-memory model (see {@link #reloadModel()}),
grouping types under
+ * category headers and applying the current search filter and the
hide-empty setting. Performs no
+ * disk I/O, so it is cheap enough to run on every keystroke.
+ */
+ private void renderTree() {
+ if (tree == null || tree.isDisposed()) {
+ return;
+ }
+ try {
+ // Capture scroll (top item) and selection so a rebuild doesn't jump
back to the top.
+ String topId = nodeIdentity(tree.getTopItem());
+ TreeItem[] selection = tree.getSelection();
+ String selectionId = selection.length > 0 ? nodeIdentity(selection[0]) :
null;
+
+ tree.setRedraw(false);
+ tree.removeAll();
+
+ boolean filtering = !Utils.isEmpty(currentSearchFilter);
+ int totalMatches = 0;
+
+ // Group the (name-sorted) types by their category id.
+ Map<String, List<MetadataTypeModel>> typesByCategory = new
LinkedHashMap<>();
+ for (MetadataTypeModel typeModel : typeModels) {
+ typesByCategory
+ .computeIfAbsent(typeModel.categoryId, k -> new ArrayList<>())
+ .add(typeModel);
+ }
+ // Order categories: known categories first (declaration order), then
unknown, "Other" last.
+ List<String> categoryIds = new ArrayList<>(typesByCategory.keySet());
+ categoryIds.sort(
+ Comparator.comparingInt(MetadataCategories::orderOf)
+ .thenComparing(MetadataCategories::labelFor));
+
+ for (String categoryId : categoryIds) {
+ boolean categoryMatches = filtering &&
contains(MetadataCategories.labelFor(categoryId));
+ TreeItem categoryItem = null;
+ for (MetadataTypeModel typeModel : typesByCategory.get(categoryId)) {
+ boolean typeMatches = filtering && (categoryMatches ||
contains(typeModel.typeName));
+ List<MetadataItemModel> shownItems;
+ if (!filtering || typeMatches) {
+ shownItems = typeModel.items;
} else {
- // Expand class items when filtering to show matches
- classItem.setExpanded(true);
+ shownItems = new ArrayList<>();
+ for (MetadataItemModel itemModel : typeModel.items) {
+ if (contains(itemModel.name)) {
+ shownItems.add(itemModel);
+ }
+ }
}
- }
- for (TreeItem item : itemsToRemove) {
- item.dispose();
+ boolean showType =
+ filtering
+ ? (typeMatches || !shownItems.isEmpty())
+ : (showEmptyTypes
+ || !typeModel.items.isEmpty()
+ || !typeModel.folderVirtualPaths.isEmpty());
+ if (!showType) {
+ continue;
+ }
+ totalMatches += shownItems.size();
+ if (categoryItem == null) {
+ categoryItem = createCategoryItem(categoryId);
+ }
+ buildTypeNode(categoryItem, typeModel, shownItems, filtering);
}
}
+ updateResultCount(filtering, totalMatches);
+
TreeUtil.setOptimalWidthOnColumns(tree);
- TreeMemory.setExpandedFromMemory(tree, METADATA_PERSPECTIVE_TREE);
+ // Apply the remembered expand/collapse state (also force-expands
everything while filtering).
+ applyTreeMemory();
tree.setRedraw(true);
+ // Restore the selection and scroll position captured above.
+ restoreTreeViewState(topId, selectionId);
+
updateGui();
updateSelection();
} catch (Exception e) {
@@ -1829,33 +2329,283 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
}
}
- /** Filter the tree based on search text */
+ /**
+ * Stable identity for any tree node (category, type, folder or item),
independent of display
+ * text, used to re-find a node after a rebuild. Returns {@code null} for
unknown/disposed nodes.
+ */
+ private String nodeIdentity(TreeItem item) {
+ if (item == null || item.isDisposed()) {
+ return null;
+ }
+ String type = (String) item.getData(KEY_TYPE);
+ if (CATEGORY.equals(type)) {
+ return "C\t" + item.getData();
+ }
+ if (TYPE.equals(type)) {
+ return "T\t" + item.getData();
+ }
+ if (FOLDER.equals(type)) {
+ return "F\t" + item.getData() + "\t" + item.getData(VIRTUAL_PATH);
+ }
+ if (FILE.equals(type)) {
+ return "I\t"
+ + getObjectKey(item)
+ + "\t"
+ + item.getData(VIRTUAL_PATH)
+ + "\t"
+ + item.getText(0);
+ }
+ return null;
+ }
+
+ /**
+ * Re-selects and re-scrolls to the nodes identified by {@code selectionId}
/ {@code topId} (as
+ * produced by {@link #nodeIdentity}) after the tree has been rebuilt.
Missing nodes are ignored.
+ */
+ private void restoreTreeViewState(String topId, String selectionId) {
+ if (topId == null && selectionId == null) {
+ return;
+ }
+ TreeItem[] found = new TreeItem[2]; // [0] = top item, [1] = selection
+ findTreeItems(tree.getItems(), topId, selectionId, found);
+ if (found[1] != null) {
+ tree.setSelection(found[1]);
+ }
+ if (found[0] != null) {
+ tree.setTopItem(found[0]);
+ }
+ }
+
+ private void findTreeItems(TreeItem[] items, String topId, String
selectionId, TreeItem[] found) {
+ for (TreeItem item : items) {
+ String id = nodeIdentity(item);
+ if (id != null) {
+ if (topId != null && topId.equals(id)) {
+ found[0] = item;
+ }
+ if (selectionId != null && selectionId.equals(id)) {
+ found[1] = item;
+ }
+ }
+ findTreeItems(item.getItems(), topId, selectionId, found);
+ }
+ }
+
+ /** Clears the search filter (e.g. when switching projects). */
@Override
public void clearSearchFilters() {
+ currentSearchFilter = "";
if (searchText != null && !searchText.isDisposed()) {
searchText.setText("");
- } else {
- currentSearchFilter = "";
}
}
+ private TreeItem createCategoryItem(String categoryId) {
+ TreeItem categoryItem = new TreeItem(tree, SWT.NONE);
+ categoryItem.setText(MetadataCategories.labelFor(categoryId));
+ categoryItem.setImage(
+ GuiResource.getInstance()
+ .getImage(
+ MetadataCategories.imageFor(categoryId),
+ getClass().getClassLoader(),
+ ConstUi.SMALL_ICON_SIZE,
+ ConstUi.SMALL_ICON_SIZE));
+ categoryItem.setData(categoryId);
+ categoryItem.setData(KEY_TYPE, CATEGORY);
+ categoryItem.setData(VIRTUAL_PATH, "");
+ return categoryItem;
+ }
+
+ /** Builds a metadata type node (with item count) and its folder/item
subtree under a category. */
+ private void buildTypeNode(
+ TreeItem categoryItem,
+ MetadataTypeModel typeModel,
+ List<MetadataItemModel> shownItems,
+ boolean filtering) {
+ Image image =
+ GuiResource.getInstance()
+ .getImage(
+ typeModel.image,
+ typeModel.metadataClass.getClassLoader(),
+ ConstUi.SMALL_ICON_SIZE,
+ ConstUi.SMALL_ICON_SIZE);
+
+ int count = filtering ? shownItems.size() : typeModel.items.size();
+ TreeItem classItem = new TreeItem(categoryItem, SWT.NONE);
+ classItem.setText(0, typeModel.typeName + " (" + count + ")");
+ classItem.setImage(image);
+ classItem.setData(typeModel.key);
+ classItem.setData(KEY_HELP, typeModel.description);
+ classItem.setData(VIRTUAL_PATH, "");
+ classItem.setData(KEY_TYPE, TYPE);
+
+ // Materialize explicitly-created (possibly empty) folders first so they
show without any items.
+ // Skip while filtering: empty folders never match a search.
+ if (!filtering) {
+ for (String folderPath : typeModel.folderVirtualPaths) {
+ resolveFolderItem(classItem, typeModel.key, folderPath);
+ }
+ }
+
+ for (MetadataItemModel itemModel : shownItems) {
+ TreeItem parentItem =
+ Utils.isEmpty(itemModel.virtualPath)
+ ? classItem
+ : resolveFolderItem(classItem, typeModel.key,
itemModel.virtualPath);
+
+ TreeItem item = new TreeItem(parentItem, SWT.NONE);
+ item.setText(0, Const.NVL(itemModel.name, ""));
+ item.setData(VIRTUAL_PATH, parentItem.getData(VIRTUAL_PATH));
+ item.setData(KEY_TYPE, FILE);
+ MetadataEditor<?> editor = this.findEditor(typeModel.key,
itemModel.name);
+ if (editor != null && editor.hasChanged()) {
+ item.setFont(GuiResource.getInstance().getFontBold());
+ }
+ }
+ }
+
+ /**
+ * Ensures the folder chain for {@code virtualPath} exists under {@code
typeItem}, creating folder
+ * nodes as needed, and returns the deepest folder node (or {@code typeItem}
for an empty path).
+ */
+ private TreeItem resolveFolderItem(TreeItem typeItem, String typeKey, String
virtualPath) {
+ TreeItem parentItem = typeItem;
+ if (Utils.isEmpty(virtualPath)) {
+ return parentItem;
+ }
+ List<String> folders = new
ArrayList<>(Arrays.asList(virtualPath.split("/")));
+ folders.removeAll(Arrays.asList("", null));
+ for (String folder : folders) {
+ if (folder.isEmpty()) {
+ continue;
+ }
+ TreeItem alreadyExists = null;
+ for (TreeItem childItem : parentItem.getItems()) {
+ if (FOLDER.equals(childItem.getData(KEY_TYPE)) &&
childItem.getText().equals(folder)) {
+ alreadyExists = childItem;
+ }
+ }
+ if (alreadyExists != null) {
+ parentItem = alreadyExists;
+ } else {
+ TreeItem folderItem = new TreeItem(parentItem, SWT.NONE);
+ folderItem.setText(folder);
+ folderItem.setData(typeKey);
+ folderItem.setImage(GuiResource.getInstance().getImageFolder());
+ folderItem.setData(
+ VIRTUAL_PATH, folderItem.getParentItem().getData(VIRTUAL_PATH) +
"/" + folder);
+ folderItem.setData(KEY_TYPE, FOLDER);
+ parentItem = folderItem;
+ }
+ }
+ return parentItem;
+ }
+
+ /**
+ * Stable TreeMemory key for an expandable node, independent of its display
text so a changing
+ * item count never invalidates a remembered state. Returns {@code null} for
non-expandable nodes.
+ */
+ private String[] treeMemoryPath(TreeItem item) {
+ String type = (String) item.getData(KEY_TYPE);
+ if (CATEGORY.equals(type)) {
+ return new String[] {"C", (String) item.getData()};
+ }
+ if (TYPE.equals(type)) {
+ return new String[] {"T", (String) item.getData()};
+ }
+ if (FOLDER.equals(type)) {
+ return new String[] {"F", (String) item.getData(), (String)
item.getData(VIRTUAL_PATH)};
+ }
+ return null;
+ }
+
+ /** Applies the remembered expand/collapse state to the whole tree (see
{@link #renderTree()}). */
+ private void applyTreeMemory() {
+ for (TreeItem item : tree.getItems()) {
+ applyTreeMemory(item);
+ }
+ }
+
+ private void applyTreeMemory(TreeItem item) {
+ String[] path = treeMemoryPath(item);
+ if (path != null) {
+ if (!Utils.isEmpty(currentSearchFilter)) {
+ // While searching, expand everything so matches are visible (not
recorded as a choice).
+ item.setExpanded(true);
+ } else {
+ // Categories expand by default; types and folders collapse by
default. Seed each
+ // default-expanded node once per session so the default holds until
the user changes it.
+ boolean defaultExpanded = "C".equals(path[0]);
+ if (defaultExpanded && treeStateSeeded.add(String.join("�", path))) {
+ TreeMemory.getInstance().storeExpanded(METADATA_PERSPECTIVE_TREE,
path, true);
+ }
+
item.setExpanded(TreeMemory.getInstance().isExpanded(METADATA_PERSPECTIVE_TREE,
path));
+ }
+ }
+ for (TreeItem child : item.getItems()) {
+ applyTreeMemory(child);
+ }
+ }
+
+ /** Records a user expand/collapse into the shared TreeMemory (ignored while
searching). */
+ private void recordTreeState(TreeItem item, boolean expanded) {
+ if (item == null || item.isDisposed() ||
!Utils.isEmpty(currentSearchFilter)) {
+ return;
+ }
+ String[] path = treeMemoryPath(item);
+ if (path != null) {
+ TreeMemory.getInstance().storeExpanded(METADATA_PERSPECTIVE_TREE, path,
expanded);
+ treeStateSeeded.add(String.join("�", path));
+ }
+ }
+
+ /**
+ * Records the current expand state of a node and its children (used after
expand/collapse all).
+ */
+ private void recordAllTreeState(TreeItem item) {
+ recordTreeState(item, item.getExpanded());
+ for (TreeItem child : item.getItems()) {
+ recordAllTreeState(child);
+ }
+ }
+
+ /** Case-insensitive containment test of {@code text} against the current
search filter. */
+ private boolean contains(String text) {
+ return text != null &&
text.toLowerCase().contains(currentSearchFilter.toLowerCase());
+ }
+
+ /** Filter the tree based on search text, debounced so we don't rebuild on
every keystroke. */
protected void filterTree() {
if (searchText == null || searchText.isDisposed()) {
return;
}
+ Display display = searchText.getDisplay();
+ display.timerExec(-1, filterRunnable);
+ display.timerExec(FILTER_DEBOUNCE_MS, filterRunnable);
+ }
+ private void applyFilter() {
+ if (searchText == null || searchText.isDisposed()) {
+ return;
+ }
currentSearchFilter = searchText.getText();
-
- // Refresh to rebuild the tree with or without filter
- refresh();
+ renderTree();
}
- /** Check if a metadata name matches the current filter */
- private boolean matchesFilter(String name) {
- if (Utils.isEmpty(currentSearchFilter)) {
- return true;
+ /** Updates the "n results" label shown under the search box while a filter
is active. */
+ private void updateResultCount(boolean filtering, int matches) {
+ if (resultCountLabel == null || resultCountLabel.isDisposed()) {
+ return;
+ }
+ resultCountLabel.setText(
+ filtering
+ ? BaseMessages.getString(PKG,
"MetadataPerspective.Search.ResultCount", matches)
+ : "");
+ // Collapse the label's row when no filter is active so there is no gap
above the tree.
+ if (resultCountLabel.getLayoutData() instanceof FormData formData) {
+ formData.height = filtering ? SWT.DEFAULT : 0;
}
- return name != null &&
name.toLowerCase().contains(currentSearchFilter.toLowerCase());
+ resultCountLabel.getParent().layout();
}
/** Recursively expand or collapse a tree item and all its children */
@@ -1869,21 +2619,24 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
protected void updateSelection() {
boolean isMetadataSelected = false;
- boolean isAnythingSelected = false;
+ boolean isFolderSelected = false;
+ boolean canCreateHere = false;
if (tree.getSelectionCount() > 0) {
- isAnythingSelected = true;
TreeItem treeItem = tree.getSelection()[0];
- if (treeItem.getData(KEY_TYPE).equals(FILE)) {
- isMetadataSelected = true;
- }
+ String nodeType = (String) treeItem.getData(KEY_TYPE);
+ isMetadataSelected = FILE.equals(nodeType);
+ isFolderSelected = FOLDER.equals(nodeType);
+ // The context "New" applies to a type, folder or file (all resolve to a
type key), but not
+ // to a category header or a plain label.
+ canCreateHere = getObjectKey(treeItem) != null;
}
- toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_NEW, isAnythingSelected);
+ toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_NEW, canCreateHere);
toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_EDIT, isMetadataSelected);
toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_RENAME, isMetadataSelected);
toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_DUPLICATE,
isMetadataSelected);
- toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_DELETE, isMetadataSelected);
+ toolBarWidgets.enableToolbarItem(TOOLBAR_ITEM_DELETE, isMetadataSelected
|| isFolderSelected);
}
@Override
@@ -1908,6 +2661,8 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
//
if (tabFolder.getItemCount() == 0) {
HopGui.getInstance().handleFileCapabilities(new EmptyFileType(),
false, false, false);
+ // Bring back the overview/landing page.
+ showOverview();
}
// Update Gui menu and toolbar
@@ -2011,40 +2766,41 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
return annotation.key();
}
- public void goToType(Class<? extends IHopMetadata> managedClass) {
- String key = getKeyOfMetadataClass(managedClass);
- // Look at all the top level items in the tree
- //
- for (TreeItem item : tree.getItems()) {
- String classKey = (String) item.getData();
- if (key.equals(classKey)) {
- // Found the item.
- //
- tree.setSelection(item);
- tree.showSelection();
- return;
+ /** Finds the tree node for a metadata type (by key), searching inside the
category headers. */
+ private TreeItem findTypeItem(String key) {
+ for (TreeItem categoryItem : tree.getItems()) {
+ for (TreeItem typeItem : categoryItem.getItems()) {
+ if (TYPE.equals(typeItem.getData(KEY_TYPE)) &&
key.equals(typeItem.getData())) {
+ return typeItem;
+ }
}
}
+ return null;
+ }
+
+ public void goToType(Class<? extends IHopMetadata> managedClass) {
+ TreeItem typeItem = findTypeItem(getKeyOfMetadataClass(managedClass));
+ if (typeItem != null) {
+ tree.setSelection(typeItem);
+ tree.showSelection();
+ }
}
public void goToElement(Class<? extends IHopMetadata> managedClass, String
elementName) {
- String key = getKeyOfMetadataClass(managedClass);
- for (TreeItem item : tree.getItems()) {
- String classKey = (String) item.getData();
- if (key.equals(classKey)) {
- // Found the type.
- //
- for (TreeItem elementItem : item.getItems()) {
- if (elementName.equals(elementItem.getText())) {
- tree.setSelection(elementItem);
- tree.showSelection();
- onEditMetadata();
- return;
- }
- }
- goToType(managedClass);
+ TreeItem typeItem = findTypeItem(getKeyOfMetadataClass(managedClass));
+ if (typeItem == null) {
+ return;
+ }
+ for (TreeItem elementItem : typeItem.getItems()) {
+ if (elementName.equals(elementItem.getText())) {
+ tree.setSelection(elementItem);
+ tree.showSelection();
+ onEditMetadata();
+ return;
}
}
+ // Element not directly under the type node (or not loaded): at least
reveal the type.
+ goToType(managedClass);
}
public void createNewFolder() {
@@ -2053,6 +2809,11 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
return;
}
TreeItem item = selection[0];
+ String typeKey = getObjectKey(item);
+ if (typeKey == null) {
+ return; // A category header or label is selected: nothing to add a
folder to.
+ }
+ String parentPath = Const.NVL((String) item.getData(VIRTUAL_PATH), "");
EnterStringDialog dialog =
new EnterStringDialog(
getShell(),
@@ -2061,34 +2822,65 @@ public class MetadataPerspective implements
IHopPerspective, TabClosable, IMetad
BaseMessages.getString(
PKG,
"MetadataPerspective.CreateFolder.Message",
- ((String) item.getData(VIRTUAL_PATH)).isEmpty()
- ? item.getText()
- : (String) item.getData(VIRTUAL_PATH)));
+ parentPath.isEmpty() ? item.getText() : parentPath));
String folder = dialog.open();
- if (!Utils.isEmpty(folder)) {
- for (TreeItem treeItem : item.getItems()) {
- if (folder.equals(treeItem.getText()) &&
treeItem.getData("type").equals(FOLDER)) {
- MessageBox msgDialog = new MessageBox(getShell(),
SWT.ICON_INFORMATION | SWT.OK);
- msgDialog.setText(
- BaseMessages.getString(PKG,
"MetadataPerspective.CreateFolder.Error.Header"));
- msgDialog.setMessage(
- BaseMessages.getString(PKG,
"MetadataPerspective.CreateFolder.Error.Message"));
- msgDialog.open();
- return;
- }
+ if (Utils.isEmpty(folder)) {
+ return;
+ }
+ // Reject a duplicate folder name on this level.
+ for (TreeItem treeItem : item.getItems()) {
+ if (folder.equals(treeItem.getText()) &&
FOLDER.equals(treeItem.getData(KEY_TYPE))) {
+ MessageBox msgDialog = new MessageBox(getShell(), SWT.ICON_INFORMATION
| SWT.OK);
+ msgDialog.setText(
+ BaseMessages.getString(PKG,
"MetadataPerspective.CreateFolder.Error.Header"));
+ msgDialog.setMessage(
+ BaseMessages.getString(PKG,
"MetadataPerspective.CreateFolder.Error.Message"));
+ msgDialog.open();
+ return;
}
- TreeItem newFolder = new TreeItem(item, SWT.NONE);
- newFolder.setText(folder);
- newFolder.setData(item.getData());
- newFolder.setImage(GuiResource.getInstance().getImageFolder());
- newFolder.setData(VIRTUAL_PATH, item.getData(VIRTUAL_PATH) + "/" +
folder);
- newFolder.setData(KEY_TYPE, FOLDER);
- TreeItem emptyString = new TreeItem(newFolder, SWT.NONE);
- emptyString.setText(
- BaseMessages.getString(PKG,
"MetadataPerspective.CreateFolder.EmptyFolder"));
- emptyString.setData(KEY_TYPE, "Label");
-
emptyString.setForeground(tree.getDisplay().getSystemColor(SWT.COLOR_GRAY));
- newFolder.setExpanded(true);
+ }
+ // Persist the folder so it survives refresh/restart, then rebuild the
tree from the model.
+ persistFolder(typeKey, parentPath + "/" + folder);
+ refresh();
+ }
+
+ /** In-memory view of one metadata type and its items, loaded once per
{@link #reloadModel()}. */
+ private static final class MetadataTypeModel {
+ private final String key;
+ private final String categoryId;
+ private final String typeName;
+ private final String description;
+ private final String image;
+ private final Class<IHopMetadata> metadataClass;
+ private final List<MetadataItemModel> items = new ArrayList<>();
+
+ /** Explicitly created virtual folder paths (persisted), shown even when
they hold no items. */
+ private final List<String> folderVirtualPaths = new ArrayList<>();
+
+ private MetadataTypeModel(
+ String key,
+ String categoryId,
+ String typeName,
+ String description,
+ String image,
+ Class<IHopMetadata> metadataClass) {
+ this.key = key;
+ this.categoryId = categoryId;
+ this.typeName = typeName;
+ this.description = description;
+ this.image = image;
+ this.metadataClass = metadataClass;
+ }
+ }
+
+ /** In-memory view of a single metadata item: its name and (optional)
virtual folder path. */
+ private static final class MetadataItemModel {
+ private final String name;
+ private final String virtualPath;
+
+ private MetadataItemModel(String name, String virtualPath) {
+ this.name = name;
+ this.virtualPath = virtualPath;
}
}
}
diff --git
a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/metadata/messages/messages_en_US.properties
b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/metadata/messages/messages_en_US.properties
index f016a85788..41f7829dd9 100644
---
a/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/metadata/messages/messages_en_US.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/hopgui/perspective/metadata/messages/messages_en_US.properties
@@ -69,4 +69,28 @@ MetadataPerspective.DeleteMetadata.Button.Details=Details
MetadataPerspective.DeleteMetadata.Details.Title=References to {0}
MetadataPerspective.DeleteMetadata.Details.Message=The following files and
metadata objects still reference ''{0}'':
MetadataPerspective.DragDropMove.Error=Error moving metadata to folder
-MetadataPerspective.DragDropOpen.Error=Error opening metadata
\ No newline at end of file
+MetadataPerspective.DragDropOpen.Error=Error opening metadata
+MetadataPerspective.Menu.NewOfType={0}
+MetadataPerspective.Menu.DeleteFolder=Delete folder
+MetadataPerspective.DeleteFolder.Title=Delete folder
+MetadataPerspective.DeleteFolder.Confirm=Delete folder ''{0}''?
+MetadataPerspective.DeleteFolder.ConfirmWithItems=Delete folder
''{0}''?\n\n{1} item(s) inside will be moved to the parent folder.
+MetadataPerspective.DeleteFolder.Error.Header=Error
+MetadataPerspective.DeleteFolder.Error.Message=Error deleting folder
+MetadataPerspective.ToolbarElement.NewType.Tooltip=Create a new metadata
element of any type
+MetadataPerspective.ToolbarElement.ShowEmpty.Tooltip=Show empty types and
categories
+MetadataPerspective.ToolbarElement.HideEmpty.Tooltip=Hide empty types and
categories
+MetadataPerspective.Search.ResultCount={0} result(s)
+MetadataPerspective.Category.Connections=Connections
+MetadataPerspective.Category.FileStorage=File Storage
+MetadataPerspective.Category.RunConfig=Run Configurations
+MetadataPerspective.Category.Servers=Servers & Web Services
+MetadataPerspective.Category.Execution=Execution & Monitoring
+MetadataPerspective.Category.Logging=Logging & Probes
+MetadataPerspective.Category.Testing=Testing
+MetadataPerspective.Category.DataDefinition=Data Definitions
+MetadataPerspective.Category.Variables=Variables
+MetadataPerspective.Category.Other=Other
+MetadataPerspective.Overview.Title=Metadata
+MetadataPerspective.Overview.Subtitle=Select a type to create a new item, or
pick an existing item from the tree on the left.
+MetadataPerspective.Overview.New.Button=New
\ No newline at end of file
diff --git
a/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataCategoriesTest.java
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataCategoriesTest.java
new file mode 100644
index 0000000000..b46cf62f72
--- /dev/null
+++
b/ui/src/test/java/org/apache/hop/ui/hopgui/perspective/metadata/MetadataCategoriesTest.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.ui.hopgui.perspective.metadata;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.metadata.api.HopMetadataCategory;
+import org.junit.jupiter.api.Test;
+
+class MetadataCategoriesTest {
+
+ @Test
+ void normalize_nullAndEmptyBecomeOther() {
+ assertEquals(MetadataCategories.OTHER, MetadataCategories.normalize(null));
+ assertEquals(MetadataCategories.OTHER, MetadataCategories.normalize(""));
+ assertEquals(
+ HopMetadataCategory.CONNECTIONS,
+ MetadataCategories.normalize(HopMetadataCategory.CONNECTIONS));
+ }
+
+ @Test
+ void orderOf_knownCategoriesFollowDeclarationOrder() {
+ assertEquals(0,
MetadataCategories.orderOf(HopMetadataCategory.CONNECTIONS));
+ assertEquals(1,
MetadataCategories.orderOf(HopMetadataCategory.FILE_STORAGE));
+ // Connections sorts before file storage, file storage before variables.
+ assertTrue(
+ MetadataCategories.orderOf(HopMetadataCategory.CONNECTIONS)
+ < MetadataCategories.orderOf(HopMetadataCategory.FILE_STORAGE));
+ assertTrue(
+ MetadataCategories.orderOf(HopMetadataCategory.FILE_STORAGE)
+ < MetadataCategories.orderOf(HopMetadataCategory.VARIABLES));
+ }
+
+ @Test
+ void orderOf_unknownComesAfterKnownButBeforeOther() {
+ int known = MetadataCategories.orderOf(HopMetadataCategory.VARIABLES);
+ int unknown = MetadataCategories.orderOf("some-third-party-category");
+ int other = MetadataCategories.orderOf(MetadataCategories.OTHER);
+
+ assertTrue(known < unknown, "a known category must sort before an unknown
one");
+ assertTrue(unknown < other, "an unknown category must sort before the
Other bucket");
+ assertEquals(Integer.MAX_VALUE, other);
+ // null normalizes to the Other bucket.
+ assertEquals(other, MetadataCategories.orderOf(null));
+ }
+
+ @Test
+ void imageFor_knownReturnsConfiguredIconOthersFallBack() {
+ assertEquals(
+ "ui/images/database.svg",
MetadataCategories.imageFor(HopMetadataCategory.CONNECTIONS));
+ assertEquals(
+ "ui/images/location.svg",
MetadataCategories.imageFor(HopMetadataCategory.FILE_STORAGE));
+ // Unknown and Other fall back to the generic metadata icon.
+ assertEquals(
+ "ui/images/metadata.svg",
MetadataCategories.imageFor("some-third-party-category"));
+ assertEquals("ui/images/metadata.svg",
MetadataCategories.imageFor(MetadataCategories.OTHER));
+ }
+
+ @Test
+ void labelFor_unknownReturnsRawIdKnownReturnsTranslatedLabel() {
+ // An unknown id is shown verbatim (no i18n key exists for it).
+ assertEquals(
+ "some-third-party-category",
MetadataCategories.labelFor("some-third-party-category"));
+ // A known id resolves to a real label, not the raw id or a blank string.
+ String connectionsLabel =
MetadataCategories.labelFor(HopMetadataCategory.CONNECTIONS);
+ assertFalse(connectionsLabel == null || connectionsLabel.isEmpty());
+ assertFalse(HopMetadataCategory.CONNECTIONS.equals(connectionsLabel));
+ }
+}