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