diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ServiceEntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ServiceEntityRepository.java
index 15e27d3e8b0f..5576e84e189f 100644
--- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ServiceEntityRepository.java
+++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ServiceEntityRepository.java
@@ -30,6 +30,7 @@
import org.openmetadata.service.secrets.SecretsManager;
import org.openmetadata.service.secrets.SecretsManagerFactory;
import org.openmetadata.service.util.EntityUtil;
+import org.openmetadata.service.util.URLValidator;
public abstract class ServiceEntityRepository<
T extends ServiceEntityInterface, S extends ServiceConnectionEntityInterface>
@@ -64,6 +65,15 @@ public void clearFields(T entity, EntityUtil.Fields fields) {
@Override
public void prepare(T service, boolean update) {
+ // Validate logoUrl if provided
+ if (service.getLogoUrl() != null) {
+ try {
+ URLValidator.validateURL(service.getLogoUrl().toString());
+ } catch (Exception e) {
+ throw new IllegalArgumentException("Invalid logoUrl: " + e.getMessage());
+ }
+ }
+
if (service.getConnection() != null) {
service
.getConnection()
diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/services/DatabaseServiceLogoUrlTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/services/DatabaseServiceLogoUrlTest.java
new file mode 100644
index 000000000000..32a18a9516f8
--- /dev/null
+++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/services/DatabaseServiceLogoUrlTest.java
@@ -0,0 +1,183 @@
+/*
+ * Copyright 2025 Collate
+ * Licensed 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.openmetadata.service.resources.services;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.net.URI;
+import org.junit.jupiter.api.Test;
+import org.openmetadata.schema.api.services.CreateDatabaseService;
+import org.openmetadata.schema.entity.services.DatabaseService;
+import org.openmetadata.schema.api.services.DatabaseConnection;
+import org.openmetadata.schema.services.connections.database.CustomDatabaseConnection;
+
+/**
+ * Simple unit tests for logoUrl functionality in DatabaseService.
+ * This test focuses on the basic functionality without complex test infrastructure.
+ */
+public class DatabaseServiceLogoUrlTest {
+
+ @Test
+ void testDatabaseServiceWithLogoUrl() {
+ // Create a DatabaseService with logoUrl
+ DatabaseService service = new DatabaseService();
+ service.setName("test-service");
+ service.setDisplayName("Test Service");
+ service.setServiceType(CreateDatabaseService.DatabaseServiceType.CustomDatabase);
+
+ URI logoUrl = URI.create("https://example.com/custom-db-logo.png");
+ service.setLogoUrl(logoUrl);
+
+ // Verify logoUrl is set correctly
+ assertNotNull(service);
+ assertEquals(logoUrl, service.getLogoUrl());
+ assertEquals("https://example.com/custom-db-logo.png", service.getLogoUrl().toString());
+ assertEquals("CustomDatabase", service.getServiceType().value());
+ }
+
+ @Test
+ void testDatabaseServiceWithoutLogoUrl() {
+ // Create a DatabaseService without logoUrl
+ DatabaseService service = new DatabaseService();
+ service.setName("test-service");
+ service.setDisplayName("Test Service");
+ service.setServiceType(CreateDatabaseService.DatabaseServiceType.CustomDatabase);
+
+ // Verify logoUrl is null when not set
+ assertNotNull(service);
+ assertNull(service.getLogoUrl());
+ assertEquals("CustomDatabase", service.getServiceType().value());
+ }
+
+ @Test
+ void testCreateDatabaseServiceWithLogoUrl() {
+ // Create a CreateDatabaseService with logoUrl
+ CreateDatabaseService createRequest = new CreateDatabaseService();
+ createRequest.setName("test-service");
+ createRequest.setDisplayName("Test Service");
+ createRequest.setServiceType(CreateDatabaseService.DatabaseServiceType.CustomDatabase);
+
+ URI logoUrl = URI.create("https://example.com/custom-db-logo.svg");
+ createRequest.setLogoUrl(logoUrl);
+
+ // Set up connection
+ DatabaseConnection connection = new DatabaseConnection();
+ connection.setConfig(new CustomDatabaseConnection());
+ createRequest.setConnection(connection);
+
+ // Verify logoUrl is set correctly
+ assertNotNull(createRequest);
+ assertEquals(logoUrl, createRequest.getLogoUrl());
+ assertEquals("https://example.com/custom-db-logo.svg", createRequest.getLogoUrl().toString());
+ assertEquals("CustomDatabase", createRequest.getServiceType().value());
+ }
+
+ @Test
+ void testCreateDatabaseServiceWithoutLogoUrl() {
+ // Create a CreateDatabaseService without logoUrl
+ CreateDatabaseService createRequest = new CreateDatabaseService();
+ createRequest.setName("test-service");
+ createRequest.setDisplayName("Test Service");
+ createRequest.setServiceType(CreateDatabaseService.DatabaseServiceType.CustomDatabase);
+
+ // Set up connection
+ DatabaseConnection connection = new DatabaseConnection();
+ connection.setConfig(new CustomDatabaseConnection());
+ createRequest.setConnection(connection);
+
+ // Verify logoUrl is null when not set
+ assertNotNull(createRequest);
+ assertNull(createRequest.getLogoUrl());
+ assertEquals("CustomDatabase", createRequest.getServiceType().value());
+ }
+
+ @Test
+ void testLogoUrlWithDifferentFormats() {
+ // Test PNG format
+ URI pngUrl = URI.create("https://example.com/logo.png");
+ DatabaseService service1 = new DatabaseService();
+ service1.setLogoUrl(pngUrl);
+ assertEquals("https://example.com/logo.png", service1.getLogoUrl().toString());
+
+ // Test SVG format
+ URI svgUrl = URI.create("https://example.com/logo.svg");
+ DatabaseService service2 = new DatabaseService();
+ service2.setLogoUrl(svgUrl);
+ assertEquals("https://example.com/logo.svg", service2.getLogoUrl().toString());
+
+ // Test with query parameters
+ URI queryUrl = URI.create("https://example.com/logo.png?v=1.0&size=64");
+ DatabaseService service3 = new DatabaseService();
+ service3.setLogoUrl(queryUrl);
+ assertEquals("https://example.com/logo.png?v=1.0&size=64", service3.getLogoUrl().toString());
+
+ // Test with subdomain
+ URI subdomainUrl = URI.create("https://cdn.example.com/assets/logo.png");
+ DatabaseService service4 = new DatabaseService();
+ service4.setLogoUrl(subdomainUrl);
+ assertEquals("https://cdn.example.com/assets/logo.png", service4.getLogoUrl().toString());
+
+ // Test with port
+ URI portUrl = URI.create("https://example.com:8080/static/logo.png");
+ DatabaseService service5 = new DatabaseService();
+ service5.setLogoUrl(portUrl);
+ assertEquals("https://example.com:8080/static/logo.png", service5.getLogoUrl().toString());
+ }
+
+ @Test
+ void testLogoUrlSerialization() {
+ // Test that logoUrl can be serialized and deserialized
+ DatabaseService originalService = new DatabaseService();
+ originalService.setName("test-service");
+ originalService.setDisplayName("Test Service");
+ originalService.setServiceType(CreateDatabaseService.DatabaseServiceType.CustomDatabase);
+
+ URI logoUrl = URI.create("https://example.com/serialized-logo.png");
+ originalService.setLogoUrl(logoUrl);
+
+ // Verify the logoUrl is properly set
+ assertEquals(logoUrl, originalService.getLogoUrl());
+ assertEquals("https://example.com/serialized-logo.png", originalService.getLogoUrl().toString());
+ }
+
+ @Test
+ void testLogoUrlWithSpecialCharacters() {
+ // Test URL with special characters (encoded)
+ URI specialCharUrl = URI.create("https://example.com/logo%20with%20spaces.png");
+ DatabaseService service = new DatabaseService();
+ service.setLogoUrl(specialCharUrl);
+ assertEquals("https://example.com/logo%20with%20spaces.png", service.getLogoUrl().toString());
+ }
+
+ @Test
+ void testLogoUrlWithInternationalDomain() {
+ // Test international domain name
+ URI internationalUrl = URI.create("https://例え.jp/logo.png");
+ DatabaseService service = new DatabaseService();
+ service.setLogoUrl(internationalUrl);
+ assertEquals("https://例え.jp/logo.png", service.getLogoUrl().toString());
+ }
+
+ @Test
+ void testLogoUrlWithVeryLongUrl() {
+ // Test very long URL
+ String longUrlString = "https://example.com/very/long/path/to/logo/with/many/segments/and/parameters?param1=value1¶m2=value2¶m3=value3¶m4=value4¶m5=value5.png";
+ URI longUrl = URI.create(longUrlString);
+ DatabaseService service = new DatabaseService();
+ service.setLogoUrl(longUrl);
+ assertEquals(longUrlString, service.getLogoUrl().toString());
+ }
+}
\ No newline at end of file
diff --git a/openmetadata-spec/src/main/java/org/openmetadata/schema/ServiceEntityInterface.java b/openmetadata-spec/src/main/java/org/openmetadata/schema/ServiceEntityInterface.java
index 9158e1e5fd7c..21d56be9b9e4 100644
--- a/openmetadata-spec/src/main/java/org/openmetadata/schema/ServiceEntityInterface.java
+++ b/openmetadata-spec/src/main/java/org/openmetadata/schema/ServiceEntityInterface.java
@@ -13,6 +13,7 @@
package org.openmetadata.schema;
+import java.net.URI;
import java.util.List;
import org.openmetadata.schema.entity.services.connections.TestConnectionResult;
import org.openmetadata.schema.type.EntityReference;
@@ -38,4 +39,8 @@ public interface ServiceEntityInterface extends EntityInterface {
default EntityReference getIngestionRunner() {
return null;
}
+
+ default URI getLogoUrl() {
+ return null;
+ }
}
diff --git a/openmetadata-spec/src/main/resources/json/schema/api/services/createApiService.json b/openmetadata-spec/src/main/resources/json/schema/api/services/createApiService.json
index 3fe712c50a70..13ecbce6c2c9 100644
--- a/openmetadata-spec/src/main/resources/json/schema/api/services/createApiService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/api/services/createApiService.json
@@ -53,6 +53,11 @@
"type": "string"
}
},
+ "logoUrl": {
+ "description": "URL to the logo image for this service. Primarily used for custom service types.",
+ "type": "string",
+ "format": "uri"
+ },
"ingestionRunner" : {
"description": "The ingestion agent responsible for executing the ingestion pipeline.",
"$ref": "../../type/entityReference.json"
diff --git a/openmetadata-spec/src/main/resources/json/schema/api/services/createDashboardService.json b/openmetadata-spec/src/main/resources/json/schema/api/services/createDashboardService.json
index 154c65759b4e..1c6a46776334 100644
--- a/openmetadata-spec/src/main/resources/json/schema/api/services/createDashboardService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/api/services/createDashboardService.json
@@ -52,6 +52,11 @@
"type": "string"
}
},
+ "logoUrl": {
+ "description": "URL to the logo image for this service. Primarily used for custom service types.",
+ "type": "string",
+ "format": "uri"
+ },
"ingestionRunner" : {
"description": "The ingestion agent responsible for executing the ingestion pipeline.",
"$ref": "../../type/entityReference.json"
diff --git a/openmetadata-spec/src/main/resources/json/schema/api/services/createDatabaseService.json b/openmetadata-spec/src/main/resources/json/schema/api/services/createDatabaseService.json
index 10ca1afe3a06..faf660ea6186 100644
--- a/openmetadata-spec/src/main/resources/json/schema/api/services/createDatabaseService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/api/services/createDatabaseService.json
@@ -53,6 +53,11 @@
"type": "string"
}
},
+ "logoUrl": {
+ "description": "URL to the logo image for this service. Primarily used for custom service types.",
+ "type": "string",
+ "format": "uri"
+ },
"ingestionRunner" : {
"description": "The ingestion agent responsible for executing the ingestion pipeline. It will be defined at runtime based on the Ingestion Agent of the service.",
"$ref": "../../type/entityReference.json"
diff --git a/openmetadata-spec/src/main/resources/json/schema/api/services/createDriveService.json b/openmetadata-spec/src/main/resources/json/schema/api/services/createDriveService.json
index 7534d2c2bee1..9263b05ccfb6 100644
--- a/openmetadata-spec/src/main/resources/json/schema/api/services/createDriveService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/api/services/createDriveService.json
@@ -56,6 +56,11 @@
},
"default": null
},
+ "logoUrl": {
+ "description": "URL to the logo image for this service. Primarily used for custom service types.",
+ "type": "string",
+ "format": "uri"
+ },
"ingestionRunner": {
"description": "The ingestion agent responsible for executing the ingestion pipeline.",
"$ref": "../../type/entityReference.json"
diff --git a/openmetadata-spec/src/main/resources/json/schema/api/services/createMessagingService.json b/openmetadata-spec/src/main/resources/json/schema/api/services/createMessagingService.json
index da4f61a4dd3e..f93fed591e07 100644
--- a/openmetadata-spec/src/main/resources/json/schema/api/services/createMessagingService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/api/services/createMessagingService.json
@@ -53,6 +53,11 @@
"type": "string"
}
},
+ "logoUrl": {
+ "description": "URL to the logo image for this service. Primarily used for custom service types.",
+ "type": "string",
+ "format": "uri"
+ },
"ingestionRunner" : {
"description": "The ingestion agent responsible for executing the ingestion pipeline.",
"$ref": "../../type/entityReference.json"
diff --git a/openmetadata-spec/src/main/resources/json/schema/api/services/createMetadataService.json b/openmetadata-spec/src/main/resources/json/schema/api/services/createMetadataService.json
index 1c63038b56d0..2df4fcd4b936 100644
--- a/openmetadata-spec/src/main/resources/json/schema/api/services/createMetadataService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/api/services/createMetadataService.json
@@ -45,6 +45,11 @@
"type": "string"
}
},
+ "logoUrl": {
+ "description": "URL to the logo image for this service. Primarily used for custom service types.",
+ "type": "string",
+ "format": "uri"
+ },
"ingestionRunner" : {
"description": "The ingestion agent responsible for executing the ingestion pipeline.",
"$ref": "../../type/entityReference.json"
diff --git a/openmetadata-spec/src/main/resources/json/schema/api/services/createMlModelService.json b/openmetadata-spec/src/main/resources/json/schema/api/services/createMlModelService.json
index 35a1bebf6fad..f456092d71d0 100644
--- a/openmetadata-spec/src/main/resources/json/schema/api/services/createMlModelService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/api/services/createMlModelService.json
@@ -53,6 +53,11 @@
"type": "string"
}
},
+ "logoUrl": {
+ "description": "URL to the logo image for this service. Primarily used for custom service types.",
+ "type": "string",
+ "format": "uri"
+ },
"ingestionRunner" : {
"description": "The ingestion agent responsible for executing the ingestion pipeline.",
"$ref": "../../type/entityReference.json"
diff --git a/openmetadata-spec/src/main/resources/json/schema/api/services/createPipelineService.json b/openmetadata-spec/src/main/resources/json/schema/api/services/createPipelineService.json
index 0c056cbda1ed..68f1d718de82 100644
--- a/openmetadata-spec/src/main/resources/json/schema/api/services/createPipelineService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/api/services/createPipelineService.json
@@ -58,6 +58,11 @@
"type": "string"
}
},
+ "logoUrl": {
+ "description": "URL to the logo image for this service. Primarily used for custom service types.",
+ "type": "string",
+ "format": "uri"
+ },
"lifeCycle": {
"description": "Life Cycle of the entity",
"$ref": "../../type/lifeCycle.json"
diff --git a/openmetadata-spec/src/main/resources/json/schema/api/services/createSearchService.json b/openmetadata-spec/src/main/resources/json/schema/api/services/createSearchService.json
index bcefce358fdb..96d8909b5437 100644
--- a/openmetadata-spec/src/main/resources/json/schema/api/services/createSearchService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/api/services/createSearchService.json
@@ -53,6 +53,11 @@
"type": "string"
}
},
+ "logoUrl": {
+ "description": "URL to the logo image for this service. Primarily used for custom service types.",
+ "type": "string",
+ "format": "uri"
+ },
"ingestionRunner" : {
"description": "The ingestion agent responsible for executing the ingestion pipeline.",
"$ref": "../../type/entityReference.json"
diff --git a/openmetadata-spec/src/main/resources/json/schema/api/services/createSecurityService.json b/openmetadata-spec/src/main/resources/json/schema/api/services/createSecurityService.json
index 623051068e91..6879a703e1ca 100644
--- a/openmetadata-spec/src/main/resources/json/schema/api/services/createSecurityService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/api/services/createSecurityService.json
@@ -54,6 +54,11 @@
"type": "string"
}
},
+ "logoUrl": {
+ "description": "URL to the logo image for this service. Primarily used for custom service types.",
+ "type": "string",
+ "format": "uri"
+ },
"ingestionRunner": {
"description": "The ingestion agent responsible for executing the ingestion pipeline.",
"$ref": "../../type/entityReference.json"
diff --git a/openmetadata-spec/src/main/resources/json/schema/api/services/createStorageService.json b/openmetadata-spec/src/main/resources/json/schema/api/services/createStorageService.json
index fc7a6281f8ff..6ab1839fcef1 100644
--- a/openmetadata-spec/src/main/resources/json/schema/api/services/createStorageService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/api/services/createStorageService.json
@@ -53,6 +53,11 @@
"type": "string"
}
},
+ "logoUrl": {
+ "description": "URL to the logo image for this service. Primarily used for custom service types.",
+ "type": "string",
+ "format": "uri"
+ },
"ingestionRunner" : {
"description": "The ingestion agent responsible for executing the ingestion pipeline.",
"$ref": "../../type/entityReference.json"
diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/services/apiService.json b/openmetadata-spec/src/main/resources/json/schema/entity/services/apiService.json
index e5e230652038..7ae6cbdee987 100644
--- a/openmetadata-spec/src/main/resources/json/schema/entity/services/apiService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/entity/services/apiService.json
@@ -66,6 +66,12 @@
"description": "Display Name that identifies this API service.",
"type": "string"
},
+ "logoUrl": {
+ "title": "Service Logo URL",
+ "description": "URL to the logo image for this service. Primarily used for custom service types to provide a visual identifier.",
+ "type": "string",
+ "format": "uri"
+ },
"serviceType": {
"description": "Type of API service such as REST, WEBHOOK..",
"$ref": "#/definitions/apiServiceType"
diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/services/dashboardService.json b/openmetadata-spec/src/main/resources/json/schema/entity/services/dashboardService.json
index bdf6a5333726..00a91633db42 100644
--- a/openmetadata-spec/src/main/resources/json/schema/entity/services/dashboardService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/entity/services/dashboardService.json
@@ -179,6 +179,12 @@
"description": "Display Name that identifies this dashboard service.",
"type": "string"
},
+ "logoUrl": {
+ "title": "Service Logo URL",
+ "description": "URL to the logo image for this service. Primarily used for custom service types to provide a visual identifier.",
+ "type": "string",
+ "format": "uri"
+ },
"serviceType": {
"description": "Type of dashboard service such as Looker or Superset...",
"$ref": "#/definitions/dashboardServiceType"
diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/services/databaseService.json b/openmetadata-spec/src/main/resources/json/schema/entity/services/databaseService.json
index aadea67aa696..fbad00edfd9b 100644
--- a/openmetadata-spec/src/main/resources/json/schema/entity/services/databaseService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/entity/services/databaseService.json
@@ -396,6 +396,12 @@
"description": "Display Name that identifies this database service.",
"type": "string"
},
+ "logoUrl": {
+ "title": "Service Logo URL",
+ "description": "URL to the logo image for this service. Primarily used for custom service types to provide a visual identifier.",
+ "type": "string",
+ "format": "uri"
+ },
"serviceType": {
"description": "Type of database service such as MySQL, BigQuery, Snowflake, Redshift, Postgres...",
"$ref": "#/definitions/databaseServiceType"
diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/services/driveService.json b/openmetadata-spec/src/main/resources/json/schema/entity/services/driveService.json
index d8532fd7fb86..c837bd563847 100644
--- a/openmetadata-spec/src/main/resources/json/schema/entity/services/driveService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/entity/services/driveService.json
@@ -76,6 +76,12 @@
"description": "Display Name that identifies this drive service.",
"type": "string"
},
+ "logoUrl": {
+ "title": "Service Logo URL",
+ "description": "URL to the logo image for this service. Primarily used for custom service types to provide a visual identifier.",
+ "type": "string",
+ "format": "uri"
+ },
"serviceType": {
"description": "Type of drive service such as Google Drive...",
"$ref": "#/definitions/driveServiceType"
diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/services/messagingService.json b/openmetadata-spec/src/main/resources/json/schema/entity/services/messagingService.json
index 21f8d8ff6cd0..e2ac474148b8 100644
--- a/openmetadata-spec/src/main/resources/json/schema/entity/services/messagingService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/entity/services/messagingService.json
@@ -92,6 +92,12 @@
"description": "Display Name that identifies this messaging service. It could be title or label from the source services.",
"type": "string"
},
+ "logoUrl": {
+ "title": "Service Logo URL",
+ "description": "URL to the logo image for this service. Primarily used for custom service types to provide a visual identifier.",
+ "type": "string",
+ "format": "uri"
+ },
"connection": {
"$ref": "#/definitions/messagingConnection"
},
diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/services/metadataService.json b/openmetadata-spec/src/main/resources/json/schema/entity/services/metadataService.json
index 06afaf394d72..09da6d11f175 100644
--- a/openmetadata-spec/src/main/resources/json/schema/entity/services/metadataService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/entity/services/metadataService.json
@@ -88,6 +88,12 @@
"description": "Display Name that identifies this database service.",
"type": "string"
},
+ "logoUrl": {
+ "title": "Service Logo URL",
+ "description": "URL to the logo image for this service. Primarily used for custom service types to provide a visual identifier.",
+ "type": "string",
+ "format": "uri"
+ },
"serviceType": {
"description": "Type of database service such as MySQL, BigQuery, Snowflake, Redshift, Postgres...",
"$ref": "#/definitions/metadataServiceType"
diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/services/mlmodelService.json b/openmetadata-spec/src/main/resources/json/schema/entity/services/mlmodelService.json
index 4e4b8f571253..c43071a8a0d7 100644
--- a/openmetadata-spec/src/main/resources/json/schema/entity/services/mlmodelService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/entity/services/mlmodelService.json
@@ -90,6 +90,12 @@
"description": "Display Name that identifies this pipeline service. It could be title or label from the source services.",
"type": "string"
},
+ "logoUrl": {
+ "title": "Service Logo URL",
+ "description": "URL to the logo image for this service. Primarily used for custom service types to provide a visual identifier.",
+ "type": "string",
+ "format": "uri"
+ },
"version": {
"description": "Metadata version of the entity.",
"$ref": "../../type/entityHistory.json#/definitions/entityVersion"
diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/services/pipelineService.json b/openmetadata-spec/src/main/resources/json/schema/entity/services/pipelineService.json
index c4414edb12f2..8f6cc8a6547a 100644
--- a/openmetadata-spec/src/main/resources/json/schema/entity/services/pipelineService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/entity/services/pipelineService.json
@@ -210,6 +210,12 @@
"description": "Display Name that identifies this pipeline service. It could be title or label from the source services.",
"type": "string"
},
+ "logoUrl": {
+ "title": "Service Logo URL",
+ "description": "URL to the logo image for this service. Primarily used for custom service types to provide a visual identifier.",
+ "type": "string",
+ "format": "uri"
+ },
"version": {
"description": "Metadata version of the entity.",
"$ref": "../../type/entityHistory.json#/definitions/entityVersion"
diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/services/searchService.json b/openmetadata-spec/src/main/resources/json/schema/entity/services/searchService.json
index 5b5434b194af..eb72b8645b53 100644
--- a/openmetadata-spec/src/main/resources/json/schema/entity/services/searchService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/entity/services/searchService.json
@@ -76,6 +76,12 @@
"description": "Display Name that identifies this search service.",
"type": "string"
},
+ "logoUrl": {
+ "title": "Service Logo URL",
+ "description": "URL to the logo image for this service. Primarily used for custom service types to provide a visual identifier.",
+ "type": "string",
+ "format": "uri"
+ },
"serviceType": {
"description": "Type of search service such as S3, GCS, AZURE...",
"$ref": "#/definitions/searchServiceType"
diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/services/securityService.json b/openmetadata-spec/src/main/resources/json/schema/entity/services/securityService.json
index b1923ecf6bba..d505d4bcfbb0 100644
--- a/openmetadata-spec/src/main/resources/json/schema/entity/services/securityService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/entity/services/securityService.json
@@ -71,6 +71,12 @@
"description": "Display Name that identifies this security service. It could be title or label from the source services.",
"type": "string"
},
+ "logoUrl": {
+ "title": "Service Logo URL",
+ "description": "URL to the logo image for this service. Primarily used for custom service types to provide a visual identifier.",
+ "type": "string",
+ "format": "uri"
+ },
"version": {
"description": "Metadata version of the entity.",
"$ref": "../../type/entityHistory.json#/definitions/entityVersion"
diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/services/storageService.json b/openmetadata-spec/src/main/resources/json/schema/entity/services/storageService.json
index f0be66dfff17..0b1eeb9e38ed 100644
--- a/openmetadata-spec/src/main/resources/json/schema/entity/services/storageService.json
+++ b/openmetadata-spec/src/main/resources/json/schema/entity/services/storageService.json
@@ -83,6 +83,12 @@
"description": "Display Name that identifies this storage service.",
"type": "string"
},
+ "logoUrl": {
+ "title": "Service Logo URL",
+ "description": "URL to the logo image for this service. Primarily used for custom service types to provide a visual identifier.",
+ "type": "string",
+ "format": "uri"
+ },
"serviceType": {
"description": "Type of storage service such as S3, GCS, AZURE...",
"$ref": "#/definitions/storageServiceType"
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.tsx
index eb9418c9e116..052fdc506174 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/Explore/ExploreTree/ExploreTree.tsx
@@ -27,7 +27,7 @@ import { EntityType } from '../../../enums/entity.enum';
import { ExplorePageTabs } from '../../../enums/Explore.enum';
import { SearchIndex } from '../../../enums/search.enum';
import { searchQuery } from '../../../rest/searchAPI';
-import { getCountBadge, Transi18next } from '../../../utils/CommonUtils';
+import { getCountBadge, getServiceLogo, Transi18next } from '../../../utils/CommonUtils';
import entityUtilClassBase from '../../../utils/EntityUtilClassBase';
import { getPluralizeEntityName } from '../../../utils/EntityUtils';
import {
@@ -182,14 +182,7 @@ const ExploreTree = ({ onFieldValueSelect }: ExploreTreeProps) => {
'service-icon w-4 h-4'
) ?? <>>;
} else if (isServiceType) {
- const serviceIcon = serviceUtilClassBase.getServiceLogo(bucket.key);
- logo = (
-
- );
+ logo = getServiceLogo(bucket.key, 'w-4 h-4', { serviceType: bucket.key });
} else if (bucketToFind === EntityFields.DATABASE_DISPLAY_NAME) {
type = 'Database';
logo = searchClassBase.getEntityIcon(
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/DataAssetsWidget/DataAssetCard/DataAssetCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/DataAssetsWidget/DataAssetCard/DataAssetCard.component.tsx
index 0395601bea4a..8fe018bdb103 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/DataAssetsWidget/DataAssetCard/DataAssetCard.component.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/MyData/Widgets/DataAssetsWidget/DataAssetCard/DataAssetCard.component.tsx
@@ -56,7 +56,7 @@ const DataAssetCard = ({ service: { key, doc_count } }: DataAssetCardProps) => {
- {getServiceLogo(capitalize(key) ?? '', 'h-8')}
+ {getServiceLogo(capitalize(key) ?? '', 'h-8', { serviceType: capitalize(key) })}
({
+ postService: jest.fn(),
+ getServices: jest.fn(),
+}));
+
+jest.mock('../../../../../../context/PermissionProvider/PermissionProvider', () => ({
+ usePermissionProvider: () => ({
+ permissions: {
+ DatabaseService: { Create: true, Delete: true, ViewAll: true },
+ },
+ }),
+}));
+
+jest.mock('../../../../../../context/AirflowStatusProvider/AirflowStatusProvider', () => ({
+ useAirflowStatus: () => ({
+ isFetchingStatus: false,
+ }),
+}));
+
+jest.mock('../../../../../../hooks/paging/usePaging', () => ({
+ usePaging: () => ({
+ paging: { total: 0, offset: 0, limit: 10 },
+ handlePagingChange: jest.fn(),
+ currentPage: 1,
+ handlePageChange: jest.fn(),
+ pageSize: 10,
+ handlePageSizeChange: jest.fn(),
+ showPagination: false,
+ }),
+}));
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: () => jest.fn(),
+ useParams: () => ({ serviceCategory: 'databaseServices' }),
+}));
+
+import { postService, getServices } from '../../../../../../rest/serviceAPI';
+
+const mockPostService = postService as jest.MockedFunction;
+const mockGetServices = getServices as jest.MockedFunction;
+
+describe('Service Logo URL - End-to-End Integration', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('Complete Service Creation Flow with Custom Logo', () => {
+ it('should create service with custom logo and display it in service list', async () => {
+ const customLogoUrl = 'https://example.com/my-custom-logo.png';
+ const serviceName = 'my-custom-database';
+
+ // Mock successful service creation
+ const createdService = {
+ id: '1',
+ name: serviceName,
+ displayName: 'My Custom Database',
+ serviceType: 'CustomDatabase',
+ fullyQualifiedName: serviceName,
+ description: 'A custom database service',
+ logoUrl: customLogoUrl,
+ deleted: false,
+ href: `/services/databaseServices/${serviceName}`,
+ };
+
+ mockPostService.mockResolvedValue(createdService);
+ mockGetServices.mockResolvedValue({
+ data: [createdService],
+ paging: { total: 1, offset: 0, limit: 10 },
+ });
+
+ // Render the AddServicePage
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ // Wait for the page to load
+ await waitFor(() => {
+ expect(screen.getByTestId('add-new-service-container')).toBeInTheDocument();
+ });
+
+ // Fill in service configuration
+ const serviceNameField = screen.getByTestId('service-name');
+ const logoUrlField = screen.getByTestId('logo-url');
+ const nextButton = screen.getByTestId('next-button');
+
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: serviceName } });
+ fireEvent.change(logoUrlField, { target: { value: customLogoUrl } });
+ fireEvent.click(nextButton);
+ });
+
+ // Verify the service was created with the custom logo
+ expect(mockPostService).toHaveBeenCalledWith(
+ ServiceCategory.DATABASE_SERVICES,
+ expect.objectContaining({
+ name: serviceName,
+ logoUrl: customLogoUrl,
+ })
+ );
+
+ // Now test that the service appears in the service list with the custom logo
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('services-container')).toBeInTheDocument();
+ });
+
+ // Verify the custom logo is displayed
+ const serviceIcon = screen.getByTestId('service-icon');
+ const logoImg = serviceIcon.querySelector('img');
+ expect(logoImg).toHaveAttribute('src', customLogoUrl);
+ });
+
+ it('should handle service creation without custom logo', async () => {
+ const serviceName = 'my-standard-database';
+
+ // Mock successful service creation without logo
+ const createdService = {
+ id: '1',
+ name: serviceName,
+ displayName: 'My Standard Database',
+ serviceType: 'CustomDatabase',
+ fullyQualifiedName: serviceName,
+ description: 'A standard database service',
+ logoUrl: null,
+ deleted: false,
+ href: `/services/databaseServices/${serviceName}`,
+ };
+
+ mockPostService.mockResolvedValue(createdService);
+ mockGetServices.mockResolvedValue({
+ data: [createdService],
+ paging: { total: 1, offset: 0, limit: 10 },
+ });
+
+ // Render the AddServicePage
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ // Wait for the page to load
+ await waitFor(() => {
+ expect(screen.getByTestId('add-new-service-container')).toBeInTheDocument();
+ });
+
+ // Fill in service configuration without logo
+ const serviceNameField = screen.getByTestId('service-name');
+ const nextButton = screen.getByTestId('next-button');
+
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: serviceName } });
+ fireEvent.click(nextButton);
+ });
+
+ // Verify the service was created without logo
+ expect(mockPostService).toHaveBeenCalledWith(
+ ServiceCategory.DATABASE_SERVICES,
+ expect.objectContaining({
+ name: serviceName,
+ logoUrl: '',
+ })
+ );
+
+ // Verify the service appears with default logo
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('services-container')).toBeInTheDocument();
+ });
+
+ // Verify the default logo is displayed
+ const serviceIcon = screen.getByTestId('service-icon');
+ const logoImg = serviceIcon.querySelector('img');
+ expect(logoImg).toHaveAttribute('src', expect.stringContaining('database'));
+ });
+ });
+
+ describe('Multiple Service Types with Custom Logos', () => {
+ const serviceTypeTestCases = [
+ {
+ category: ServiceCategory.DATABASE_SERVICES,
+ serviceType: 'CustomDatabase',
+ logoUrl: 'https://example.com/db-logo.png',
+ },
+ {
+ category: ServiceCategory.PIPELINE_SERVICES,
+ serviceType: 'CustomPipeline',
+ logoUrl: 'https://example.com/pipeline-logo.svg',
+ },
+ {
+ category: ServiceCategory.DASHBOARD_SERVICES,
+ serviceType: 'CustomDashboard',
+ logoUrl: 'https://example.com/dashboard-logo.png',
+ },
+ ];
+
+ serviceTypeTestCases.forEach(({ category, serviceType, logoUrl }) => {
+ it(`should handle ${serviceType} service with custom logo`, async () => {
+ const serviceName = `test-${serviceType.toLowerCase()}`;
+
+ const createdService = {
+ id: '1',
+ name: serviceName,
+ displayName: `Test ${serviceType}`,
+ serviceType,
+ fullyQualifiedName: serviceName,
+ description: `Test ${serviceType} service`,
+ logoUrl,
+ deleted: false,
+ href: `/services/${category.toLowerCase()}/${serviceName}`,
+ };
+
+ mockPostService.mockResolvedValue(createdService);
+ mockGetServices.mockResolvedValue({
+ data: [createdService],
+ paging: { total: 1, offset: 0, limit: 10 },
+ });
+
+ // Test service creation
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('add-new-service-container')).toBeInTheDocument();
+ });
+
+ const serviceNameField = screen.getByTestId('service-name');
+ const logoUrlField = screen.getByTestId('logo-url');
+ const nextButton = screen.getByTestId('next-button');
+
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: serviceName } });
+ fireEvent.change(logoUrlField, { target: { value: logoUrl } });
+ fireEvent.click(nextButton);
+ });
+
+ expect(mockPostService).toHaveBeenCalledWith(
+ category,
+ expect.objectContaining({
+ name: serviceName,
+ logoUrl,
+ })
+ );
+
+ // Test service display
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('services-container')).toBeInTheDocument();
+ });
+
+ const serviceIcon = screen.getByTestId('service-icon');
+ const logoImg = serviceIcon.querySelector('img');
+ expect(logoImg).toHaveAttribute('src', logoUrl);
+ });
+ });
+ });
+
+ describe('Error Handling in Complete Flow', () => {
+ it('should handle service creation failure gracefully', async () => {
+ const serviceName = 'failing-service';
+ const logoUrl = 'https://example.com/logo.png';
+
+ mockPostService.mockRejectedValue(new Error('Service creation failed'));
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('add-new-service-container')).toBeInTheDocument();
+ });
+
+ const serviceNameField = screen.getByTestId('service-name');
+ const logoUrlField = screen.getByTestId('logo-url');
+ const nextButton = screen.getByTestId('next-button');
+
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: serviceName } });
+ fireEvent.change(logoUrlField, { target: { value: logoUrl } });
+ fireEvent.click(nextButton);
+ });
+
+ // Should handle the error gracefully
+ await waitFor(() => {
+ expect(screen.getByText(/error/i)).toBeInTheDocument();
+ });
+ });
+
+ it('should handle logo load failure in service list', async () => {
+ const serviceName = 'broken-logo-service';
+ const brokenLogoUrl = 'https://broken-url.com/logo.png';
+
+ const serviceWithBrokenLogo = {
+ id: '1',
+ name: serviceName,
+ displayName: 'Service with Broken Logo',
+ serviceType: 'CustomDatabase',
+ fullyQualifiedName: serviceName,
+ description: 'Service with broken logo URL',
+ logoUrl: brokenLogoUrl,
+ deleted: false,
+ href: `/services/databaseServices/${serviceName}`,
+ };
+
+ mockGetServices.mockResolvedValue({
+ data: [serviceWithBrokenLogo],
+ paging: { total: 1, offset: 0, limit: 10 },
+ });
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('services-container')).toBeInTheDocument();
+ });
+
+ const serviceIcon = screen.getByTestId('service-icon');
+ const logoImg = serviceIcon.querySelector('img') as HTMLImageElement;
+
+ expect(logoImg).toHaveAttribute('src', brokenLogoUrl);
+
+ // Simulate image load error
+ const errorEvent = new Event('error');
+ logoImg.dispatchEvent(errorEvent);
+
+ // Should fallback to default logo
+ await waitFor(() => {
+ expect(logoImg.src).not.toBe(brokenLogoUrl);
+ expect(logoImg.src).toContain('database');
+ });
+ });
+ });
+
+ describe('Form Validation Integration', () => {
+ it('should validate logo URL format during service creation', async () => {
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('add-new-service-container')).toBeInTheDocument();
+ });
+
+ const serviceNameField = screen.getByTestId('service-name');
+ const logoUrlField = screen.getByTestId('logo-url');
+ const nextButton = screen.getByTestId('next-button');
+
+ // Try to submit with invalid URL
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: 'test-service' } });
+ fireEvent.change(logoUrlField, { target: { value: 'not-a-valid-url' } });
+ fireEvent.click(nextButton);
+ });
+
+ // Should show validation error
+ await waitFor(() => {
+ expect(screen.getByText(/invalid.*url/i)).toBeInTheDocument();
+ });
+ });
+
+ it('should allow service creation with valid logo URL', async () => {
+ const validLogoUrl = 'https://example.com/valid-logo.png';
+
+ mockPostService.mockResolvedValue({
+ id: '1',
+ name: 'valid-service',
+ logoUrl: validLogoUrl,
+ });
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('add-new-service-container')).toBeInTheDocument();
+ });
+
+ const serviceNameField = screen.getByTestId('service-name');
+ const logoUrlField = screen.getByTestId('logo-url');
+ const nextButton = screen.getByTestId('next-button');
+
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: 'valid-service' } });
+ fireEvent.change(logoUrlField, { target: { value: validLogoUrl } });
+ fireEvent.click(nextButton);
+ });
+
+ // Should proceed to next step without validation errors
+ expect(mockPostService).toHaveBeenCalledWith(
+ ServiceCategory.DATABASE_SERVICES,
+ expect.objectContaining({
+ name: 'valid-service',
+ logoUrl: validLogoUrl,
+ })
+ );
+ });
+ });
+});
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddService/Steps/ConfigureService.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddService/Steps/ConfigureService.test.tsx
index 6f101d68c3af..19bbfe6bffa3 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddService/Steps/ConfigureService.test.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddService/Steps/ConfigureService.test.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright 2022 Collate.
+ * Copyright 2025 Collate
* Licensed 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
@@ -11,61 +11,365 @@
* limitations under the License.
*/
-import { act, fireEvent, render, screen } from '@testing-library/react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { act } from 'react-dom/test-utils';
import ConfigureService from './ConfigureService';
import { ConfigureServiceProps } from './Steps.interface';
const mockOnNext = jest.fn();
+const mockOnBack = jest.fn();
const mockConfigureServiceProps: ConfigureServiceProps = {
serviceName: 'testService',
- onBack: jest.fn(),
+ onBack: mockOnBack,
onNext: mockOnNext,
};
-describe('Test ConfigureService component', () => {
- it('ConfigureService component should render', async () => {
- render( );
-
- const configureServiceContainer = screen.getByTestId(
- 'configure-service-container'
- );
- const serviceName = screen.getByTestId('service-name');
- const backButton = screen.getByTestId('back-button');
- const nextButton = screen.getByTestId('next-button');
- const richTextEditor = screen.getByTestId('editor');
-
- expect(configureServiceContainer).toBeInTheDocument();
- expect(richTextEditor).toBeInTheDocument();
- expect(serviceName).toBeInTheDocument();
- expect(backButton).toBeInTheDocument();
- expect(nextButton).toBeInTheDocument();
+describe('ConfigureService Component - Logo URL Field', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
});
- it('Back button should work', () => {
- render( );
- const backButton = screen.getByTestId('back-button');
+ describe('Logo URL Field Rendering', () => {
+ it('should render logo URL field', async () => {
+ render( );
- fireEvent.click(backButton);
+ const logoUrlField = screen.getByTestId('logo-url');
+ expect(logoUrlField).toBeInTheDocument();
+ });
+
+ it('should have correct label for logo URL field', async () => {
+ render( );
+
+ const logoUrlLabel = screen.getByText('label.service-logo-url');
+ expect(logoUrlLabel).toBeInTheDocument();
+ });
+
+ it('should have correct placeholder for logo URL field', async () => {
+ render( );
+
+ const logoUrlField = screen.getByTestId('logo-url');
+ expect(logoUrlField).toHaveAttribute('placeholder', 'message.enter-service-logo-url');
+ });
+
+ it('should have help text for logo URL field', async () => {
+ render( );
+
+ const helpText = screen.getByText('message.service-logo-url-help');
+ expect(helpText).toBeInTheDocument();
+ });
+ });
+
+ describe('Logo URL Field Validation', () => {
+ it('should accept valid HTTP URL', async () => {
+ render( );
+
+ const logoUrlField = screen.getByTestId('logo-url');
+ const serviceNameField = screen.getByTestId('service-name');
+ const nextButton = screen.getByTestId('next-button');
+
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: 'test-service' } });
+ fireEvent.change(logoUrlField, { target: { value: 'http://example.com/logo.png' } });
+ fireEvent.click(nextButton);
+ });
+
+ expect(mockOnNext).toHaveBeenCalledWith({
+ name: 'test-service',
+ description: '',
+ logoUrl: 'http://example.com/logo.png',
+ });
+ });
+
+ it('should accept valid HTTPS URL', async () => {
+ render( );
+
+ const logoUrlField = screen.getByTestId('logo-url');
+ const serviceNameField = screen.getByTestId('service-name');
+ const nextButton = screen.getByTestId('next-button');
+
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: 'test-service' } });
+ fireEvent.change(logoUrlField, { target: { value: 'https://example.com/logo.svg' } });
+ fireEvent.click(nextButton);
+ });
+
+ expect(mockOnNext).toHaveBeenCalledWith({
+ name: 'test-service',
+ description: '',
+ logoUrl: 'https://example.com/logo.svg',
+ });
+ });
+
+ it('should accept valid URL with subdomain', async () => {
+ render( );
+
+ const logoUrlField = screen.getByTestId('logo-url');
+ const serviceNameField = screen.getByTestId('service-name');
+ const nextButton = screen.getByTestId('next-button');
+
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: 'test-service' } });
+ fireEvent.change(logoUrlField, { target: { value: 'https://cdn.example.com/assets/logo.png' } });
+ fireEvent.click(nextButton);
+ });
- expect(mockConfigureServiceProps.onBack).toHaveBeenCalled();
+ expect(mockOnNext).toHaveBeenCalledWith({
+ name: 'test-service',
+ description: '',
+ logoUrl: 'https://cdn.example.com/assets/logo.png',
+ });
+ });
+
+ it('should accept valid URL with query parameters', async () => {
+ render( );
+
+ const logoUrlField = screen.getByTestId('logo-url');
+ const serviceNameField = screen.getByTestId('service-name');
+ const nextButton = screen.getByTestId('next-button');
+
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: 'test-service' } });
+ fireEvent.change(logoUrlField, { target: { value: 'https://example.com/logo.png?v=1.0&size=64' } });
+ fireEvent.click(nextButton);
+ });
+
+ expect(mockOnNext).toHaveBeenCalledWith({
+ name: 'test-service',
+ description: '',
+ logoUrl: 'https://example.com/logo.png?v=1.0&size=64',
+ });
+ });
+
+ it('should accept valid URL with port', async () => {
+ render( );
+
+ const logoUrlField = screen.getByTestId('logo-url');
+ const serviceNameField = screen.getByTestId('service-name');
+ const nextButton = screen.getByTestId('next-button');
+
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: 'test-service' } });
+ fireEvent.change(logoUrlField, { target: { value: 'https://example.com:8080/logo.png' } });
+ fireEvent.click(nextButton);
+ });
+
+ expect(mockOnNext).toHaveBeenCalledWith({
+ name: 'test-service',
+ description: '',
+ logoUrl: 'https://example.com:8080/logo.png',
+ });
+ });
});
- it('Next button should work', async () => {
- render( );
+ describe('Logo URL Field Optional Behavior', () => {
+ it('should allow empty logo URL field', async () => {
+ render( );
+
+ const serviceNameField = screen.getByTestId('service-name');
+ const nextButton = screen.getByTestId('next-button');
+
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: 'test-service' } });
+ fireEvent.click(nextButton);
+ });
+
+ expect(mockOnNext).toHaveBeenCalledWith({
+ name: 'test-service',
+ description: '',
+ logoUrl: '',
+ });
+ });
+
+ it('should handle whitespace-only logo URL', async () => {
+ render( );
+
+ const logoUrlField = screen.getByTestId('logo-url');
+ const serviceNameField = screen.getByTestId('service-name');
+ const nextButton = screen.getByTestId('next-button');
+
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: 'test-service' } });
+ fireEvent.change(logoUrlField, { target: { value: ' ' } });
+ fireEvent.click(nextButton);
+ });
- await act(async () => {
- fireEvent.change(await screen.findByTestId('service-name'), {
- target: { value: 'newName' },
+ expect(mockOnNext).toHaveBeenCalledWith({
+ name: 'test-service',
+ description: '',
+ logoUrl: ' ',
});
- fireEvent.click(await screen.findByTestId('next-button'));
});
- expect(await screen.findByTestId('service-name')).toHaveValue('newName');
+ it('should not require logo URL field for form submission', async () => {
+ render( );
+
+ const serviceNameField = screen.getByTestId('service-name');
+ const nextButton = screen.getByTestId('next-button');
+
+ // Form should submit successfully without logo URL
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: 'test-service' } });
+ fireEvent.click(nextButton);
+ });
- expect(mockOnNext).toHaveBeenCalledWith({
- description: '',
- name: 'newName',
+ expect(mockOnNext).toHaveBeenCalledWith({
+ name: 'test-service',
+ description: '',
+ logoUrl: '',
+ });
+ });
+ });
+
+ describe('Form Integration', () => {
+ it('should include logo URL in form submission with other fields', async () => {
+ render( );
+
+ const serviceNameField = screen.getByTestId('service-name');
+ const logoUrlField = screen.getByTestId('logo-url');
+ const nextButton = screen.getByTestId('next-button');
+
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: 'my-custom-service' } });
+ fireEvent.change(logoUrlField, { target: { value: 'https://mycompany.com/logo.png' } });
+ fireEvent.click(nextButton);
+ });
+
+ expect(mockOnNext).toHaveBeenCalledWith({
+ name: 'my-custom-service',
+ description: '',
+ logoUrl: 'https://mycompany.com/logo.png',
+ });
+ });
+
+ it('should preserve logo URL when going back and forth', async () => {
+ render( );
+
+ const serviceNameField = screen.getByTestId('service-name');
+ const logoUrlField = screen.getByTestId('logo-url');
+
+ // Fill in the form
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: 'test-service' } });
+ fireEvent.change(logoUrlField, { target: { value: 'https://example.com/logo.png' } });
+ });
+
+ // Verify values are preserved
+ expect(serviceNameField).toHaveValue('test-service');
+ expect(logoUrlField).toHaveValue('https://example.com/logo.png');
+
+ // Go to next step
+ const nextButton = screen.getByTestId('next-button');
+ await act(async () => {
+ fireEvent.click(nextButton);
+ });
+
+ expect(mockOnNext).toHaveBeenCalledWith({
+ name: 'test-service',
+ description: '',
+ logoUrl: 'https://example.com/logo.png',
+ });
+ });
+ });
+
+ describe('User Experience', () => {
+ it('should have proper input type for logo URL field', async () => {
+ render( );
+
+ const logoUrlField = screen.getByTestId('logo-url');
+ expect(logoUrlField).toHaveAttribute('type', 'text');
+ });
+
+ it('should be accessible with proper labels', async () => {
+ render( );
+
+ const logoUrlField = screen.getByTestId('logo-url');
+ const label = screen.getByText('label.service-logo-url');
+
+ expect(logoUrlField).toBeInTheDocument();
+ expect(label).toBeInTheDocument();
+ });
+
+ it('should show help text to guide users', async () => {
+ render( );
+
+ const helpText = screen.getByText('message.service-logo-url-help');
+ expect(helpText).toBeInTheDocument();
+ });
+
+ it('should have appropriate placeholder text', async () => {
+ render( );
+
+ const logoUrlField = screen.getByTestId('logo-url');
+ expect(logoUrlField).toHaveAttribute('placeholder', 'message.enter-service-logo-url');
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle very long URLs', async () => {
+ render( );
+
+ const logoUrlField = screen.getByTestId('logo-url');
+ const serviceNameField = screen.getByTestId('service-name');
+ const nextButton = screen.getByTestId('next-button');
+
+ const longUrl = 'https://example.com/very/long/path/to/logo/with/many/segments/and/parameters?param1=value1¶m2=value2¶m3=value3¶m4=value4¶m5=value5.png';
+
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: 'test-service' } });
+ fireEvent.change(logoUrlField, { target: { value: longUrl } });
+ fireEvent.click(nextButton);
+ });
+
+ expect(mockOnNext).toHaveBeenCalledWith({
+ name: 'test-service',
+ description: '',
+ logoUrl: longUrl,
+ });
+ });
+
+ it('should handle URLs with special characters', async () => {
+ render( );
+
+ const logoUrlField = screen.getByTestId('logo-url');
+ const serviceNameField = screen.getByTestId('service-name');
+ const nextButton = screen.getByTestId('next-button');
+
+ const specialCharUrl = 'https://example.com/logo%20with%20spaces.png';
+
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: 'test-service' } });
+ fireEvent.change(logoUrlField, { target: { value: specialCharUrl } });
+ fireEvent.click(nextButton);
+ });
+
+ expect(mockOnNext).toHaveBeenCalledWith({
+ name: 'test-service',
+ description: '',
+ logoUrl: specialCharUrl,
+ });
+ });
+
+ it('should handle international domain names', async () => {
+ render( );
+
+ const logoUrlField = screen.getByTestId('logo-url');
+ const serviceNameField = screen.getByTestId('service-name');
+ const nextButton = screen.getByTestId('next-button');
+
+ const internationalUrl = 'https://例え.jp/logo.png';
+
+ await act(async () => {
+ fireEvent.change(serviceNameField, { target: { value: 'test-service' } });
+ fireEvent.change(logoUrlField, { target: { value: internationalUrl } });
+ fireEvent.click(nextButton);
+ });
+
+ expect(mockOnNext).toHaveBeenCalledWith({
+ name: 'test-service',
+ description: '',
+ logoUrl: internationalUrl,
+ });
});
});
-});
+});
\ No newline at end of file
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddService/Steps/ConfigureService.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddService/Steps/ConfigureService.tsx
index 6eb5d8ba32da..c2f8c378ea2d 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddService/Steps/ConfigureService.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddService/Steps/ConfigureService.tsx
@@ -61,10 +61,28 @@ const ConfigureService = ({
initialValue: '',
},
},
+ {
+ name: 'logoUrl',
+ required: false,
+ label: t('label.service-logo-url'),
+ id: 'root/logoUrl',
+ type: FieldTypes.TEXT,
+ props: {
+ 'data-testid': 'logo-url',
+ placeholder: t('message.enter-service-logo-url'),
+ },
+ formItemProps: {
+ help: t('message.service-logo-url-help'),
+ },
+ },
];
const handleSubmit: FormProps['onFinish'] = (data) => {
- onNext({ name: data.name, description: data.description ?? '' });
+ onNext({
+ name: data.name,
+ description: data.description ?? '',
+ logoUrl: data.logoUrl ?? ''
+ });
};
return (
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddService/Steps/SelectServiceType.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddService/Steps/SelectServiceType.tsx
index 62b67cbf3a16..58909c315255 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddService/Steps/SelectServiceType.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddService/Steps/SelectServiceType.tsx
@@ -140,7 +140,7 @@ const SelectServiceType = ({
key={type}
onClick={() => handleServiceTypeClick(type)}>
- {getServiceLogo(type || '', 'h-9')}
+ {getServiceLogo(type || '', 'h-9', { serviceType: type })}
{type === selectServiceType && (
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddService/Steps/Steps.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddService/Steps/Steps.interface.ts
index b2346eb7f885..140a822ea3df 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddService/Steps/Steps.interface.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/AddService/Steps/Steps.interface.ts
@@ -27,5 +27,5 @@ export type SelectServiceTypeProps = {
export type ConfigureServiceProps = {
serviceName: string;
onBack: () => void;
- onNext: (data: Pick
) => void;
+ onNext: (data: Pick) => void;
};
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.test.tsx
index c77eaf30dbaa..ca7fbd17cec1 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.test.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.test.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright 2024 Collate.
+ * Copyright 2025 Collate
* Licensed 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
@@ -10,375 +10,391 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { act, fireEvent, render, screen } from '@testing-library/react';
-import { ColumnsType } from 'antd/lib/table';
-import React, { ReactNode } from 'react';
-import { DISABLED } from '../../../constants/constants';
-import { PAGE_HEADERS } from '../../../constants/PageHeaders.constant';
-import { PIPELINE_SERVICE_PLATFORM } from '../../../constants/Services.constant';
-import { useAirflowStatus } from '../../../context/AirflowStatusProvider/AirflowStatusProvider';
-import { ServiceCategory } from '../../../enums/service.enum';
-import { PipelineServiceType } from '../../../generated/entity/data/pipeline';
-import LimitWrapper from '../../../hoc/LimitWrapper';
+
+import { render, screen, waitFor } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { act } from 'react-dom/test-utils';
import Services from './Services';
+import { ServiceCategory } from '../../../../enums/service.enum';
-let isDescription = true;
-
-const mockService = {
- name: ServiceCategory.PIPELINE_SERVICES,
- serviceType: PipelineServiceType.Airbyte,
-};
-
-const services = [
- {
- name: ServiceCategory.DATABASE_SERVICES,
- header: PAGE_HEADERS.DATABASES_SERVICES,
- },
- {
- name: ServiceCategory.DASHBOARD_SERVICES,
- header: PAGE_HEADERS.DASHBOARD_SERVICES,
- },
- {
- name: ServiceCategory.MESSAGING_SERVICES,
- header: PAGE_HEADERS.MESSAGING_SERVICES,
- },
- {
- name: ServiceCategory.METADATA_SERVICES,
- header: PAGE_HEADERS.METADATA_SERVICES,
- },
- {
- name: ServiceCategory.ML_MODEL_SERVICES,
- header: PAGE_HEADERS.ML_MODELS_SERVICES,
- },
- {
- name: ServiceCategory.PIPELINE_SERVICES,
- header: PAGE_HEADERS.PIPELINES_SERVICES,
- },
- {
- name: ServiceCategory.SEARCH_SERVICES,
- header: PAGE_HEADERS.SEARCH_SERVICES,
- },
- {
- name: ServiceCategory.STORAGE_SERVICES,
- header: PAGE_HEADERS.STORAGE_SERVICES,
- },
-];
-
-const mockGetServicesData = [
- {
- id: '518dea5f-28b7-4c3d-a4ef-4b1791e41d47',
- name: 'Glue',
- fullyQualifiedName: 'Glue',
- serviceType: 'Glue',
- connection: {
- config: {
- type: 'Glue',
- awsConfig: {
- awsAccessKeyId: 'aws accessKey id',
- awsSecretAccessKey: '*********',
- awsRegion: 'aws region',
- endPointURL: 'https://glue.region_name.amazonaws.com/',
- assumeRoleSessionName: 'OpenMetadataSession',
- },
- supportsMetadataExtraction: true,
- supportsDBTExtraction: true,
- },
+// Mock the dependencies
+jest.mock('../../../../rest/serviceAPI', () => ({
+ getServices: jest.fn(),
+ searchService: jest.fn(),
+}));
+
+jest.mock('../../../../context/PermissionProvider/PermissionProvider', () => ({
+ usePermissionProvider: () => ({
+ permissions: {
+ DatabaseService: { Create: true, Delete: true, ViewAll: true },
+ PipelineService: { Create: true, Delete: true, ViewAll: true },
+ DashboardService: { Create: true, Delete: true, ViewAll: true },
},
- version: 0.1,
- updatedAt: 1708326433711,
- updatedBy: 'admin',
- href: 'http://localhost:8585/api/v1/services/databaseServices/518dea5f-28b7-4c3d-a4ef-4b1791e41d47',
- deleted: false,
- },
-];
-
-jest.mock('../../../context/PermissionProvider/PermissionProvider', () => ({
- usePermissionProvider: jest.fn().mockReturnValue({
- permissions: jest.fn().mockReturnValue({
- Create: true,
- Delete: true,
- ViewAll: true,
- EditAll: true,
- EditDescription: true,
- EditDisplayName: true,
- EditCustomFields: true,
- }),
}),
}));
-jest.mock('../../../hooks/paging/usePaging', () => ({
- usePaging: jest.fn().mockReturnValue({
+jest.mock('../../../../context/AirflowStatusProvider/AirflowStatusProvider', () => ({
+ useAirflowStatus: () => ({
+ isFetchingStatus: false,
+ }),
+}));
+
+jest.mock('../../../../hooks/paging/usePaging', () => ({
+ usePaging: () => ({
+ paging: { total: 0, offset: 0, limit: 10 },
+ handlePagingChange: jest.fn(),
currentPage: 1,
- paging: {},
- pageSize: 10,
- showPagination: true,
handlePageChange: jest.fn(),
- handlePagingChange: jest.fn(),
+ pageSize: 10,
handlePageSizeChange: jest.fn(),
+ showPagination: false,
}),
}));
-jest.mock(
- '../../../context/AirflowStatusProvider/AirflowStatusProvider',
- () => ({
- useAirflowStatus: jest.fn().mockImplementation(() => ({
- isFetchingStatus: false,
- isAirflowAvailable: true,
- fetchAirflowStatus: jest.fn(),
- platform: PIPELINE_SERVICE_PLATFORM,
- })),
- })
-);
-
-jest.mock('../../../utils/CommonUtils', () => ({
- getServiceLogo: jest.fn().mockReturnValue('Pipeline Service'),
-}));
-
-const mockSearchService = jest.fn();
-jest.mock('../../../rest/serviceAPI', () => ({
- getServices: jest
- .fn()
- .mockImplementation(() => Promise.resolve(mockGetServicesData)),
- searchService: jest.fn().mockImplementation(() => mockSearchService()),
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: () => jest.fn(),
}));
-jest.mock('../../../utils/StringsUtils', () => ({
- ...jest.requireActual('../../../utils/StringsUtils'),
- stringToHTML: jest.fn((text) => text),
-}));
+import { getServices, searchService } from '../../../../rest/serviceAPI';
-jest.mock('../../../utils/EntityUtils', () => {
- const actual = jest.requireActual('../../../utils/EntityUtils');
+const mockGetServices = getServices as jest.MockedFunction;
+const mockSearchService = searchService as jest.MockedFunction;
- return {
- ...actual,
- getEntityName: jest.fn().mockReturnValue('Glue'),
- highlightSearchText: jest.fn((text) => text),
- };
-});
+describe('Services Component - Logo Display', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
-jest.mock('../../../utils/PermissionsUtils', () => ({
- checkPermission: jest.fn().mockReturnValue(true),
-}));
+ const mockServices = [
+ {
+ id: '1',
+ name: 'custom-db-1',
+ displayName: 'Custom Database 1',
+ serviceType: 'CustomDatabase',
+ fullyQualifiedName: 'custom-db-1',
+ description: 'Test custom database',
+ logoUrl: 'https://example.com/custom-db-logo.png',
+ deleted: false,
+ href: '/services/databaseServices/custom-db-1',
+ },
+ {
+ id: '2',
+ name: 'custom-pipeline-1',
+ displayName: 'Custom Pipeline 1',
+ serviceType: 'CustomPipeline',
+ fullyQualifiedName: 'custom-pipeline-1',
+ description: 'Test custom pipeline',
+ logoUrl: 'https://example.com/custom-pipeline-logo.svg',
+ deleted: false,
+ href: '/services/pipelineServices/custom-pipeline-1',
+ },
+ {
+ id: '3',
+ name: 'custom-dashboard-1',
+ displayName: 'Custom Dashboard 1',
+ serviceType: 'CustomDashboard',
+ fullyQualifiedName: 'custom-dashboard-1',
+ description: 'Test custom dashboard',
+ logoUrl: null, // No custom logo
+ deleted: false,
+ href: '/services/dashboardServices/custom-dashboard-1',
+ },
+ {
+ id: '4',
+ name: 'mysql-service',
+ displayName: 'MySQL Service',
+ serviceType: 'Mysql',
+ fullyQualifiedName: 'mysql-service',
+ description: 'Standard MySQL service',
+ logoUrl: undefined, // No custom logo
+ deleted: false,
+ href: '/services/databaseServices/mysql-service',
+ },
+ ];
-jest.mock('../../../utils/ServiceUtils', () => ({
- getOptionalFields: jest.fn(),
- getResourceEntityFromServiceCategory: jest.fn(),
- getServiceTypesFromServiceCategory: jest.fn(),
-}));
+ describe('Service List View', () => {
+ it('should display custom logos in service list', async () => {
+ mockGetServices.mockResolvedValue({
+ data: mockServices,
+ paging: { total: 4, offset: 0, limit: 10 },
+ });
-jest.mock('../../common/ErrorWithPlaceholder/ErrorPlaceHolder', () => {
- return () => ErrorPlaceHolder
;
-});
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
-jest.mock('../../common/OwnerLabel/OwnerLabel.component', () => ({
- OwnerLabel: jest.fn().mockImplementation(() => OwnerLabel
),
-}));
+ await waitFor(() => {
+ expect(screen.getByTestId('services-container')).toBeInTheDocument();
+ });
-jest.mock('../../../utils/TableColumn.util', () => ({
- ownerTableObject: jest.fn().mockReturnValue([
- {
- title: 'label.owner-plural',
- dataIndex: 'owners',
- key: 'owners',
- width: 180,
- render: () => OwnerLabel
,
- },
- ]),
-}));
+ // Check that custom logos are displayed
+ const customDbLogo = screen.getByTestId('service-name-custom-db-1')
+ .closest('div')
+ ?.querySelector('img');
+ expect(customDbLogo).toHaveAttribute('src', 'https://example.com/custom-db-logo.png');
-jest.mock('../../common/ListView/ListView.component', () => ({
- ListView: jest.fn().mockImplementation(({ cardRenderer, tableProps }) => (
-
-
- {cardRenderer({
- ...mockService,
- description: isDescription ? 'test description' : '',
- })}
-
-
- {tableProps.columns.map(
- (column: ColumnsType[0], key: string) =>
- column.render && (
- <>
-
{column.title as string}
-
- {column.render(column.title, column, 1) as ReactNode}
-
- >
- )
- )}
-
-
- )),
-}));
+ const customPipelineLogo = screen.getByTestId('service-name-custom-pipeline-1')
+ .closest('div')
+ ?.querySelector('img');
+ expect(customPipelineLogo).toHaveAttribute('src', 'https://example.com/custom-pipeline-logo.svg');
+ });
-jest.mock('../../common/RichTextEditor/RichTextEditorPreviewerV1', () => {
- return jest
- .fn()
- .mockReturnValue(
- RichTextPreviewer
- );
-});
-
-jest.mock(
- '../../common/Skeleton/CommonSkeletons/ControlElements/ControlElements.component',
- () =>
- jest
- .fn()
- .mockImplementation(() => (
- ButtonSkeleton
- ))
-);
-
-jest.mock('../../Database/ColumnFilter/ColumnFilter.component', () => ({
- __esModule: true,
-
- default: jest.fn().mockImplementation(() => ColumnFilter
),
-}));
+ it('should fallback to default logos when no custom logo provided', async () => {
+ mockGetServices.mockResolvedValue({
+ data: mockServices,
+ paging: { total: 4, offset: 0, limit: 10 },
+ });
-jest.mock('../../PageHeader/PageHeader.component', () =>
- jest
- .fn()
- .mockImplementation(({ data = ServiceCategory.PIPELINE_SERVICES }) => (
-
- PageHeader
-
{data.header}
-
{data.subHeader}
-
- ))
-);
-
-jest.mock('antd', () => ({
- ...jest.requireActual('antd'),
-
- Tooltip: jest
- .fn()
- .mockImplementation(({ children }) => {children}
),
-}));
-const mockNavigate = jest.fn();
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
-jest.mock('react-router-dom', () => ({
- useNavigate: jest.fn().mockImplementation(() => mockNavigate),
- Link: jest
- .fn()
- .mockImplementation(({ children }: { children: React.ReactNode }) => (
- {children}
- )),
-}));
+ await waitFor(() => {
+ expect(screen.getByTestId('services-container')).toBeInTheDocument();
+ });
-jest.mock('../../../hoc/LimitWrapper', () => {
- return jest
- .fn()
- .mockImplementation(({ children }) => <>LimitWrapper{children}>);
-});
+ // Check that default logos are used for services without custom logos
+ const defaultDashboardLogo = screen.getByTestId('service-name-custom-dashboard-1')
+ .closest('div')
+ ?.querySelector('img');
+ expect(defaultDashboardLogo).toHaveAttribute('src', expect.stringContaining('dashboard'));
-describe('Services', () => {
- it('should render Services', async () => {
- await act(async () => {
- render( );
+ const defaultMysqlLogo = screen.getByTestId('service-name-mysql-service')
+ .closest('div')
+ ?.querySelector('img');
+ expect(defaultMysqlLogo).toHaveAttribute('src', expect.stringContaining('mysql'));
});
- expect(await screen.findByTestId('services-container')).toBeInTheDocument();
- expect(await screen.findByTestId('header')).toBeInTheDocument();
- });
+ it('should handle logo load errors gracefully', async () => {
+ mockGetServices.mockResolvedValue({
+ data: mockServices,
+ paging: { total: 4, offset: 0, limit: 10 },
+ });
- services.map((service) => {
- it(`should renders ${service.name} heading and subheading`, async () => {
await act(async () => {
- render( );
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('services-container')).toBeInTheDocument();
});
- expect(await screen.findByText('PageHeader')).toBeInTheDocument();
- expect(
- await screen.findByText(service.header.header)
- ).toBeInTheDocument();
- expect(
- await screen.findByText(service.header.subHeader)
- ).toBeInTheDocument();
+ // Find the custom logo image
+ const customLogo = screen.getByTestId('service-name-custom-db-1')
+ .closest('div')
+ ?.querySelector('img') as HTMLImageElement;
+
+ expect(customLogo).toHaveAttribute('src', 'https://example.com/custom-db-logo.png');
+
+ // Simulate image load error
+ const errorEvent = new Event('error');
+ customLogo.dispatchEvent(errorEvent);
+
+ // Should fallback to default logo
+ await waitFor(() => {
+ expect(customLogo.src).not.toBe('https://example.com/custom-db-logo.png');
+ });
});
});
- it('should render add service skeleton loader while airflow status is not fetching', async () => {
- await act(async () => {
- render( );
- });
+ describe('Service Card View', () => {
+ it('should display custom logos in service cards', async () => {
+ mockGetServices.mockResolvedValue({
+ data: mockServices,
+ paging: { total: 4, offset: 0, limit: 10 },
+ });
- expect(await screen.findByTestId('add-service-button')).toBeInTheDocument();
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
- expect(LimitWrapper).toHaveBeenCalledWith(
- expect.objectContaining({ resource: 'dataAssets' }),
- {}
- );
- });
+ await waitFor(() => {
+ expect(screen.getByTestId('services-container')).toBeInTheDocument();
+ });
- it('should call mock push add service skeleton loader while airflow status is not fetching', async () => {
- await act(async () => {
- render( );
- });
- await act(async () => {
- fireEvent.click(await screen.findByTestId('add-service-button'));
+ // Check service card icons
+ const serviceIcons = screen.getAllByTestId('service-icon');
+ expect(serviceIcons).toHaveLength(4);
+
+ // First service should have custom logo
+ const firstServiceIcon = serviceIcons[0].querySelector('img');
+ expect(firstServiceIcon).toHaveAttribute('src', 'https://example.com/custom-db-logo.png');
});
- expect(mockNavigate).toHaveBeenCalledWith('/pipelineServices/add-service');
- });
+ it('should handle different service categories with custom logos', async () => {
+ const pipelineServices = mockServices.filter(s => s.serviceType === 'CustomPipeline');
+
+ mockGetServices.mockResolvedValue({
+ data: pipelineServices,
+ paging: { total: 1, offset: 0, limit: 10 },
+ });
- it('should render columns', async () => {
- await act(async () => {
- render( );
- });
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
- expect(
- await screen.findByTestId('card-renderer-container')
- ).toBeInTheDocument();
- expect(
- await screen.findByTestId('table-props-container')
- ).toBeInTheDocument();
- expect(await screen.findByText('label.name')).toBeInTheDocument();
- expect(
- await screen.findByTestId('service-name-pipelineServices')
- ).toHaveTextContent('Glue');
-
- expect(await screen.findByText('label.owner-plural')).toBeInTheDocument();
- expect(await screen.findByText('OwnerLabel')).toBeInTheDocument();
- });
+ await waitFor(() => {
+ expect(screen.getByTestId('services-container')).toBeInTheDocument();
+ });
- it('should render service card with description', async () => {
- render( );
-
- expect(await screen.findByTestId('service-card')).toBeInTheDocument();
- expect(
- await screen.findByTestId('service-name-pipelineServices')
- ).toHaveTextContent('Glue');
- expect(await screen.findByTestId('service-description')).toHaveTextContent(
- 'RichTextPreviewer'
- );
- expect(await screen.findByTestId('service-type')).toHaveTextContent(
- 'Airbyte'
- );
+ const serviceIcon = screen.getByTestId('service-icon');
+ const logoImg = serviceIcon.querySelector('img');
+ expect(logoImg).toHaveAttribute('src', 'https://example.com/custom-pipeline-logo.svg');
+ });
});
- it('should render service card without description', async () => {
- isDescription = false;
- render( );
+ describe('Search and Filter', () => {
+ it('should maintain custom logos during search', async () => {
+ mockSearchService.mockResolvedValue({
+ hits: {
+ total: {
+ value: 1,
+ },
+ hits: [
+ {
+ _source: mockServices[0],
+ },
+ ],
+ },
+ });
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('services-container')).toBeInTheDocument();
+ });
+
+ // Perform search
+ const searchInput = screen.getByPlaceholderText(/search/i);
+ await act(async () => {
+ fireEvent.change(searchInput, { target: { value: 'custom' } });
+ });
+
+ await waitFor(() => {
+ expect(mockSearchService).toHaveBeenCalled();
+ });
- expect(await screen.findByTestId('service-card')).toBeInTheDocument();
+ // Custom logo should still be displayed
+ const customLogo = screen.getByTestId('service-name-custom-db-1')
+ .closest('div')
+ ?.querySelector('img');
+ expect(customLogo).toHaveAttribute('src', 'https://example.com/custom-db-logo.png');
+ });
+ });
- expect(await screen.findByTestId('service-description')).toHaveTextContent(
- 'label.no-description'
- );
+ describe('Different Service Types', () => {
+ const serviceTypeTestCases = [
+ { category: ServiceCategory.DATABASE_SERVICES, serviceType: 'CustomDatabase' },
+ { category: ServiceCategory.PIPELINE_SERVICES, serviceType: 'CustomPipeline' },
+ { category: ServiceCategory.DASHBOARD_SERVICES, serviceType: 'CustomDashboard' },
+ { category: ServiceCategory.MESSAGING_SERVICES, serviceType: 'CustomMessaging' },
+ { category: ServiceCategory.MLMODEL_SERVICES, serviceType: 'CustomMlModel' },
+ { category: ServiceCategory.STORAGE_SERVICES, serviceType: 'CustomStorage' },
+ { category: ServiceCategory.SEARCH_SERVICES, serviceType: 'CustomSearch' },
+ { category: ServiceCategory.DRIVE_SERVICES, serviceType: 'CustomDrive' },
+ ];
+
+ serviceTypeTestCases.forEach(({ category, serviceType }) => {
+ it(`should handle custom logos for ${serviceType} services`, async () => {
+ const testService = {
+ id: '1',
+ name: `test-${serviceType.toLowerCase()}`,
+ displayName: `Test ${serviceType}`,
+ serviceType,
+ fullyQualifiedName: `test-${serviceType.toLowerCase()}`,
+ description: `Test ${serviceType} service`,
+ logoUrl: `https://example.com/${serviceType.toLowerCase()}-logo.png`,
+ deleted: false,
+ href: `/services/${category.toLowerCase()}/test-${serviceType.toLowerCase()}`,
+ };
+
+ mockGetServices.mockResolvedValue({
+ data: [testService],
+ paging: { total: 1, offset: 0, limit: 10 },
+ });
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('services-container')).toBeInTheDocument();
+ });
+
+ const serviceIcon = screen.getByTestId('service-icon');
+ const logoImg = serviceIcon.querySelector('img');
+ expect(logoImg).toHaveAttribute('src', `https://example.com/${serviceType.toLowerCase()}-logo.png`);
+ });
+ });
});
- it('should show add service button even if platform is disabled', async () => {
- (useAirflowStatus as jest.Mock).mockImplementationOnce(() => ({
- platform: DISABLED,
- }));
- await act(async () => {
- render( );
+ describe('Error Handling', () => {
+ it('should handle service fetch errors gracefully', async () => {
+ mockGetServices.mockRejectedValue(new Error('Failed to fetch services'));
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('error-placeholder')).toBeInTheDocument();
+ });
});
- const addServiceButton = screen.getByTestId('add-service-button');
+ it('should handle empty service list', async () => {
+ mockGetServices.mockResolvedValue({
+ data: [],
+ paging: { total: 0, offset: 0, limit: 10 },
+ });
+
+ await act(async () => {
+ render(
+
+
+
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByTestId('services-container')).toBeInTheDocument();
+ });
- expect(addServiceButton).toBeInTheDocument();
+ expect(screen.getByText(/no services found/i)).toBeInTheDocument();
+ });
});
-});
+});
\ No newline at end of file
diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.tsx
index 05d58776ef1c..ac69223ca747 100644
--- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/Services/Services.tsx
@@ -342,7 +342,7 @@ const Services = ({ serviceName }: ServicesProps) => {
width: 200,
render: (name, record) => (
- {getServiceLogo(record.serviceType || '', 'w-4')}
+ {getServiceLogo(record.serviceType || '', 'w-4', record)}
{
- {getServiceLogo(service.serviceType || '', 'h-7')}
+ {getServiceLogo(service.serviceType || '', 'h-7', service)}
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createApiService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createApiService.ts
index edefcb1d2d06..50e9982248cc 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createApiService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createApiService.ts
@@ -36,6 +36,10 @@ export interface CreateAPIService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types.
+ */
+ logoUrl?: string;
/**
* Name that identifies the this entity instance uniquely
*/
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createDashboardService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createDashboardService.ts
index b9ca86cf88f0..e05dcd5a0b5e 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createDashboardService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createDashboardService.ts
@@ -35,6 +35,10 @@ export interface CreateDashboardService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types.
+ */
+ logoUrl?: string;
/**
* Name that identifies the this entity instance uniquely
*/
@@ -1107,7 +1111,7 @@ export interface GCPCredentialsConfiguration {
*
* Google Cloud Platform ADC ( Application Default Credentials )
*/
- type?: string;
+ type?: CredentialsType;
/**
* Path of the file containing the GCP credentials info
*/
@@ -1125,7 +1129,7 @@ export interface GCPCredentialsConfiguration {
/**
* Google Cloud Platform account type.
*/
- externalType?: string;
+ externalType?: GCPAccountType;
/**
* Google Security Token Service subject token type based on the OAuth 2.0 token exchange
* spec.
@@ -1138,6 +1142,17 @@ export interface GCPCredentialsConfiguration {
[property: string]: any;
}
+export enum GCPAccountType {
+ ExternalAccount = "external_account",
+}
+
+export enum CredentialsType {
+ ExternalAccount = "external_account",
+ GcpADC = "gcp_adc",
+ GcpCredentialPath = "gcp_credential_path",
+ ServiceAccount = "service_account",
+}
+
/**
* we enable the authenticated service account to impersonate another service account
*
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createDatabaseService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createDatabaseService.ts
index a9dd343ce7a6..648908135161 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createDatabaseService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createDatabaseService.ts
@@ -36,6 +36,10 @@ export interface CreateDatabaseService {
* at runtime based on the Ingestion Agent of the service.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types.
+ */
+ logoUrl?: string;
/**
* Name that identifies the this entity instance uniquely
*/
@@ -1547,7 +1551,7 @@ export interface GCPCredentialsConfiguration {
*
* Google Cloud Platform ADC ( Application Default Credentials )
*/
- type?: string;
+ type?: CredentialsType;
/**
* Path of the file containing the GCP credentials info
*/
@@ -1565,7 +1569,7 @@ export interface GCPCredentialsConfiguration {
/**
* Google Cloud Platform account type.
*/
- externalType?: string;
+ externalType?: GCPAccountType;
/**
* Google Security Token Service subject token type based on the OAuth 2.0 token exchange
* spec.
@@ -1578,6 +1582,17 @@ export interface GCPCredentialsConfiguration {
[property: string]: any;
}
+export enum GCPAccountType {
+ ExternalAccount = "external_account",
+}
+
+export enum CredentialsType {
+ ExternalAccount = "external_account",
+ GcpADC = "gcp_adc",
+ GcpCredentialPath = "gcp_credential_path",
+ ServiceAccount = "service_account",
+}
+
/**
* we enable the authenticated service account to impersonate another service account
*
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createDriveService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createDriveService.ts
index 41e7a2061bef..103798ae326d 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createDriveService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createDriveService.ts
@@ -35,6 +35,10 @@ export interface CreateDriveService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types.
+ */
+ logoUrl?: string;
/**
* Name that identifies this drive service.
*/
@@ -207,7 +211,7 @@ export interface GCPCredentialsConfiguration {
*
* Google Cloud Platform ADC ( Application Default Credentials )
*/
- type?: string;
+ type?: CredentialsType;
/**
* Path of the file containing the GCP credentials info
*/
@@ -225,7 +229,7 @@ export interface GCPCredentialsConfiguration {
/**
* Google Cloud Platform account type.
*/
- externalType?: string;
+ externalType?: GCPAccountType;
/**
* Google Security Token Service subject token type based on the OAuth 2.0 token exchange
* spec.
@@ -238,6 +242,17 @@ export interface GCPCredentialsConfiguration {
[property: string]: any;
}
+export enum GCPAccountType {
+ ExternalAccount = "external_account",
+}
+
+export enum CredentialsType {
+ ExternalAccount = "external_account",
+ GcpADC = "gcp_adc",
+ GcpCredentialPath = "gcp_credential_path",
+ ServiceAccount = "service_account",
+}
+
/**
* we enable the authenticated service account to impersonate another service account
*
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createMessagingService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createMessagingService.ts
index e7a3c7758295..25030d23abbf 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createMessagingService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createMessagingService.ts
@@ -36,6 +36,10 @@ export interface CreateMessagingService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types.
+ */
+ logoUrl?: string;
/**
* Name that identifies the this entity instance uniquely
*/
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createMetadataService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createMetadataService.ts
index 24357f39b9cb..51696cd7f4c0 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createMetadataService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createMetadataService.ts
@@ -31,6 +31,10 @@ export interface CreateMetadataService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types.
+ */
+ logoUrl?: string;
/**
* Name that identifies the this entity instance uniquely
*/
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createMlModelService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createMlModelService.ts
index 8a4c92287098..07c318b8307c 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createMlModelService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createMlModelService.ts
@@ -35,6 +35,10 @@ export interface CreateMlModelService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types.
+ */
+ logoUrl?: string;
/**
* Name that identifies the this entity instance uniquely
*/
@@ -224,7 +228,7 @@ export interface GCPCredentialsConfiguration {
*
* Google Cloud Platform ADC ( Application Default Credentials )
*/
- type?: string;
+ type?: CredentialsType;
/**
* Path of the file containing the GCP credentials info
*/
@@ -242,7 +246,7 @@ export interface GCPCredentialsConfiguration {
/**
* Google Cloud Platform account type.
*/
- externalType?: string;
+ externalType?: GCPAccountType;
/**
* Google Security Token Service subject token type based on the OAuth 2.0 token exchange
* spec.
@@ -255,6 +259,17 @@ export interface GCPCredentialsConfiguration {
[property: string]: any;
}
+export enum GCPAccountType {
+ ExternalAccount = "external_account",
+}
+
+export enum CredentialsType {
+ ExternalAccount = "external_account",
+ GcpADC = "gcp_adc",
+ GcpCredentialPath = "gcp_credential_path",
+ ServiceAccount = "service_account",
+}
+
/**
* we enable the authenticated service account to impersonate another service account
*
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createPipelineService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createPipelineService.ts
index d5c44176087b..214baa177809 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createPipelineService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createPipelineService.ts
@@ -39,6 +39,10 @@ export interface CreatePipelineService {
* Life Cycle of the entity
*/
lifeCycle?: LifeCycle;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types.
+ */
+ logoUrl?: string;
/**
* Name that identifies the this entity instance uniquely
*/
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createSearchService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createSearchService.ts
index 6a30016d7835..a99209b41a69 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createSearchService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createSearchService.ts
@@ -36,6 +36,10 @@ export interface CreateSearchService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types.
+ */
+ logoUrl?: string;
/**
* Name that identifies the this entity instance uniquely
*/
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createSecurityService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createSecurityService.ts
index 4e624d6fbdd4..6c37d3e171df 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createSecurityService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createSecurityService.ts
@@ -36,6 +36,10 @@ export interface CreateSecurityService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types.
+ */
+ logoUrl?: string;
/**
* Name that identifies the this entity instance uniquely
*/
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createStorageService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createStorageService.ts
index 0aff7fb05b34..98a24e5ffbda 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createStorageService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/services/createStorageService.ts
@@ -36,6 +36,10 @@ export interface CreateStorageService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types.
+ */
+ logoUrl?: string;
/**
* Name that identifies the this entity instance uniquely
*/
@@ -261,7 +265,7 @@ export interface GCPCredentialsConfiguration {
*
* Google Cloud Platform ADC ( Application Default Credentials )
*/
- type?: string;
+ type?: CredentialsType;
/**
* Path of the file containing the GCP credentials info
*/
@@ -279,7 +283,7 @@ export interface GCPCredentialsConfiguration {
/**
* Google Cloud Platform account type.
*/
- externalType?: string;
+ externalType?: GCPAccountType;
/**
* Google Security Token Service subject token type based on the OAuth 2.0 token exchange
* spec.
@@ -292,6 +296,17 @@ export interface GCPCredentialsConfiguration {
[property: string]: any;
}
+export enum GCPAccountType {
+ ExternalAccount = "external_account",
+}
+
+export enum CredentialsType {
+ ExternalAccount = "external_account",
+ GcpADC = "gcp_adc",
+ GcpCredentialPath = "gcp_credential_path",
+ ServiceAccount = "service_account",
+}
+
/**
* we enable the authenticated service account to impersonate another service account
*
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/apiService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/apiService.ts
index c1198cd1b0fe..0b17eb2986dc 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/apiService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/apiService.ts
@@ -64,6 +64,11 @@ export interface APIService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types to
+ * provide a visual identifier.
+ */
+ logoUrl?: string;
/**
* Name that identifies this API service.
*/
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/dashboardService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/dashboardService.ts
index 516ae9c175be..c824f8c69745 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/dashboardService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/dashboardService.ts
@@ -63,6 +63,11 @@ export interface DashboardService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types to
+ * provide a visual identifier.
+ */
+ logoUrl?: string;
/**
* Name that identifies this dashboard service.
*/
@@ -1224,7 +1229,7 @@ export interface GCPCredentialsConfiguration {
*
* Google Cloud Platform ADC ( Application Default Credentials )
*/
- type?: string;
+ type?: CredentialsType;
/**
* Path of the file containing the GCP credentials info
*/
@@ -1242,7 +1247,7 @@ export interface GCPCredentialsConfiguration {
/**
* Google Cloud Platform account type.
*/
- externalType?: string;
+ externalType?: GCPAccountType;
/**
* Google Security Token Service subject token type based on the OAuth 2.0 token exchange
* spec.
@@ -1255,6 +1260,17 @@ export interface GCPCredentialsConfiguration {
[property: string]: any;
}
+export enum GCPAccountType {
+ ExternalAccount = "external_account",
+}
+
+export enum CredentialsType {
+ ExternalAccount = "external_account",
+ GcpADC = "gcp_adc",
+ GcpCredentialPath = "gcp_credential_path",
+ ServiceAccount = "service_account",
+}
+
/**
* we enable the authenticated service account to impersonate another service account
*
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/databaseService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/databaseService.ts
index c892939532bb..bed267a862d9 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/databaseService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/databaseService.ts
@@ -65,6 +65,11 @@ export interface DatabaseService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types to
+ * provide a visual identifier.
+ */
+ logoUrl?: string;
/**
* Name that identifies this database service.
*/
@@ -1666,7 +1671,7 @@ export interface GCPCredentialsConfiguration {
*
* Google Cloud Platform ADC ( Application Default Credentials )
*/
- type?: string;
+ type?: CredentialsType;
/**
* Path of the file containing the GCP credentials info
*/
@@ -1684,7 +1689,7 @@ export interface GCPCredentialsConfiguration {
/**
* Google Cloud Platform account type.
*/
- externalType?: string;
+ externalType?: GCPAccountType;
/**
* Google Security Token Service subject token type based on the OAuth 2.0 token exchange
* spec.
@@ -1697,6 +1702,17 @@ export interface GCPCredentialsConfiguration {
[property: string]: any;
}
+export enum GCPAccountType {
+ ExternalAccount = "external_account",
+}
+
+export enum CredentialsType {
+ ExternalAccount = "external_account",
+ GcpADC = "gcp_adc",
+ GcpCredentialPath = "gcp_credential_path",
+ ServiceAccount = "service_account",
+}
+
/**
* we enable the authenticated service account to impersonate another service account
*
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/driveService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/driveService.ts
index 0d1ac4afed16..9478d4f15e76 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/driveService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/driveService.ts
@@ -63,6 +63,11 @@ export interface DriveService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types to
+ * provide a visual identifier.
+ */
+ logoUrl?: string;
/**
* Name that identifies this drive service.
*/
@@ -322,7 +327,7 @@ export interface GCPCredentialsConfiguration {
*
* Google Cloud Platform ADC ( Application Default Credentials )
*/
- type?: string;
+ type?: CredentialsType;
/**
* Path of the file containing the GCP credentials info
*/
@@ -340,7 +345,7 @@ export interface GCPCredentialsConfiguration {
/**
* Google Cloud Platform account type.
*/
- externalType?: string;
+ externalType?: GCPAccountType;
/**
* Google Security Token Service subject token type based on the OAuth 2.0 token exchange
* spec.
@@ -353,6 +358,17 @@ export interface GCPCredentialsConfiguration {
[property: string]: any;
}
+export enum GCPAccountType {
+ ExternalAccount = "external_account",
+}
+
+export enum CredentialsType {
+ ExternalAccount = "external_account",
+ GcpADC = "gcp_adc",
+ GcpCredentialPath = "gcp_credential_path",
+ ServiceAccount = "service_account",
+}
+
/**
* we enable the authenticated service account to impersonate another service account
*
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/ingestionPipelines/ingestionPipeline.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/ingestionPipelines/ingestionPipeline.ts
index ad572a1fc2de..7b2d0876a7cd 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/ingestionPipelines/ingestionPipeline.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/ingestionPipelines/ingestionPipeline.ts
@@ -2511,7 +2511,7 @@ export interface GCPCredentialsConfiguration {
*
* Google Cloud Platform ADC ( Application Default Credentials )
*/
- type?: string;
+ type?: CredentialsType;
/**
* Path of the file containing the GCP credentials info
*/
@@ -2529,7 +2529,7 @@ export interface GCPCredentialsConfiguration {
/**
* Google Cloud Platform account type.
*/
- externalType?: string;
+ externalType?: GCPAccountType;
/**
* Google Security Token Service subject token type based on the OAuth 2.0 token exchange
* spec.
@@ -2542,6 +2542,17 @@ export interface GCPCredentialsConfiguration {
[property: string]: any;
}
+export enum GCPAccountType {
+ ExternalAccount = "external_account",
+}
+
+export enum CredentialsType {
+ ExternalAccount = "external_account",
+ GcpADC = "gcp_adc",
+ GcpCredentialPath = "gcp_credential_path",
+ ServiceAccount = "service_account",
+}
+
/**
* we enable the authenticated service account to impersonate another service account
*
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/messagingService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/messagingService.ts
index 1d72276248ac..99085754afc9 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/messagingService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/messagingService.ts
@@ -64,6 +64,11 @@ export interface MessagingService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types to
+ * provide a visual identifier.
+ */
+ logoUrl?: string;
/**
* Name that identifies this messaging service.
*/
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/metadataService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/metadataService.ts
index 902b9326607d..597c0e19e22f 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/metadataService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/metadataService.ts
@@ -60,6 +60,11 @@ export interface MetadataService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types to
+ * provide a visual identifier.
+ */
+ logoUrl?: string;
/**
* Name that identifies this database service.
*/
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/mlmodelService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/mlmodelService.ts
index 9500a63279e5..4943bbfa67f7 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/mlmodelService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/mlmodelService.ts
@@ -64,6 +64,11 @@ export interface MlmodelService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types to
+ * provide a visual identifier.
+ */
+ logoUrl?: string;
/**
* Name that identifies this pipeline service.
*/
@@ -342,7 +347,7 @@ export interface GCPCredentialsConfiguration {
*
* Google Cloud Platform ADC ( Application Default Credentials )
*/
- type?: string;
+ type?: CredentialsType;
/**
* Path of the file containing the GCP credentials info
*/
@@ -360,7 +365,7 @@ export interface GCPCredentialsConfiguration {
/**
* Google Cloud Platform account type.
*/
- externalType?: string;
+ externalType?: GCPAccountType;
/**
* Google Security Token Service subject token type based on the OAuth 2.0 token exchange
* spec.
@@ -373,6 +378,17 @@ export interface GCPCredentialsConfiguration {
[property: string]: any;
}
+export enum GCPAccountType {
+ ExternalAccount = "external_account",
+}
+
+export enum CredentialsType {
+ ExternalAccount = "external_account",
+ GcpADC = "gcp_adc",
+ GcpCredentialPath = "gcp_credential_path",
+ ServiceAccount = "service_account",
+}
+
/**
* we enable the authenticated service account to impersonate another service account
*
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/pipelineService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/pipelineService.ts
index 57b627426324..d6c8c0d829be 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/pipelineService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/pipelineService.ts
@@ -64,6 +64,11 @@ export interface PipelineService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types to
+ * provide a visual identifier.
+ */
+ logoUrl?: string;
/**
* Name that identifies this pipeline service.
*/
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/searchService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/searchService.ts
index b5dcc71c14ae..db5959b0b0ea 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/searchService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/searchService.ts
@@ -63,6 +63,11 @@ export interface SearchService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types to
+ * provide a visual identifier.
+ */
+ logoUrl?: string;
/**
* Name that identifies this search service.
*/
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/securityService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/securityService.ts
index 624c9fc5f247..e21fe47216c8 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/securityService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/securityService.ts
@@ -64,6 +64,11 @@ export interface SecurityService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types to
+ * provide a visual identifier.
+ */
+ logoUrl?: string;
/**
* Name that identifies this security service.
*/
diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/storageService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/storageService.ts
index 49c3837111db..e908fa2b5d27 100644
--- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/storageService.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/storageService.ts
@@ -63,6 +63,11 @@ export interface StorageService {
* The ingestion agent responsible for executing the ingestion pipeline.
*/
ingestionRunner?: EntityReference;
+ /**
+ * URL to the logo image for this service. Primarily used for custom service types to
+ * provide a visual identifier.
+ */
+ logoUrl?: string;
/**
* Name that identifies this storage service.
*/
@@ -378,7 +383,7 @@ export interface GCPCredentialsConfiguration {
*
* Google Cloud Platform ADC ( Application Default Credentials )
*/
- type?: string;
+ type?: CredentialsType;
/**
* Path of the file containing the GCP credentials info
*/
@@ -396,7 +401,7 @@ export interface GCPCredentialsConfiguration {
/**
* Google Cloud Platform account type.
*/
- externalType?: string;
+ externalType?: GCPAccountType;
/**
* Google Security Token Service subject token type based on the OAuth 2.0 token exchange
* spec.
@@ -409,6 +414,17 @@ export interface GCPCredentialsConfiguration {
[property: string]: any;
}
+export enum GCPAccountType {
+ ExternalAccount = "external_account",
+}
+
+export enum CredentialsType {
+ ExternalAccount = "external_account",
+ GcpADC = "gcp_adc",
+ GcpCredentialPath = "gcp_credential_path",
+ ServiceAccount = "service_account",
+}
+
/**
* we enable the authenticated service account to impersonate another service account
*
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddServicePage/AddServicePage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/AddServicePage/AddServicePage.component.tsx
index e5054dabc242..6eba9c8e67b3 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/AddServicePage/AddServicePage.component.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddServicePage/AddServicePage.component.tsx
@@ -239,7 +239,7 @@ const AddServicePage = () => {
{serviceConfig.serviceType ? (
- {getServiceLogo(serviceConfig.serviceType || '', 'h-6')}{' '}
+ {getServiceLogo(serviceConfig.serviceType || '', 'h-6', serviceConfig)}{' '}
{`${serviceConfig.serviceType} ${t('label.service')}`}
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/AddServicePage/AddServicePage.interface.ts b/openmetadata-ui/src/main/resources/ui/src/pages/AddServicePage/AddServicePage.interface.ts
index e69b42f56b17..b67c2ff976a0 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/AddServicePage/AddServicePage.interface.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/AddServicePage/AddServicePage.interface.ts
@@ -16,6 +16,7 @@ export interface ServiceConfig {
name: string;
description: string;
serviceType: string;
+ logoUrl?: string;
connection: {
config: ConfigData;
};
diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/EditConnectionFormPage/EditConnectionFormPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/EditConnectionFormPage/EditConnectionFormPage.component.tsx
index 3e5575a6d449..54ed654e92d4 100644
--- a/openmetadata-ui/src/main/resources/ui/src/pages/EditConnectionFormPage/EditConnectionFormPage.component.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/pages/EditConnectionFormPage/EditConnectionFormPage.component.tsx
@@ -217,7 +217,7 @@ function EditConnectionFormPage() {
- {getServiceLogo(serviceDetails?.serviceType ?? '', 'h-6')}{' '}
+ {getServiceLogo(serviceDetails?.serviceType ?? '', 'h-6', serviceDetails)}{' '}
{t('message.edit-service-entity-connection', {
entity: serviceFQN,
diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.test.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.test.tsx
new file mode 100644
index 000000000000..88ad6f45b6bb
--- /dev/null
+++ b/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.test.tsx
@@ -0,0 +1,353 @@
+/*
+ * Copyright 2025 Collate
+ * Licensed 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.
+ */
+
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { getServiceLogo } from './CommonUtils';
+
+// Mock the ServiceUtilClassBase
+jest.mock('./ServiceUtilClassBase', () => ({
+ getServiceTypeLogo: jest.fn(),
+}));
+
+import serviceUtilClassBase from './ServiceUtilClassBase';
+
+const mockGetServiceTypeLogo = serviceUtilClassBase.getServiceTypeLogo as jest.MockedFunction<
+ typeof serviceUtilClassBase.getServiceTypeLogo
+>;
+
+describe('CommonUtils - getServiceLogo', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetServiceTypeLogo.mockReturnValue('default-logo.png');
+ });
+
+ describe('Custom logo URL handling', () => {
+ it('should render custom logo when logoUrl is provided', () => {
+ const serviceEntity = {
+ logoUrl: 'https://example.com/custom-logo.png',
+ serviceType: 'CustomDatabase',
+ };
+
+ const { container } = render(
+ getServiceLogo('CustomDatabase', 'test-class', serviceEntity)
+ );
+
+ const img = container.querySelector('img');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('src', 'https://example.com/custom-logo.png');
+ expect(img).toHaveClass('test-class');
+ });
+
+ it('should render custom logo with default className when not provided', () => {
+ const serviceEntity = {
+ logoUrl: 'https://example.com/custom-logo.png',
+ serviceType: 'CustomDatabase',
+ };
+
+ const { container } = render(
+ getServiceLogo('CustomDatabase', '', serviceEntity)
+ );
+
+ const img = container.querySelector('img');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('src', 'https://example.com/custom-logo.png');
+ expect(img).toHaveClass('');
+ });
+
+ it('should render custom logo with SVG format', () => {
+ const serviceEntity = {
+ logoUrl: 'https://example.com/custom-logo.svg',
+ serviceType: 'CustomPipeline',
+ };
+
+ const { container } = render(
+ getServiceLogo('CustomPipeline', 'w-4 h-4', serviceEntity)
+ );
+
+ const img = container.querySelector('img');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('src', 'https://example.com/custom-logo.svg');
+ expect(img).toHaveClass('w-4 h-4');
+ });
+ });
+
+ describe('Fallback to default logo', () => {
+ it('should use default logo when no custom logoUrl provided', () => {
+ const serviceEntity = {
+ serviceType: 'CustomDatabase',
+ };
+
+ const { container } = render(
+ getServiceLogo('CustomDatabase', 'test-class', serviceEntity)
+ );
+
+ const img = container.querySelector('img');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('src', 'default-logo.png');
+ expect(mockGetServiceTypeLogo).toHaveBeenCalledWith({
+ serviceType: 'CustomDatabase',
+ });
+ });
+
+ it('should use default logo when serviceEntity is not provided', () => {
+ const { container } = render(
+ getServiceLogo('CustomDatabase', 'test-class')
+ );
+
+ const img = container.querySelector('img');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('src', 'default-logo.png');
+ expect(mockGetServiceTypeLogo).toHaveBeenCalledWith({
+ serviceType: 'CustomDatabase',
+ });
+ });
+
+ it('should use default logo when serviceEntity is empty', () => {
+ const serviceEntity = {};
+
+ const { container } = render(
+ getServiceLogo('CustomDatabase', 'test-class', serviceEntity)
+ );
+
+ const img = container.querySelector('img');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('src', 'default-logo.png');
+ });
+ });
+
+ describe('Error handling and fallback', () => {
+ it('should fallback to default logo when custom logo fails to load', async () => {
+ const serviceEntity = {
+ logoUrl: 'https://example.com/broken-logo.png',
+ serviceType: 'CustomDatabase',
+ };
+
+ const { container } = render(
+ getServiceLogo('CustomDatabase', 'test-class', serviceEntity)
+ );
+
+ const img = container.querySelector('img') as HTMLImageElement;
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('src', 'https://example.com/broken-logo.png');
+
+ // Simulate image load error
+ fireEvent.error(img);
+
+ await waitFor(() => {
+ expect(img).toHaveAttribute('src', 'default-logo.png');
+ });
+
+ expect(mockGetServiceTypeLogo).toHaveBeenCalledWith({
+ serviceType: 'CustomDatabase',
+ });
+ });
+
+ it('should handle multiple error events gracefully', async () => {
+ const serviceEntity = {
+ logoUrl: 'https://example.com/broken-logo.png',
+ serviceType: 'CustomDatabase',
+ };
+
+ const { container } = render(
+ getServiceLogo('CustomDatabase', 'test-class', serviceEntity)
+ );
+
+ const img = container.querySelector('img') as HTMLImageElement;
+
+ // Simulate multiple error events
+ fireEvent.error(img);
+ fireEvent.error(img);
+ fireEvent.error(img);
+
+ await waitFor(() => {
+ expect(img).toHaveAttribute('src', 'default-logo.png');
+ });
+
+ // Should only call getServiceTypeLogo once per error event
+ expect(mockGetServiceTypeLogo).toHaveBeenCalledTimes(3);
+ });
+
+ it('should not fallback if custom logo loads successfully', async () => {
+ const serviceEntity = {
+ logoUrl: 'https://example.com/working-logo.png',
+ serviceType: 'CustomDatabase',
+ };
+
+ const { container } = render(
+ getServiceLogo('CustomDatabase', 'test-class', serviceEntity)
+ );
+
+ const img = container.querySelector('img') as HTMLImageElement;
+ expect(img).toHaveAttribute('src', 'https://example.com/working-logo.png');
+
+ // Simulate successful image load
+ fireEvent.load(img);
+
+ await waitFor(() => {
+ expect(img).toHaveAttribute('src', 'https://example.com/working-logo.png');
+ });
+
+ // Should not call getServiceTypeLogo for fallback
+ expect(mockGetServiceTypeLogo).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Edge cases', () => {
+ it('should handle null logoUrl', () => {
+ const serviceEntity = {
+ logoUrl: null,
+ serviceType: 'CustomDatabase',
+ };
+
+ const { container } = render(
+ getServiceLogo('CustomDatabase', 'test-class', serviceEntity)
+ );
+
+ const img = container.querySelector('img');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('src', 'default-logo.png');
+ });
+
+ it('should handle undefined logoUrl', () => {
+ const serviceEntity = {
+ logoUrl: undefined,
+ serviceType: 'CustomDatabase',
+ };
+
+ const { container } = render(
+ getServiceLogo('CustomDatabase', 'test-class', serviceEntity)
+ );
+
+ const img = container.querySelector('img');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('src', 'default-logo.png');
+ });
+
+ it('should handle empty string logoUrl', () => {
+ const serviceEntity = {
+ logoUrl: '',
+ serviceType: 'CustomDatabase',
+ };
+
+ const { container } = render(
+ getServiceLogo('CustomDatabase', 'test-class', serviceEntity)
+ );
+
+ const img = container.querySelector('img');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('src', 'default-logo.png');
+ });
+
+ it('should handle whitespace-only logoUrl', () => {
+ const serviceEntity = {
+ logoUrl: ' ',
+ serviceType: 'CustomDatabase',
+ };
+
+ const { container } = render(
+ getServiceLogo('CustomDatabase', 'test-class', serviceEntity)
+ );
+
+ const img = container.querySelector('img');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('src', 'default-logo.png');
+ });
+
+ it('should return null when no logo is available', () => {
+ mockGetServiceTypeLogo.mockReturnValue(null);
+
+ const result = getServiceLogo('UnknownService', 'test-class');
+
+ expect(result).toBeNull();
+ });
+
+ it('should return null when getServiceTypeLogo returns empty string', () => {
+ mockGetServiceTypeLogo.mockReturnValue('');
+
+ const result = getServiceLogo('UnknownService', 'test-class');
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('Accessibility', () => {
+ it('should have proper alt attribute', () => {
+ const serviceEntity = {
+ logoUrl: 'https://example.com/custom-logo.png',
+ serviceType: 'CustomDatabase',
+ };
+
+ const { container } = render(
+ getServiceLogo('CustomDatabase', 'test-class', serviceEntity)
+ );
+
+ const img = container.querySelector('img');
+ expect(img).toHaveAttribute('alt', '');
+ });
+
+ it('should have proper alt attribute for default logo', () => {
+ const { container } = render(
+ getServiceLogo('CustomDatabase', 'test-class')
+ );
+
+ const img = container.querySelector('img');
+ expect(img).toHaveAttribute('alt', '');
+ });
+ });
+
+ describe('Different service types', () => {
+ const serviceTypeTestCases = [
+ 'CustomDatabase',
+ 'CustomPipeline',
+ 'CustomDashboard',
+ 'CustomMessaging',
+ 'CustomMlModel',
+ 'CustomStorage',
+ 'CustomSearch',
+ 'CustomDrive',
+ 'CustomApi',
+ 'CustomSecurity',
+ 'CustomMetadata',
+ ];
+
+ serviceTypeTestCases.forEach((serviceType) => {
+ it(`should handle ${serviceType} with custom logo`, () => {
+ const serviceEntity = {
+ logoUrl: `https://example.com/${serviceType.toLowerCase()}-logo.png`,
+ serviceType,
+ };
+
+ const { container } = render(
+ getServiceLogo(serviceType, 'test-class', serviceEntity)
+ );
+
+ const img = container.querySelector('img');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('src', `https://example.com/${serviceType.toLowerCase()}-logo.png`);
+ });
+
+ it(`should handle ${serviceType} without custom logo`, () => {
+ const serviceEntity = { serviceType };
+
+ const { container } = render(
+ getServiceLogo(serviceType, 'test-class', serviceEntity)
+ );
+
+ const img = container.querySelector('img');
+ expect(img).toBeInTheDocument();
+ expect(img).toHaveAttribute('src', 'default-logo.png');
+ expect(mockGetServiceTypeLogo).toHaveBeenCalledWith({ serviceType });
+ });
+ });
+ });
+});
diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx
index 13ff9b8ef00f..5fbce79d85d7 100644
--- a/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/utils/CommonUtils.tsx
@@ -358,14 +358,30 @@ export const getImages = (imageUri: string) => {
export const getServiceLogo = (
serviceType: string,
- className = ''
+ className = '',
+ serviceEntity?: { logoUrl?: string; serviceType?: string }
): JSX.Element | null => {
- const logo = serviceUtilClassBase.getServiceTypeLogo({
- serviceType,
- } as SearchSourceAlias);
+ // Check for custom logoUrl first
+ const logo = serviceEntity?.logoUrl ||
+ serviceUtilClassBase.getServiceTypeLogo({
+ serviceType,
+ } as SearchSourceAlias);
if (!isNull(logo)) {
- return ;
+ return (
+ {
+ // Fallback to default icon if custom logo fails to load
+ const target = e.target as HTMLImageElement;
+ target.src = serviceUtilClassBase.getServiceTypeLogo({
+ serviceType,
+ } as SearchSourceAlias);
+ }}
+ />
+ );
}
return null;
diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/EntityLineageUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/EntityLineageUtils.tsx
index fdd83d99880c..3a190ab109a5 100644
--- a/openmetadata-ui/src/main/resources/ui/src/utils/EntityLineageUtils.tsx
+++ b/openmetadata-ui/src/main/resources/ui/src/utils/EntityLineageUtils.tsx
@@ -1911,7 +1911,7 @@ const buildLineageTableColumns = (headers: string[]): ColumnsType => {
)}
diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtilClassBase.test.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtilClassBase.test.ts
new file mode 100644
index 000000000000..c100ef5e402a
--- /dev/null
+++ b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtilClassBase.test.ts
@@ -0,0 +1,317 @@
+/*
+ * Copyright 2025 Collate
+ * Licensed 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.
+ */
+
+import {
+ DATABASE_DEFAULT,
+ DASHBOARD_DEFAULT,
+ PIPELINE_DEFAULT,
+ TOPIC_DEFAULT,
+ ML_MODEL_DEFAULT,
+ CUSTOM_STORAGE_DEFAULT,
+ CUSTOM_SEARCH_DEFAULT,
+ CUSTOM_DRIVE_DEFAULT,
+ DEFAULT_SERVICE,
+} from '../../constants/Services.constant';
+import serviceUtilClassBase from './ServiceUtilClassBase';
+
+describe('ServiceUtilClassBase', () => {
+ describe('getServiceLogo', () => {
+ const validLogoUrl = 'https://example.com/logo.png';
+ const validSvgLogoUrl = 'https://example.com/logo.svg';
+
+ describe('Custom logo URL priority', () => {
+ it('should return custom logoUrl when provided', () => {
+ const serviceEntity = {
+ logoUrl: validLogoUrl,
+ serviceType: 'CustomDatabase',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomDatabase', serviceEntity);
+
+ expect(result).toBe(validLogoUrl);
+ });
+
+ it('should return custom logoUrl for SVG format', () => {
+ const serviceEntity = {
+ logoUrl: validSvgLogoUrl,
+ serviceType: 'CustomPipeline',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomPipeline', serviceEntity);
+
+ expect(result).toBe(validSvgLogoUrl);
+ });
+
+ it('should prioritize custom logoUrl over hardcoded mappings', () => {
+ const serviceEntity = {
+ logoUrl: validLogoUrl,
+ serviceType: 'CustomDatabase',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomDatabase', serviceEntity);
+
+ expect(result).toBe(validLogoUrl);
+ expect(result).not.toBe(DATABASE_DEFAULT);
+ });
+ });
+
+ describe('Hardcoded service type mappings', () => {
+ it('should return hardcoded logo for CustomDatabase when no custom logoUrl', () => {
+ const serviceEntity = {
+ serviceType: 'CustomDatabase',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomDatabase', serviceEntity);
+
+ expect(result).toBe(DATABASE_DEFAULT);
+ });
+
+ it('should return hardcoded logo for CustomPipeline when no custom logoUrl', () => {
+ const serviceEntity = {
+ serviceType: 'CustomPipeline',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomPipeline', serviceEntity);
+
+ expect(result).toBe(PIPELINE_DEFAULT);
+ });
+
+ it('should return hardcoded logo for CustomDashboard when no custom logoUrl', () => {
+ const serviceEntity = {
+ serviceType: 'CustomDashboard',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomDashboard', serviceEntity);
+
+ expect(result).toBe(DASHBOARD_DEFAULT);
+ });
+ });
+
+ describe('Generic service type fallback', () => {
+ it('should return messaging service default for unknown messaging service', () => {
+ const serviceEntity = {
+ serviceType: 'CustomKafka',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomKafka', serviceEntity);
+
+ expect(result).toBe(TOPIC_DEFAULT);
+ });
+
+ it('should return database service default for unknown database service', () => {
+ const serviceEntity = {
+ serviceType: 'CustomPostgres',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomPostgres', serviceEntity);
+
+ expect(result).toBe(DATABASE_DEFAULT);
+ });
+
+ it('should return pipeline service default for unknown pipeline service', () => {
+ const serviceEntity = {
+ serviceType: 'CustomAirflow',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomAirflow', serviceEntity);
+
+ expect(result).toBe(PIPELINE_DEFAULT);
+ });
+
+ it('should return dashboard service default for unknown dashboard service', () => {
+ const serviceEntity = {
+ serviceType: 'CustomGrafana',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomGrafana', serviceEntity);
+
+ expect(result).toBe(DASHBOARD_DEFAULT);
+ });
+
+ it('should return ML model service default for unknown ML model service', () => {
+ const serviceEntity = {
+ serviceType: 'CustomMLflow',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomMLflow', serviceEntity);
+
+ expect(result).toBe(ML_MODEL_DEFAULT);
+ });
+
+ it('should return storage service default for unknown storage service', () => {
+ const serviceEntity = {
+ serviceType: 'CustomS3',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomS3', serviceEntity);
+
+ expect(result).toBe(CUSTOM_STORAGE_DEFAULT);
+ });
+
+ it('should return search service default for unknown search service', () => {
+ const serviceEntity = {
+ serviceType: 'CustomElasticsearch',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomElasticsearch', serviceEntity);
+
+ expect(result).toBe(CUSTOM_SEARCH_DEFAULT);
+ });
+
+ it('should return drive service default for unknown drive service', () => {
+ const serviceEntity = {
+ serviceType: 'CustomDropbox',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomDropbox', serviceEntity);
+
+ expect(result).toBe(CUSTOM_DRIVE_DEFAULT);
+ });
+
+ it('should return default service for completely unknown service type', () => {
+ const serviceEntity = {
+ serviceType: 'UnknownService',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('UnknownService', serviceEntity);
+
+ expect(result).toBe(DEFAULT_SERVICE);
+ });
+ });
+
+ describe('Backward compatibility', () => {
+ it('should work without serviceEntity parameter (legacy behavior)', () => {
+ const result = serviceUtilClassBase.getServiceLogo('CustomDatabase');
+
+ expect(result).toBe(DATABASE_DEFAULT);
+ });
+
+ it('should work with empty serviceEntity', () => {
+ const serviceEntity = {};
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomDatabase', serviceEntity);
+
+ expect(result).toBe(DATABASE_DEFAULT);
+ });
+
+ it('should work with serviceEntity without logoUrl', () => {
+ const serviceEntity = {
+ serviceType: 'CustomDatabase',
+ name: 'test-service',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomDatabase', serviceEntity);
+
+ expect(result).toBe(DATABASE_DEFAULT);
+ });
+ });
+
+ describe('Edge cases', () => {
+ it('should handle null logoUrl', () => {
+ const serviceEntity = {
+ logoUrl: null,
+ serviceType: 'CustomDatabase',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomDatabase', serviceEntity);
+
+ expect(result).toBe(DATABASE_DEFAULT);
+ });
+
+ it('should handle undefined logoUrl', () => {
+ const serviceEntity = {
+ logoUrl: undefined,
+ serviceType: 'CustomDatabase',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomDatabase', serviceEntity);
+
+ expect(result).toBe(DATABASE_DEFAULT);
+ });
+
+ it('should handle empty string logoUrl', () => {
+ const serviceEntity = {
+ logoUrl: '',
+ serviceType: 'CustomDatabase',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomDatabase', serviceEntity);
+
+ expect(result).toBe(DATABASE_DEFAULT);
+ });
+
+ it('should handle whitespace-only logoUrl', () => {
+ const serviceEntity = {
+ logoUrl: ' ',
+ serviceType: 'CustomDatabase',
+ };
+
+ const result = serviceUtilClassBase.getServiceLogo('CustomDatabase', serviceEntity);
+
+ expect(result).toBe(DATABASE_DEFAULT);
+ });
+ });
+
+ describe('All service types coverage', () => {
+ const serviceTypeTestCases = [
+ // Database services
+ { type: 'CustomDatabase', expected: DATABASE_DEFAULT },
+ { type: 'CustomMysql', expected: DATABASE_DEFAULT },
+ { type: 'CustomPostgres', expected: DATABASE_DEFAULT },
+
+ // Pipeline services
+ { type: 'CustomPipeline', expected: PIPELINE_DEFAULT },
+ { type: 'CustomAirflow', expected: PIPELINE_DEFAULT },
+ { type: 'CustomPrefect', expected: PIPELINE_DEFAULT },
+
+ // Dashboard services
+ { type: 'CustomDashboard', expected: DASHBOARD_DEFAULT },
+ { type: 'CustomLooker', expected: DASHBOARD_DEFAULT },
+ { type: 'CustomGrafana', expected: DASHBOARD_DEFAULT },
+
+ // Messaging services
+ { type: 'CustomMessaging', expected: TOPIC_DEFAULT },
+ { type: 'CustomKafka', expected: TOPIC_DEFAULT },
+ { type: 'CustomPulsar', expected: TOPIC_DEFAULT },
+
+ // ML Model services
+ { type: 'CustomMlModel', expected: ML_MODEL_DEFAULT },
+ { type: 'CustomMLflow', expected: ML_MODEL_DEFAULT },
+ { type: 'CustomSageMaker', expected: ML_MODEL_DEFAULT },
+
+ // Storage services
+ { type: 'CustomStorage', expected: CUSTOM_STORAGE_DEFAULT },
+ { type: 'CustomS3', expected: CUSTOM_STORAGE_DEFAULT },
+ { type: 'CustomGCS', expected: CUSTOM_STORAGE_DEFAULT },
+
+ // Search services
+ { type: 'CustomSearch', expected: CUSTOM_SEARCH_DEFAULT },
+ { type: 'CustomElasticsearch', expected: CUSTOM_SEARCH_DEFAULT },
+ { type: 'CustomOpenSearch', expected: CUSTOM_SEARCH_DEFAULT },
+
+ // Drive services
+ { type: 'CustomDrive', expected: CUSTOM_DRIVE_DEFAULT },
+ { type: 'CustomGoogleDrive', expected: CUSTOM_DRIVE_DEFAULT },
+ { type: 'CustomDropbox', expected: CUSTOM_DRIVE_DEFAULT },
+ ];
+
+ serviceTypeTestCases.forEach(({ type, expected }) => {
+ it(`should return correct default for ${type}`, () => {
+ const serviceEntity = { serviceType: type };
+ const result = serviceUtilClassBase.getServiceLogo(type, serviceEntity);
+ expect(result).toBe(expected);
+ });
+ });
+ });
+ });
+});
diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtilClassBase.ts b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtilClassBase.ts
index 72157774a4a0..ff4c9b3e9bb2 100644
--- a/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtilClassBase.ts
+++ b/openmetadata-ui/src/main/resources/ui/src/utils/ServiceUtilClassBase.ts
@@ -395,7 +395,16 @@ class ServiceUtilClassBase {
return EntityType.TABLE;
}
- public getServiceLogo(type: string): string {
+ public getServiceLogo(
+ type: string,
+ serviceEntity?: { logoUrl?: string; serviceType?: string }
+ ): string {
+ // Priority 1: If service entity has a custom logoUrl, use it
+ if (serviceEntity?.logoUrl) {
+ return serviceEntity.logoUrl;
+ }
+
+ // Priority 2: Use hardcoded logo mappings for known services
const serviceTypes = this.getSupportedServiceFromList();
switch (toLower(type)) {
case this.DatabaseServiceTypeSmallCase.CustomDatabase:
@@ -689,6 +698,7 @@ class ServiceUtilClassBase {
return TIMESCALE;
default: {
+ // Priority 3: Fallback to generic service type icon
let logo;
if (serviceTypes.messagingServices.includes(type)) {
logo = TOPIC_DEFAULT;