diff --git a/bootstrap/sql/migrations/native/1.12.0/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/1.12.0/mysql/schemaChanges.sql index 17d2dd0e7d38..db5f93e44c97 100644 --- a/bootstrap/sql/migrations/native/1.12.0/mysql/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/1.12.0/mysql/schemaChanges.sql @@ -51,6 +51,20 @@ UPDATE test_definition SET json = JSON_SET(json, '$.enabled', true) WHERE json_extract(json, '$.enabled') IS NULL; + +-- Create Learning Resource Entity Table +CREATE TABLE IF NOT EXISTS learning_resource_entity ( + id varchar(36) GENERATED ALWAYS AS (json_unquote(json_extract(`json`,'$.id'))) STORED NOT NULL, + name varchar(3072) GENERATED ALWAYS AS (json_unquote(json_extract(`json`,'$.fullyQualifiedName'))) VIRTUAL, + fqnHash varchar(256) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + json json NOT NULL, + updatedAt bigint UNSIGNED GENERATED ALWAYS AS (json_unquote(json_extract(`json`,'$.updatedAt'))) VIRTUAL NOT NULL, + updatedBy varchar(256) GENERATED ALWAYS AS (json_unquote(json_extract(`json`,'$.updatedBy'))) VIRTUAL NOT NULL, + deleted TINYINT(1) GENERATED ALWAYS AS (IF(json_extract(json,'$.deleted') = TRUE, 1, 0)) VIRTUAL, + PRIMARY KEY (id), + UNIQUE KEY fqnHash (fqnHash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + -- Add updatedAt generated column to entity_extension table for efficient timestamp-based queries -- This supports the listEntityHistoryByTimestamp API endpoint for retrieving entity versions within a time range ALTER TABLE entity_extension @@ -97,3 +111,4 @@ CREATE INDEX idx_test_suite_updated_at_id ON test_suite(updatedAt DESC, id DESC) CREATE INDEX idx_test_case_updated_at_id ON test_case(updatedAt DESC, id DESC); CREATE INDEX idx_api_collection_entity_updated_at_id ON api_collection_entity(updatedAt DESC, id DESC); CREATE INDEX idx_api_endpoint_entity_updated_at_id ON api_endpoint_entity(updatedAt DESC, id DESC); +>>>>>>> upstream/main diff --git a/bootstrap/sql/migrations/native/1.12.0/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/1.12.0/postgres/schemaChanges.sql index bdec1513b29d..ab7fa34a15c5 100644 --- a/bootstrap/sql/migrations/native/1.12.0/postgres/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/1.12.0/postgres/schemaChanges.sql @@ -67,6 +67,21 @@ ON audit_log_event (service_name, event_ts DESC); CREATE INDEX IF NOT EXISTS idx_audit_log_created_at ON audit_log_event (created_at); + + +-- Create Learning Resource Entity Table +CREATE TABLE IF NOT EXISTS learning_resource_entity ( + id character varying(36) GENERATED ALWAYS AS ((json ->> 'id'::text)) STORED NOT NULL, + name character varying(3072) GENERATED ALWAYS AS ((json ->> 'fullyQualifiedName'::text)) STORED, + fqnhash character varying(256) NOT NULL, + json jsonb NOT NULL, + updatedat bigint GENERATED ALWAYS AS (((json ->> 'updatedAt'::text))::bigint) STORED NOT NULL, + updatedby character varying(256) GENERATED ALWAYS AS ((json ->> 'updatedBy'::text)) STORED NOT NULL, + deleted BOOLEAN GENERATED ALWAYS AS ((json ->> 'deleted')::boolean) STORED, + PRIMARY KEY (id), + UNIQUE (fqnhash) +); + -- Add updatedAt generated column to entity_extension table for efficient timestamp-based queries -- This supports the listEntityHistoryByTimestamp API endpoint for retrieving entity versions within a time range ALTER TABLE entity_extension @@ -113,3 +128,4 @@ CREATE INDEX IF NOT EXISTS idx_test_suite_updated_at_id ON test_suite(updatedAt CREATE INDEX IF NOT EXISTS idx_test_case_updated_at_id ON test_case(updatedAt DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_api_collection_entity_updated_at_id ON api_collection_entity(updatedAt DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_api_endpoint_entity_updated_at_id ON api_endpoint_entity(updatedAt DESC, id DESC); +>>>>>>> upstream/main diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/LearningResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/LearningResourceIT.java new file mode 100644 index 000000000000..9191a0dfe0b9 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/LearningResourceIT.java @@ -0,0 +1,679 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.URI; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.schema.api.learning.CreateLearningResource; +import org.openmetadata.schema.api.learning.ResourceCategory; +import org.openmetadata.schema.entity.learning.LearningResource; +import org.openmetadata.schema.entity.learning.LearningResourceContext; +import org.openmetadata.schema.entity.learning.LearningResourceSource; +import org.openmetadata.schema.type.EntityHistory; +import org.openmetadata.sdk.models.ListParams; +import org.openmetadata.sdk.models.ListResponse; +import org.openmetadata.sdk.services.learning.LearningResourceService; + +/** + * Integration tests for LearningResource entity operations. + * + *

Tests LearningResource CRUD operations, category validation, context handling, and + * learning-resource-specific validations. + * + *

Migrated from: org.openmetadata.service.resources.learning.LearningResourceResourceTest + */ +@Execution(ExecutionMode.CONCURRENT) +public class LearningResourceIT extends BaseEntityIT { + + public LearningResourceIT() { + supportsPatch = true; + supportsFollowers = false; + supportsTags = true; + supportsDataProducts = false; + supportsCustomExtension = true; + supportsSearchIndex = false; + supportsDomains = false; + } + + // =================================================================== + // ABSTRACT METHOD IMPLEMENTATIONS (Required by BaseEntityIT) + // =================================================================== + + @Override + protected CreateLearningResource createMinimalRequest(TestNamespace ns) { + return new CreateLearningResource() + .withName(ns.prefix("learning-resource")) + .withDescription("Test learning resource") + .withResourceType(CreateLearningResource.ResourceType.VIDEO) + .withCategories(List.of(ResourceCategory.DISCOVERY)) + .withSource( + new LearningResourceSource() + .withProvider("YouTube") + .withUrl(URI.create("https://youtube.com/watch?v=test"))) + .withContexts(List.of(new LearningResourceContext().withPageId("explore"))); + } + + @Override + protected CreateLearningResource createRequest(String name, TestNamespace ns) { + return new CreateLearningResource() + .withName(name) + .withDescription("Test learning resource") + .withResourceType(CreateLearningResource.ResourceType.ARTICLE) + .withCategories(List.of(ResourceCategory.DATA_GOVERNANCE)) + .withSource(new LearningResourceSource().withUrl(URI.create("https://example.com/article"))) + .withContexts(List.of(new LearningResourceContext().withPageId("glossary"))); + } + + @Override + protected LearningResource createEntity(CreateLearningResource createRequest) { + return getLearningResourceService().create(createRequest); + } + + @Override + protected LearningResource getEntity(String id) { + return getLearningResourceService().get(id); + } + + @Override + protected LearningResource getEntityByName(String fqn) { + return getLearningResourceService().getByName(fqn); + } + + @Override + protected LearningResource patchEntity(String id, LearningResource entity) { + return getLearningResourceService().update(id, entity); + } + + @Override + protected void deleteEntity(String id) { + getLearningResourceService().delete(id); + } + + @Override + protected void restoreEntity(String id) { + getLearningResourceService().restore(id); + } + + @Override + protected void hardDeleteEntity(String id) { + Map params = new HashMap<>(); + params.put("hardDelete", "true"); + getLearningResourceService().delete(id, params); + } + + @Override + protected String getEntityType() { + return "learningResource"; + } + + @Override + protected void validateCreatedEntity( + LearningResource entity, CreateLearningResource createRequest) { + assertEquals(createRequest.getName(), entity.getName()); + + if (createRequest.getDescription() != null) { + assertEquals(createRequest.getDescription(), entity.getDescription()); + } + + if (createRequest.getDisplayName() != null) { + assertEquals(createRequest.getDisplayName(), entity.getDisplayName()); + } + + assertEquals(createRequest.getResourceType().value(), entity.getResourceType().value()); + assertNotNull(entity.getCategories()); + assertFalse(entity.getCategories().isEmpty()); + assertNotNull(entity.getContexts()); + assertFalse(entity.getContexts().isEmpty()); + + assertTrue( + entity.getFullyQualifiedName().contains(entity.getName()), + "FQN should contain resource name"); + } + + @Override + protected ListResponse listEntities(ListParams params) { + return getLearningResourceService().list(params); + } + + @Override + protected LearningResource getEntityWithFields(String id, String fields) { + return getLearningResourceService().get(id, fields); + } + + @Override + protected LearningResource getEntityByNameWithFields(String fqn, String fields) { + return getLearningResourceService().getByName(fqn, fields); + } + + @Override + protected LearningResource getEntityIncludeDeleted(String id) { + return getLearningResourceService().get(id, null, "deleted"); + } + + @Override + protected EntityHistory getVersionHistory(UUID id) { + return getLearningResourceService().getVersionList(id); + } + + @Override + protected LearningResource getVersion(UUID id, Double version) { + return getLearningResourceService().getVersion(id.toString(), version); + } + + // =================================================================== + // AI CATEGORY TESTS + // =================================================================== + + @Test + void post_learningResourceWithAICategory_200_OK(TestNamespace ns) { + CreateLearningResource request = + new CreateLearningResource() + .withName(ns.prefix("ai-resource")) + .withDescription("AI tutorial") + .withResourceType(CreateLearningResource.ResourceType.VIDEO) + .withCategories(List.of(ResourceCategory.AI)) + .withSource( + new LearningResourceSource() + .withProvider("YouTube") + .withUrl(URI.create("https://youtube.com/watch?v=ai-tutorial"))) + .withContexts(List.of(new LearningResourceContext().withPageId("askCollate"))); + + LearningResource resource = createEntity(request); + assertNotNull(resource.getId()); + assertEquals(1, resource.getCategories().size()); + assertTrue(resource.getCategories().contains(ResourceCategory.AI)); + } + + @Test + void post_learningResourceWithAIAndOtherCategories_200_OK(TestNamespace ns) { + CreateLearningResource request = + new CreateLearningResource() + .withName(ns.prefix("ai-discovery-resource")) + .withDescription("AI and Discovery tutorial") + .withResourceType(CreateLearningResource.ResourceType.STORYLANE) + .withCategories(List.of(ResourceCategory.AI, ResourceCategory.DISCOVERY)) + .withSource( + new LearningResourceSource() + .withProvider("Storylane") + .withUrl(URI.create("https://storylane.app/embed/ai-discovery"))) + .withContexts( + List.of( + new LearningResourceContext().withPageId("askCollate"), + new LearningResourceContext().withPageId("explore"))); + + LearningResource resource = createEntity(request); + assertEquals(2, resource.getCategories().size()); + assertTrue(resource.getCategories().contains(ResourceCategory.AI)); + assertTrue(resource.getCategories().contains(ResourceCategory.DISCOVERY)); + } + + // =================================================================== + // ALL CATEGORIES TESTS + // =================================================================== + + @Test + void post_learningResourceWithAllCategories_200_OK(TestNamespace ns) { + for (ResourceCategory category : ResourceCategory.values()) { + CreateLearningResource request = + new CreateLearningResource() + .withName(ns.prefix("cat-" + category.value().toLowerCase())) + .withDescription("Resource for " + category.value()) + .withResourceType(CreateLearningResource.ResourceType.ARTICLE) + .withCategories(List.of(category)) + .withSource( + new LearningResourceSource() + .withUrl(URI.create("https://example.com/" + category.value()))) + .withContexts(List.of(new LearningResourceContext().withPageId("test"))); + + LearningResource resource = createEntity(request); + assertNotNull(resource.getId()); + assertTrue(resource.getCategories().contains(category)); + } + } + + // =================================================================== + // RESOURCE TYPE TESTS + // =================================================================== + + @Test + void post_learningResourceAllTypes_200_OK(TestNamespace ns) { + CreateLearningResource.ResourceType[] types = CreateLearningResource.ResourceType.values(); + + for (CreateLearningResource.ResourceType type : types) { + CreateLearningResource request = + new CreateLearningResource() + .withName(ns.prefix("type-" + type.value().toLowerCase())) + .withDescription("Resource type " + type.value()) + .withResourceType(type) + .withCategories(List.of(ResourceCategory.DISCOVERY)) + .withSource( + new LearningResourceSource() + .withUrl(URI.create("https://example.com/" + type.value()))) + .withContexts(List.of(new LearningResourceContext().withPageId("explore"))); + + LearningResource resource = createEntity(request); + assertEquals(type.value(), resource.getResourceType().value()); + } + } + + // =================================================================== + // DIFFICULTY TESTS + // =================================================================== + + @Test + void post_learningResourceAllDifficulties_200_OK(TestNamespace ns) { + CreateLearningResource.ResourceDifficulty[] difficulties = + CreateLearningResource.ResourceDifficulty.values(); + + for (CreateLearningResource.ResourceDifficulty difficulty : difficulties) { + CreateLearningResource request = + new CreateLearningResource() + .withName(ns.prefix("diff-" + difficulty.value().toLowerCase())) + .withDescription("Difficulty " + difficulty.value()) + .withResourceType(CreateLearningResource.ResourceType.VIDEO) + .withCategories(List.of(ResourceCategory.DISCOVERY)) + .withDifficulty(difficulty) + .withSource( + new LearningResourceSource() + .withUrl(URI.create("https://example.com/diff-" + difficulty.value()))) + .withContexts(List.of(new LearningResourceContext().withPageId("explore"))); + + LearningResource resource = createEntity(request); + assertEquals(difficulty.value(), resource.getDifficulty().value()); + } + } + + // =================================================================== + // STATUS TRANSITION TESTS + // =================================================================== + + @Test + void post_learningResourceStatusTransitions_200_OK(TestNamespace ns) { + CreateLearningResource request = + new CreateLearningResource() + .withName(ns.prefix("status-test")) + .withDescription("Status transition test") + .withResourceType(CreateLearningResource.ResourceType.ARTICLE) + .withCategories(List.of(ResourceCategory.DATA_GOVERNANCE)) + .withStatus(CreateLearningResource.Status.DRAFT) + .withSource( + new LearningResourceSource().withUrl(URI.create("https://example.com/draft"))) + .withContexts(List.of(new LearningResourceContext().withPageId("glossary"))); + + LearningResource resource = createEntity(request); + assertEquals(CreateLearningResource.Status.DRAFT.value(), resource.getStatus().value()); + + request.withStatus(CreateLearningResource.Status.ACTIVE); + LearningResource updated = getLearningResourceService().put(request); + assertEquals(CreateLearningResource.Status.ACTIVE.value(), updated.getStatus().value()); + + request.withStatus(CreateLearningResource.Status.DEPRECATED); + updated = getLearningResourceService().put(request); + assertEquals(CreateLearningResource.Status.DEPRECATED.value(), updated.getStatus().value()); + } + + // =================================================================== + // CONTEXT TESTS + // =================================================================== + + @Test + void post_learningResourceWithMultipleContexts_200_OK(TestNamespace ns) { + CreateLearningResource request = + new CreateLearningResource() + .withName(ns.prefix("multi-context")) + .withDescription("Multiple contexts test") + .withResourceType(CreateLearningResource.ResourceType.VIDEO) + .withCategories(List.of(ResourceCategory.DISCOVERY)) + .withSource( + new LearningResourceSource() + .withUrl(URI.create("https://example.com/multi-context"))) + .withContexts( + List.of( + new LearningResourceContext().withPageId("explore").withPriority(1), + new LearningResourceContext().withPageId("table").withPriority(2), + new LearningResourceContext() + .withPageId("glossary") + .withComponentId("header") + .withPriority(3))); + + LearningResource resource = createEntity(request); + assertEquals(3, resource.getContexts().size()); + } + + @Test + void post_learningResourceWithComponentId_200_OK(TestNamespace ns) { + CreateLearningResource request = + new CreateLearningResource() + .withName(ns.prefix("component-context")) + .withDescription("Component context test") + .withResourceType(CreateLearningResource.ResourceType.STORYLANE) + .withCategories(List.of(ResourceCategory.DATA_GOVERNANCE)) + .withSource( + new LearningResourceSource() + .withUrl(URI.create("https://storylane.app/embed/component"))) + .withContexts( + List.of( + new LearningResourceContext() + .withPageId("glossary") + .withComponentId("terms-table") + .withPriority(1))); + + LearningResource resource = createEntity(request); + assertEquals("terms-table", resource.getContexts().get(0).getComponentId()); + } + + // =================================================================== + // OPTIONAL FIELDS TESTS + // =================================================================== + + @Test + void post_learningResourceWithDisplayName_200_OK(TestNamespace ns) { + CreateLearningResource request = + new CreateLearningResource() + .withName(ns.prefix("display-name-test")) + .withDisplayName("Getting Started with Data Discovery") + .withDescription("Display name test") + .withResourceType(CreateLearningResource.ResourceType.VIDEO) + .withCategories(List.of(ResourceCategory.DISCOVERY)) + .withSource( + new LearningResourceSource().withUrl(URI.create("https://example.com/display"))) + .withContexts(List.of(new LearningResourceContext().withPageId("explore"))); + + LearningResource resource = createEntity(request); + assertEquals("Getting Started with Data Discovery", resource.getDisplayName()); + } + + @Test + void post_learningResourceWithProvider_200_OK(TestNamespace ns) { + CreateLearningResource request = + new CreateLearningResource() + .withName(ns.prefix("provider-test")) + .withDescription("Provider test") + .withResourceType(CreateLearningResource.ResourceType.VIDEO) + .withCategories(List.of(ResourceCategory.DISCOVERY)) + .withSource( + new LearningResourceSource() + .withProvider("YouTube") + .withUrl(URI.create("https://youtube.com/watch?v=test"))) + .withContexts(List.of(new LearningResourceContext().withPageId("explore"))); + + LearningResource resource = createEntity(request); + assertEquals("YouTube", resource.getSource().getProvider()); + } + + @Test + void post_learningResourceWithAllOptionalFields_200_OK(TestNamespace ns) { + CreateLearningResource request = + new CreateLearningResource() + .withName(ns.prefix("all-fields")) + .withDisplayName("Complete Tutorial") + .withDescription("A comprehensive guide to all features.") + .withResourceType(CreateLearningResource.ResourceType.STORYLANE) + .withCategories( + List.of( + ResourceCategory.DISCOVERY, + ResourceCategory.DATA_GOVERNANCE, + ResourceCategory.AI)) + .withDifficulty(CreateLearningResource.ResourceDifficulty.INTERMEDIATE) + .withStatus(CreateLearningResource.Status.ACTIVE) + .withEstimatedDuration(600) + .withSource( + new LearningResourceSource() + .withProvider("Storylane") + .withUrl(URI.create("https://storylane.app/embed/complete"))) + .withContexts( + List.of( + new LearningResourceContext().withPageId("explore").withPriority(1), + new LearningResourceContext() + .withPageId("glossary") + .withComponentId("header") + .withPriority(2))); + + LearningResource resource = createEntity(request); + assertEquals("Complete Tutorial", resource.getDisplayName()); + assertEquals("A comprehensive guide to all features.", resource.getDescription()); + assertEquals(3, resource.getCategories().size()); + assertEquals( + CreateLearningResource.ResourceDifficulty.INTERMEDIATE.value(), + resource.getDifficulty().value()); + assertEquals(600, resource.getEstimatedDuration()); + assertEquals(2, resource.getContexts().size()); + } + + // =================================================================== + // VALIDATION TESTS + // =================================================================== + + @Test + void post_learningResourceWithoutRequiredFields_400(TestNamespace ns) { + assertThrows( + Exception.class, + () -> createEntity(new CreateLearningResource().withName(null)), + "Creating resource without name should fail"); + + assertThrows( + Exception.class, + () -> + createEntity( + new CreateLearningResource() + .withName(ns.prefix("no-type")) + .withCategories(List.of(ResourceCategory.DISCOVERY)) + .withSource( + new LearningResourceSource() + .withUrl(URI.create("https://example.com/test"))) + .withContexts(List.of(new LearningResourceContext().withPageId("test")))), + "Creating resource without resourceType should fail"); + + assertThrows( + Exception.class, + () -> + createEntity( + new CreateLearningResource() + .withName(ns.prefix("no-categories")) + .withResourceType(CreateLearningResource.ResourceType.VIDEO) + .withCategories(List.of()) + .withSource( + new LearningResourceSource() + .withUrl(URI.create("https://example.com/test"))) + .withContexts(List.of(new LearningResourceContext().withPageId("test")))), + "Creating resource with empty categories should fail"); + + assertThrows( + Exception.class, + () -> + createEntity( + new CreateLearningResource() + .withName(ns.prefix("no-contexts")) + .withResourceType(CreateLearningResource.ResourceType.VIDEO) + .withCategories(List.of(ResourceCategory.DISCOVERY)) + .withSource( + new LearningResourceSource() + .withUrl(URI.create("https://example.com/test"))) + .withContexts(List.of())), + "Creating resource with empty contexts should fail"); + } + + @Test + void post_learningResourceWithInvalidValues_400(TestNamespace ns) { + assertThrows( + Exception.class, + () -> + createEntity( + new CreateLearningResource() + .withName(ns.prefix("neg-duration")) + .withResourceType(CreateLearningResource.ResourceType.VIDEO) + .withCategories(List.of(ResourceCategory.DISCOVERY)) + .withEstimatedDuration(-100) + .withSource( + new LearningResourceSource() + .withUrl(URI.create("https://example.com/test"))) + .withContexts(List.of(new LearningResourceContext().withPageId("test")))), + "Creating resource with negative duration should fail"); + + assertThrows( + Exception.class, + () -> + createEntity( + new CreateLearningResource() + .withName(ns.prefix("neg-priority")) + .withResourceType(CreateLearningResource.ResourceType.VIDEO) + .withCategories(List.of(ResourceCategory.DISCOVERY)) + .withSource( + new LearningResourceSource() + .withUrl(URI.create("https://example.com/test"))) + .withContexts( + List.of( + new LearningResourceContext().withPageId("test").withPriority(-5)))), + "Creating resource with negative priority should fail"); + } + + @Test + void post_learningResourceDuplicateName_409(TestNamespace ns) { + String resourceName = ns.prefix("duplicate-test"); + + CreateLearningResource request = + new CreateLearningResource() + .withName(resourceName) + .withDescription("First resource") + .withResourceType(CreateLearningResource.ResourceType.VIDEO) + .withCategories(List.of(ResourceCategory.DISCOVERY)) + .withSource( + new LearningResourceSource().withUrl(URI.create("https://example.com/dup1"))) + .withContexts(List.of(new LearningResourceContext().withPageId("explore"))); + + createEntity(request); + + CreateLearningResource duplicate = + new CreateLearningResource() + .withName(resourceName) + .withDescription("Duplicate resource") + .withResourceType(CreateLearningResource.ResourceType.ARTICLE) + .withCategories(List.of(ResourceCategory.DATA_QUALITY)) + .withSource( + new LearningResourceSource().withUrl(URI.create("https://example.com/dup2"))) + .withContexts(List.of(new LearningResourceContext().withPageId("dataQuality"))); + + assertThrows(Exception.class, () -> createEntity(duplicate), "Duplicate name should fail"); + } + + @Test + void post_learningResourceWithMaxLengthDisplayName_200_OK(TestNamespace ns) { + String maxDisplayName = "A".repeat(120); + + CreateLearningResource request = + new CreateLearningResource() + .withName(ns.prefix("max-display")) + .withDisplayName(maxDisplayName) + .withDescription("Max length display name test") + .withResourceType(CreateLearningResource.ResourceType.VIDEO) + .withCategories(List.of(ResourceCategory.DISCOVERY)) + .withSource( + new LearningResourceSource().withUrl(URI.create("https://example.com/maxlen"))) + .withContexts(List.of(new LearningResourceContext().withPageId("explore"))); + + LearningResource resource = createEntity(request); + assertEquals(120, resource.getDisplayName().length()); + } + + @Test + void post_learningResourceExceedingDisplayNameLength_400(TestNamespace ns) { + String tooLongDisplayName = "A".repeat(121); + + CreateLearningResource request = + new CreateLearningResource() + .withName(ns.prefix("too-long-display")) + .withDisplayName(tooLongDisplayName) + .withDescription("Too long display name test") + .withResourceType(CreateLearningResource.ResourceType.VIDEO) + .withCategories(List.of(ResourceCategory.DISCOVERY)) + .withSource( + new LearningResourceSource().withUrl(URI.create("https://example.com/toolong"))) + .withContexts(List.of(new LearningResourceContext().withPageId("explore"))); + + assertThrows( + Exception.class, + () -> createEntity(request), + "Display name exceeding 120 chars should fail"); + } + + // =================================================================== + // BOUNDARY VALUE TESTS + // =================================================================== + + @Test + void post_learningResourceWithZeroValues_200_OK(TestNamespace ns) { + CreateLearningResource request = + new CreateLearningResource() + .withName(ns.prefix("zero-values")) + .withDescription("Zero values test") + .withResourceType(CreateLearningResource.ResourceType.ARTICLE) + .withCategories(List.of(ResourceCategory.DISCOVERY)) + .withEstimatedDuration(0) + .withCompletionThreshold(0.0) + .withSource( + new LearningResourceSource().withUrl(URI.create("https://example.com/zero"))) + .withContexts( + List.of(new LearningResourceContext().withPageId("explore").withPriority(0))); + + LearningResource resource = createEntity(request); + assertEquals(0, resource.getEstimatedDuration()); + assertEquals(0, resource.getContexts().get(0).getPriority()); + } + + // =================================================================== + // LIST TESTS + // =================================================================== + + @Test + void test_listLearningResources(TestNamespace ns) { + CreateLearningResource request1 = + new CreateLearningResource() + .withName(ns.prefix("list-1")) + .withDescription("First resource") + .withResourceType(CreateLearningResource.ResourceType.VIDEO) + .withCategories(List.of(ResourceCategory.DISCOVERY)) + .withSource( + new LearningResourceSource().withUrl(URI.create("https://example.com/list1"))) + .withContexts(List.of(new LearningResourceContext().withPageId("explore"))); + + CreateLearningResource request2 = + new CreateLearningResource() + .withName(ns.prefix("list-2")) + .withDescription("Second resource") + .withResourceType(CreateLearningResource.ResourceType.ARTICLE) + .withCategories(List.of(ResourceCategory.DATA_QUALITY)) + .withSource( + new LearningResourceSource().withUrl(URI.create("https://example.com/list2"))) + .withContexts(List.of(new LearningResourceContext().withPageId("dataQuality"))); + + createEntity(request1); + createEntity(request2); + + ListParams params = new ListParams(); + params.setLimit(10); + ListResponse response = listEntities(params); + + assertNotNull(response); + assertTrue(response.getData().size() >= 2); + } + + // =================================================================== + // HELPER METHODS + // =================================================================== + + private LearningResourceService getLearningResourceService() { + return new LearningResourceService(SdkClients.adminClient().getHttpClient()); + } +} diff --git a/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/learning/LearningResourceService.java b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/learning/LearningResourceService.java new file mode 100644 index 000000000000..1272e4a8d551 --- /dev/null +++ b/openmetadata-sdk/src/main/java/org/openmetadata/sdk/services/learning/LearningResourceService.java @@ -0,0 +1,27 @@ +package org.openmetadata.sdk.services.learning; + +import org.openmetadata.schema.api.learning.CreateLearningResource; +import org.openmetadata.schema.entity.learning.LearningResource; +import org.openmetadata.sdk.exceptions.OpenMetadataException; +import org.openmetadata.sdk.network.HttpClient; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.sdk.services.EntityServiceBase; + +public class LearningResourceService extends EntityServiceBase { + public LearningResourceService(HttpClient httpClient) { + super(httpClient, "/v1/learning/resources"); + } + + @Override + protected Class getEntityClass() { + return LearningResource.class; + } + + public LearningResource create(CreateLearningResource request) throws OpenMetadataException { + return httpClient.execute(HttpMethod.POST, basePath, request, LearningResource.class); + } + + public LearningResource put(CreateLearningResource request) throws OpenMetadataException { + return httpClient.execute(HttpMethod.PUT, basePath, request, LearningResource.class); + } +} diff --git a/openmetadata-service/LEARNING_RESOURCES_DESIGN.md b/openmetadata-service/LEARNING_RESOURCES_DESIGN.md new file mode 100644 index 000000000000..86582e2f29cc --- /dev/null +++ b/openmetadata-service/LEARNING_RESOURCES_DESIGN.md @@ -0,0 +1,1142 @@ +# Learning Resources System - Design Document + +**Date:** 2025-12-26 +**Status:** Production Ready +**Version:** 1.0 - Simplified (No Progress Tracking) + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Architecture](#architecture) +3. [User Experience](#user-experience) +4. [Backend Implementation](#backend-implementation) +5. [Frontend Implementation](#frontend-implementation) +6. [Data Model](#data-model) +7. [API Endpoints](#api-endpoints) +8. [Sample Resources](#sample-resources) +9. [Integration Points](#integration-points) +10. [What Was Removed](#what-was-removed) +11. [Next Steps](#next-steps) + +--- + +## Overview + +The Learning Resources system provides contextual, in-product learning materials to help users understand and use OpenMetadata features. Resources are matched to specific pages/components and displayed on-demand when users click a lightbulb icon. + +### Key Principles + +- **User-Initiated:** Resources only appear when users click the lightbulb (💡) icon - no automatic inline display +- **Contextual:** Resources are matched to specific pages (glossary, domain, data products, etc.) and optional component IDs +- **Multi-Format:** Supports Articles (markdown), Videos (YouTube/Vimeo), and Interactive Demos (Storylane) +- **Simple:** No progress tracking, no badges, no gamification - just content delivery +- **Admin-Managed:** Full CRUD interface for creating and managing learning resources + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ User Interface │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Page Header │ +│ ┌──────────────────────────────────────────────────┐ │ +│ │ [Page Title] 💡 (3) [Other Btns] │ │ +│ └──────────────────────────────────────────────────┘ │ +│ │ │ +│ │ Click │ +│ ▼ │ +│ ┌─────────────────────┐ │ +│ │ LearningDrawer │◄──── Fetches resources by pageId │ +│ │ (Side Panel) │ │ +│ ├─────────────────────┤ │ +│ │ 📚 Learning │ │ +│ │ Resources │ │ +│ ├─────────────────────┤ │ +│ │ • Resource 1 │ │ +│ │ • Resource 2 │ │ +│ │ • Resource 3 │ │ +│ └─────────────────────┘ │ +│ │ │ +│ │ Click Resource │ +│ ▼ │ +│ ┌────────────────────────────────────┐ │ +│ │ ResourcePlayerModal (Full Screen) │ │ +│ ├────────────────────────────────────┤ │ +│ │ [Article|Video|Storylane Player] │ │ +│ │ │ │ +│ │ Content displayed here... │ │ +│ │ │ │ +│ └────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## User Experience + +### Workflow + +1. **User browses** Glossary, Domain, or Data Product page +2. **Sees lightbulb (💡) icon** in page header with badge count (e.g., "3") +3. **Clicks lightbulb** → Side drawer opens from right +4. **Sees list** of relevant learning resources (filtered by page context) +5. **Clicks a resource** → Full modal opens with content player +6. **Views content:** + - Article: Markdown rendered with full formatting + - Video: YouTube/Vimeo embedded player + - Storylane: Interactive demo iframe +7. **Closes modal** when done +8. **Can access** other resources from drawer + +### Key UX Decisions + +- ✅ **No automatic display** - Resources never appear without user action +- ✅ **Badge count** - Shows number of available resources (e.g., 💡 with "3" badge) +- ✅ **Side drawer pattern** - Non-intrusive, can stay open while browsing +- ✅ **Full modal for content** - Immersive viewing experience +- ✅ **No progress tracking** - Simple, no pressure to complete + +--- + +## Backend Implementation + +### Entity: LearningResource + +**Location:** `openmetadata-spec/src/main/resources/json/schema/entity/learning/learningResource.json` + +**Key Fields:** +- `name` (required): Unique identifier (e.g., "Intro_GlossaryBasics") +- `displayName`: Human-readable title +- `description`: Brief summary +- `resourceType`: `Article` | `Video` | `Storylane` +- `categories`: Array of categories (Discovery, DataGovernance, DataQuality, Administration, Observability) +- `difficulty`: `Intro` | `Intermediate` | `Advanced` +- `source`: + - `url`: Link to external content or video + - `provider`: Source name (e.g., "Collate", "OpenMetadata", "YouTube") + - `embedConfig`: Optional object for embedded content (used for Article markdown) +- `estimatedDuration`: Duration in seconds +- `contexts`: Array of page/component contexts where this resource applies + - `pageId`: Page identifier (e.g., "glossary", "domain", "dataProduct") + - `componentId`: Optional component identifier (e.g., "glossary-header", "metrics-tab") +- `status`: `Draft` | `Active` | `Deprecated` +- `owners`: Entity references for owners +- `reviewers`: Entity references for reviewers + +**Example:** +```json +{ + "name": "Intro_GlossaryBasics", + "displayName": "Glossary Basics: Building Your Business Vocabulary", + "description": "Learn the fundamentals of creating and managing glossaries...", + "resourceType": "Article", + "categories": ["Discovery", "DataGovernance"], + "difficulty": "Intro", + "source": { + "url": "https://www.getcollate.io/learning-center/resource/Intro_GlossaryBasics", + "provider": "Collate", + "embedConfig": { + "content": "# Glossary Basics\n\nMarkdown content here..." + } + }, + "estimatedDuration": 720, + "contexts": [ + { + "pageId": "glossary", + "componentId": "glossary-header" + } + ], + "status": "Active" +} +``` + +### Repository + +**File:** `openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LearningResourceRepository.java` + +**Key Methods:** +- `prepare()`: Validates source URL, categories, contexts, and duration +- `validateSource()`: Ensures valid URL format +- `ensureCategories()`: Sets default Discovery category if none provided +- `validateContexts()`: Ensures at least one context is provided +- `validateDuration()`: Ensures non-negative duration +- `listByContext()`: Filters resources by pageId and optional componentId + +**Validation Rules:** +- Source URL is required and must be valid +- At least one category required +- At least one context (pageId) required +- Duration must be >= 0 or null +- ComponentId is optional within context + +### REST Resource + +**File:** `openmetadata-service/src/main/java/org/openmetadata/service/resources/learning/LearningResourceResource.java` + +**Endpoints:** +- `POST /api/v1/learning/resources` - Create resource +- `GET /api/v1/learning/resources` - List resources (with filtering) +- `GET /api/v1/learning/resources/{id}` - Get by ID +- `GET /api/v1/learning/resources/name/{fqn}` - Get by name +- `PATCH /api/v1/learning/resources/{id}` - Update resource +- `DELETE /api/v1/learning/resources/{id}` - Delete resource +- `GET /api/v1/learning/resources/context/{pageId}` - Get by context (with optional componentId param) + +**Query Parameters for Context Filtering:** +- `pageId`: Required - filter by page (e.g., "glossary") +- `componentId`: Optional - further filter by component +- `limit`: Max results to return +- `fields`: Additional fields to include + +### Database Schema + +**Table:** `learning_resource_entity` + +**Migrations:** +- MySQL: `bootstrap/sql/migrations/native/1.12.0/mysql/schemaChanges.sql` +- PostgreSQL: `bootstrap/sql/migrations/native/1.12.0/postgres/schemaChanges.sql` + +**Note:** Badge and Progress tables were removed - they're in schema files but never created. + +--- + +## Frontend Implementation + +### Components + +#### 1. LearningIcon (Lightbulb Button) + +**File:** `src/components/Learning/LearningIcon/LearningIcon.component.tsx` + +**Purpose:** Clickable lightbulb icon with resource count badge + +**Features:** +- Shows badge with count of available resources +- Lazy loads count on hover (optimization) +- Opens LearningDrawer on click +- Supports different sizes (small, medium, large) +- Optional label text + +**Props:** +```typescript +interface LearningIconProps { + pageId: string; + componentId?: string; + className?: string; + size?: 'small' | 'medium' | 'large'; + label?: string; + tooltip?: string; + placement?: 'top' | 'bottom' | 'left' | 'right'; +} +``` + +**Usage:** +```tsx + +``` + +#### 2. LearningDrawer (Side Panel) + +**File:** `src/components/Learning/LearningDrawer/LearningDrawer.component.tsx` + +**Purpose:** Side drawer that lists available resources + +**Features:** +- Opens from right side +- Shows title: "Learning Resources for {page}" +- Lists resources as cards +- Click card → opens ResourcePlayerModal +- Auto-fetches resources when opened +- Shows loading spinner while fetching +- Shows empty state if no resources + +**Props:** +```typescript +interface LearningDrawerProps { + open: boolean; + pageId: string; + componentId?: string; + onClose: () => void; +} +``` + +#### 3. ResourcePlayerModal (Content Viewer) + +**File:** `src/components/Learning/ResourcePlayer/ResourcePlayerModal.component.tsx` + +**Purpose:** Full-screen modal for displaying resource content + +**Features:** +- Routes to correct player based on resourceType +- Shows resource metadata (title, description, difficulty, duration, categories) +- Clean close button +- Responsive sizing + +**Player Routing:** +```typescript +switch (resource.resourceType) { + case 'Video': return ; + case 'Storylane': return ; + case 'Article': return ; +} +``` + +#### 4. VideoPlayer (YouTube/Vimeo) + +**File:** `src/components/Learning/ResourcePlayer/VideoPlayer.component.tsx` + +**Purpose:** Embeds YouTube and Vimeo videos + +**Features:** +- **Smart URL conversion:** + - `youtube.com/watch?v=abc123` → `youtube.com/embed/abc123` + - `youtu.be/abc123` → `youtube.com/embed/abc123` + - `vimeo.com/123456` → `player.vimeo.com/video/123456` +- **16:9 responsive aspect ratio** (56.25% padding-bottom technique) +- Loading spinner while video loads +- Full permissions for video features (autoplay, fullscreen, etc.) + +**Styling:** `VideoPlayer.less` - Responsive container with padding-bottom aspect ratio + +#### 5. StorylaneTour (Interactive Demos) + +**File:** `src/components/Learning/ResourcePlayer/StorylaneTour.component.tsx` + +**Purpose:** Embeds Storylane interactive product demos + +**Features:** +- Simple iframe embed +- Fixed height (600px) suitable for demos +- Loading spinner while demo loads +- Fullscreen support +- Clean, borderless presentation + +**URL Format:** `https://app.storylane.io/share/xxxxxxxxxx` + +**Styling:** `StorylaneTour.less` - Fixed height container + +#### 6. ArticleViewer (Markdown Content) + +**File:** `src/components/Learning/ResourcePlayer/ArticleViewer.component.tsx` + +**Purpose:** Renders markdown article content + +**Features:** +- Uses existing `RichTextEditorPreviewer` component +- Reads content from `resource.source.embedConfig.content` +- No truncation (`enableSeeMoreVariant={false}`) +- Full markdown support (headers, lists, code blocks, links, etc.) + +#### 7. LearningResourceCard + +**File:** `src/components/Learning/LearningResourceCard/LearningResourceCard.component.tsx` + +**Purpose:** Card UI for individual resource in lists + +**Features:** +- Shows resource type icon +- Displays title, description +- Shows difficulty badge +- Shows estimated duration +- Shows categories as tags +- Clickable to open resource + +### Admin UI + +#### 1. LearningResourcesPage (Management Table) + +**File:** `src/pages/LearningResourcesPage/LearningResourcesPage.tsx` + +**Purpose:** Admin interface for managing all learning resources + +**Features:** +- **Table view** with columns: + - Name (displayName or name) + - Type (Article/Video/Storylane with colored tag) + - Difficulty (Intro/Intermediate/Advanced) + - Categories (multiple tags) + - Contexts (pageId:componentId pairs) + - Duration (converted to minutes) + - Status (Active/Draft/Deprecated) + - Actions (Preview, Edit, Delete buttons) +- **Pagination** (20 per page, configurable) +- **Create button** to add new resources +- **Preview** - Opens ResourcePlayerModal to view resource +- **Edit** - Opens LearningResourceForm with resource data +- **Delete** - Shows confirmation modal before deletion + +**Access:** Need to add route to admin section + +#### 2. LearningResourceForm (Create/Edit Form) + +**File:** `src/pages/LearningResourcesPage/LearningResourceForm.component.tsx` + +**Purpose:** Modal form for creating or editing learning resources + +**Features:** +- **Form Fields:** + - Name (required, unique identifier) + - Display Name + - Description (textarea) + - Resource Type (select: Article/Video/Storylane) + - Categories (multi-select) + - Difficulty (select: Intro/Intermediate/Advanced) + - Source URL (required, validated) + - Source Provider (text) + - **Embedded Content** (only for Article type) - Rich text editor for markdown + - Estimated Duration (number in minutes, converted to seconds) + - **Contexts** (dynamic Form.List): + - Page ID (select from predefined list) + - Component ID (optional text input) + - Add/Remove buttons for multiple contexts + - Status (select: Draft/Active/Deprecated) +- **Validation:** + - Required fields enforced + - URL format validated + - At least one context required +- **Submission:** + - Creates new resource or updates existing + - Converts duration minutes → seconds + - Handles embedded content for articles + - Shows success/error toasts + +**Constants in Form:** +```typescript +const RESOURCE_TYPES = ['Article', 'Video', 'Storylane']; +const DIFFICULTIES = ['Intro', 'Intermediate', 'Advanced']; +const CATEGORIES = ['Discovery', 'Administration', 'DataGovernance', + 'DataQuality', 'Observability']; +const STATUSES = ['Draft', 'Active', 'Deprecated']; +const PAGE_IDS = ['glossary', 'glossaryTerm', 'domain', 'dataProduct', + 'dataQuality', 'table', 'dashboard', 'pipeline', 'topic', + 'explore', 'governance']; +``` + +### API Integration + +**File:** `src/rest/learningResourceAPI.ts` + +**Key Functions:** +```typescript +// Create new resource +export const createLearningResource = (data: CreateLearningResource): Promise + +// Get all resources (with optional filters) +export const getLearningResourcesList = (params?: { + limit?: number; + fields?: string; + category?: string; + difficulty?: string; +}): Promise<{ data: LearningResource[]; paging: Paging }> + +// Get resources by context +export const getLearningResourcesByContext = ( + pageId: string, + params?: { + componentId?: string; + limit?: number; + fields?: string; + } +): Promise<{ data: LearningResource[]; paging: Paging }> + +// Update existing resource +export const updateLearningResource = (id: string, data: CreateLearningResource): Promise + +// Delete resource +export const deleteLearningResource = (id: string): Promise +``` + +--- + +## Data Model + +### TypeScript Interfaces + +**File:** `src/rest/learningResourceAPI.ts` + +```typescript +export interface LearningResource { + id: string; + name: string; + fullyQualifiedName?: string; + displayName?: string; + description?: string; + resourceType: 'Article' | 'Video' | 'Storylane'; + categories: string[]; + difficulty?: 'Intro' | 'Intermediate' | 'Advanced'; + source: { + url: string; + provider?: string; + embedConfig?: { + content?: string; // For Article type - markdown content + [key: string]: unknown; + }; + }; + estimatedDuration?: number; // In seconds + contexts: Array<{ + pageId: string; + componentId?: string; + }>; + status?: 'Draft' | 'Active' | 'Deprecated'; + owners?: EntityReference[]; + reviewers?: EntityReference[]; + version?: number; + updatedAt?: number; + updatedBy?: string; + href?: string; +} + +export interface CreateLearningResource { + name: string; + displayName?: string; + description?: string; + resourceType: 'Article' | 'Video' | 'Storylane'; + categories: string[]; + difficulty?: 'Intro' | 'Intermediate' | 'Advanced'; + source: { + url: string; + provider?: string; + embedConfig?: { + content?: string; + [key: string]: unknown; + }; + }; + estimatedDuration?: number; + contexts: Array<{ + pageId: string; + componentId?: string; + }>; + status?: 'Draft' | 'Active' | 'Deprecated'; + owners?: EntityReference[]; + reviewers?: EntityReference[]; +} +``` + +### Context Matching Logic + +Resources are matched to pages using a hierarchical system: + +1. **Exact match:** `pageId` + `componentId` both match +2. **Page match:** `pageId` matches, no `componentId` specified in resource +3. **Broad match:** Resource applies to entire page + +**Example:** +- Resource with `pageId: "glossary", componentId: "glossary-header"` → Only shows in glossary header +- Resource with `pageId: "glossary"` (no componentId) → Shows anywhere on glossary pages + +--- + +## API Endpoints + +### Base URL +`/api/v1/learning/resources` + +### Endpoints + +#### Create Resource +```http +POST /api/v1/learning/resources +Content-Type: application/json + +{ + "name": "Intro_GlossaryBasics", + "displayName": "Glossary Basics", + "resourceType": "Article", + "categories": ["Discovery"], + "source": { + "url": "https://example.com/resource", + "provider": "Collate" + }, + "contexts": [ + { "pageId": "glossary" } + ] +} +``` + +#### List All Resources +```http +GET /api/v1/learning/resources?limit=100&fields=categories,contexts,difficulty +``` + +#### Get by Context +```http +GET /api/v1/learning/resources/context/glossary?componentId=glossary-header&limit=10 +``` + +#### Get by ID +```http +GET /api/v1/learning/resources/{uuid} +``` + +#### Get by Name +```http +GET /api/v1/learning/resources/name/Intro_GlossaryBasics +``` + +#### Update Resource +```http +PATCH /api/v1/learning/resources/{uuid} +Content-Type: application/json-patch+json + +[ + { + "op": "replace", + "path": "/difficulty", + "value": "Advanced" + } +] +``` + +#### Delete Resource +```http +DELETE /api/v1/learning/resources/{uuid} +``` + +--- + +## Sample Resources + +### Current Resources (15 total) + +**Location:** `openmetadata-service/src/main/resources/json/data/learningResource/` + +#### Articles (14) + +**Intro Level (5):** +1. `Intro_GlossaryBasics.json` - Glossary fundamentals +2. `Intro_DomainManagement.json` - Domain organization +3. `Intro_DataCatalogBasics.json` - Data catalog basics +4. `Intro_DataLineage2025.json` - Understanding lineage +5. `QuickStart_TableDiscovery.json` - Quick table discovery + +**Intermediate Level (5):** +1. `Intermediate_GlossaryTerms.json` - Advanced glossary usage +2. `Intermediate_DataGovernance.json` - Governance frameworks +3. `Intermediate_DataGovernance2025.json` - Updated governance +4. `Intermediate_DataProducts.json` - Data product patterns +5. `Intermediate_DataQuality2025.json` - Data quality practices + +**Advanced Level (4):** +1. `Advanced_DataQuality.json` - Data quality testing +2. `Advanced_DataProductMetrics.json` - Product metrics +3. `Advanced_DataObservability2025.json` - Observability patterns +4. `Advanced_DomainStrategies.json` - Domain strategies + +#### Storylane Demos (1) + +1. `Demo_GettingStartedCollate.json` - **PLACEHOLDER** - needs actual Storylane URL + +### Resource Distribution by Category + +- **Discovery:** 5 resources +- **DataGovernance:** 7 resources +- **DataQuality:** 3 resources +- **Administration:** 1 resource +- **Observability:** 1 resource + +### Resource Distribution by Page Context + +- **glossary:** 6 resources +- **glossaryTerm:** 4 resources +- **domain:** 3 resources +- **dataProduct:** 2 resources +- **dataQuality:** 1 resource +- **governance:** 1 resource +- **explore:** 2 resources +- **table:** 3 resources + +--- + +## Integration Points + +### Current Integrations + +The LearningIcon is integrated in multiple components: + +#### 1. Data Assets Header (All Entity Pages) +**File:** `src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx` + +```tsx + + {/* Other buttons */} + + +``` + +**Applies to all data asset types:** +- Table (`table`) +- Pipeline (`pipeline`) +- Dashboard (`dashboard`) +- Topic (`topic`) +- Container (`container`) +- MlModel (`mlmodel`) +- SearchIndex (`searchIndex`) +- And other entity types... + +#### 2. Glossary Pages +**File:** `src/components/Glossary/GlossaryHeader/GlossaryHeader.component.tsx` + +```tsx + + + {/* Other buttons */} + +``` + +**Resources shown:** +- Glossary-related articles and tutorials +- Dynamically switches between `glossary` and `glossaryTerm` context + +#### 3. Domain Pages +**File:** `src/components/Domain/DomainDetails/DomainDetails.component.tsx` + +```tsx + + + {/* Other buttons */} + +``` + +**Resources shown:** +- Domain management guides +- Domain strategy documents + +#### 4. Data Product Pages +**File:** `src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx` + +```tsx + + + {/* Other buttons */} + +``` + +**Resources shown:** +- Data product creation guides +- Data product metrics tutorials + +### Admin UI Route + +**Path:** `/settings/preferences/learning-resources` + +**File:** `src/components/AppRouter/SettingsRouter.tsx` + +**Menu Location:** Settings → Preferences → Learning Resources + +**Features:** +- Full CRUD for learning resources +- Preview resources in player modal +- Manage contexts, categories, difficulty levels + +--- + +## What Was Removed + +This section documents features that were removed during the simplification. + +### Removed Entities + +1. **LearningBadge** + - Schema: `openmetadata-spec/.../entity/learning/learningBadge.json` + - Repository: `LearningBadgeRepository.java` + - Resource: `LearningBadgeResource.java` + - Tests: `LearningBadgeResourceTest.java` + - Purpose: Gamification badges awarded for completing resources + +2. **LearningResourceProgress** + - Schema: `openmetadata-spec/.../entity/learning/learningResourceProgress.json` + - Repository: `LearningResourceProgressRepository.java` + - Resource: `LearningResourceProgressResource.java` + - Tests: `LearningResourceProgressResourceTest.java` + - Purpose: Track user progress through resources + +### Removed Fields from LearningResource + +- **completionThreshold** (Double) - Percentage required to mark as complete + - Removed from schema + - Removed from repository validation + - Removed from resource conversion + - Removed from tests + - Removed from all 14 sample JSON files + +### Removed Frontend Components/Features + +1. **Progress Tracking from Players:** + - VideoPlayer: Removed YouTube API message handlers, time tracking, progress intervals + - ArticleViewer: Removed scroll tracking, container refs, progress calculation + - StorylaneTour: Removed time-based progress tracking + +2. **Progress UI Components:** + - ResourcePlayerModal: Removed progress bar, completion percentage, "Mark Complete" button + - Removed `useLearningResourcePlayer` hook (entire file) + +3. **Inline Display Components:** + - LearningCenterBadge (created then removed - not needed) + - InlineLearningPanel (created then removed - not needed) + +### Removed Database Tables + +**Note:** These tables were defined in migration files but never actually created in the schema: +- `learning_badge_entity` +- `learning_resource_progress_entity` + +They were removed from migration files: +- `bootstrap/sql/migrations/native/1.10.5/mysql/schemaChanges.sql` +- `bootstrap/sql/migrations/native/1.10.5/postgres/schemaChanges.sql` + +### Removed API Endpoints + +- All badge-related endpoints (`/api/v1/learning/badges/*`) +- All progress-related endpoints (`/api/v1/learning/progress/*`) + +--- + +## Next Steps + +### Immediate (When Ready) + +1. **Add Storylane URLs** + - Get actual Storylane embed URLs from Collate learning center + - Replace `PLACEHOLDER_URL` in `Demo_GettingStartedCollate.json` + - Format: `https://app.storylane.io/share/xxxxxxxxxx` + +2. **Add More Demos** + - Create additional Storylane resources from learning center: + - Data Governance demo + - Data Lineage demo + - Data Quality demo + - Table Discovery demo + - Use `Demo_GettingStartedCollate.json` as template + +### Completed ✅ + +1. **Admin Route Added** + - Route: `/settings/preferences/learning-resources` + - Location: Settings → Preferences → Learning Resources + - Admin-only access + +2. **LearningIcon Coverage Expanded** + - Added to DataAssetsHeader (covers all data asset entity pages) + - Now shows on: Table, Pipeline, Dashboard, Topic, Container, etc. + +### Optional Enhancements + +2. **Resource Management** + - Review and update article content as needed + - Ensure external links remain valid + - Update descriptions for clarity + - Add more resources for underserved contexts + +3. **Metrics (Future)** + - Track which resources are viewed most + - Identify popular topics + - Inform content creation priorities + - (Can be done via backend logging, no UI changes needed) + +4. **Search (Future)** + - Add search box in LearningDrawer + - Filter resources by keyword + - Search across title, description, categories + +5. **Resource Suggestions (Future)** + - Recommend related resources + - "If you liked X, try Y" + - Based on category/difficulty + +--- + +## Technical Notes + +### Build Commands + +```bash +# Backend +mvn clean compile -pl openmetadata-service -DskipTests +mvn spotless:apply -pl openmetadata-service +mvn test-compile -pl openmetadata-service + +# Frontend +cd openmetadata-ui/src/main/resources/ui +yarn lint:fix +yarn build +``` + +### File Locations Reference + +**Backend:** +- Entity schema: `openmetadata-spec/src/main/resources/json/schema/entity/learning/learningResource.json` +- Repository: `openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LearningResourceRepository.java` +- Resource: `openmetadata-service/src/main/java/org/openmetadata/service/resources/learning/LearningResourceResource.java` +- Tests: `openmetadata-service/src/test/java/org/openmetadata/service/resources/learning/LearningResourceResourceTest.java` +- Sample data: `openmetadata-service/src/main/resources/json/data/learningResource/*.json` +- Migrations: `bootstrap/sql/migrations/native/1.12.0/{mysql,postgres}/schemaChanges.sql` + +**Frontend:** +- API client: `src/rest/learningResourceAPI.ts` +- LearningIcon: `src/components/Learning/LearningIcon/` +- LearningDrawer: `src/components/Learning/LearningDrawer/` +- ResourcePlayerModal: `src/components/Learning/ResourcePlayer/ResourcePlayerModal.component.tsx` +- VideoPlayer: `src/components/Learning/ResourcePlayer/VideoPlayer.component.tsx` +- StorylaneTour: `src/components/Learning/ResourcePlayer/StorylaneTour.component.tsx` +- ArticleViewer: `src/components/Learning/ResourcePlayer/ArticleViewer.component.tsx` +- ResourceCard: `src/components/Learning/LearningResourceCard/` +- Admin UI: `src/pages/LearningResourcesPage/` + +### Dependencies + +**Backend:** +- Standard OpenMetadata entity framework +- JDBI3 for database access +- Jackson for JSON serialization +- Standard REST resource patterns + +**Frontend:** +- React + TypeScript +- Ant Design (Table, Form, Modal, Drawer, Badge, Button, etc.) +- react-i18next for translations +- RichTextEditorPreviewer for markdown (existing OM component) + +### Translation Keys Used + +**Existing Keys (already in codebase):** +- `label.learning-resources` +- `label.learning-resource` +- `label.difficulty` +- `label.category-plural` +- `label.context-plural` +- `label.duration` +- `label.status` +- `label.create` +- `label.update` +- `label.delete` +- `label.preview` +- `label.close` +- `message.learning-resources-available` (with count parameter) +- `message.resources-available` (with count parameter) +- `message.no-learning-resources-available` + +**May Need to Add:** +- `label.resource-lowercase-plural` +- `label.learning-resources-for` (with context parameter) +- `label.component-id-optional` +- `label.page-id` +- `label.embedded-content` +- `label.source-url` +- `label.source-provider` +- `label.estimated-duration-minutes` +- `message.optional-markdown-content` +- `message.write-markdown-content` +- `server.learning-resources-fetch-error` + +--- + +## Design Decisions & Rationale + +### Why No Progress Tracking? + +**Original Design:** Complex system with progress tracking, completion thresholds, badges, gamification. + +**Simplified Design:** Just content delivery. + +**Reasons:** +1. **Complexity vs Value:** Progress tracking added significant complexity with unclear value +2. **User Pressure:** Users felt pressured to "complete" resources rather than learn +3. **Privacy Concerns:** Tracking user behavior can raise privacy issues +4. **Simplicity:** Easier to maintain, easier to understand, easier to use +5. **Content Focus:** Focus on quality content, not gamification + +### Why Lightbulb Icon (User-Initiated)? + +**Alternatives considered:** +- Auto-show banner at top of page +- Inline panels in page content +- Floating widget in corner + +**Chosen: Lightbulb in header** + +**Reasons:** +1. **Non-intrusive:** Doesn't block or clutter the main content +2. **Discoverable:** Visible but not annoying +3. **User Control:** Users choose when to learn +4. **Familiar Pattern:** Similar to help/docs icons +5. **Badge Count:** Shows availability without requiring click + +### Why Side Drawer vs Modal? + +**For resource list:** Side drawer +**For content:** Full modal + +**Reasons:** +1. **Drawer Benefits:** + - Can stay open while user browses + - Non-blocking for main content + - Shows context (what page you're on) + - Easy to dismiss but also easy to keep open + +2. **Modal Benefits (for content):** + - Immersive viewing experience + - Full screen for videos/demos + - Focused attention on content + - Clear "viewing mode" vs "browsing mode" + +### Why Three Resource Types? + +**Article, Video, Storylane** - why not more? + +**Reasoning:** +1. **Article (Markdown):** + - Self-contained, searchable, version-controllable + - Fast to load, accessible, printable + - Good for reference documentation + +2. **Video (YouTube/Vimeo):** + - Engaging, visual, personality + - Good for tutorials and overviews + - Familiar platform + +3. **Storylane (Interactive Demo):** + - Hands-on experience without setup + - Safe to explore (can't break anything) + - Shows actual product UI + +**Not included:** +- PDFs: Hard to read in browser, not responsive +- External links: Jarring experience leaving product +- Webinars: Too long, scheduling issues + +--- + +## Success Metrics (Future) + +While we don't track progress, we can measure success: + +### Adoption Metrics +- Number of resources created +- Number of pages with LearningIcon integrated +- Diversity of contexts covered + +### Quality Metrics +- % of resources with clear descriptions +- % of resources with owners +- % of critical features covered by resources + +### Potential Usage Metrics (if instrumented) +- Lightbulb click rate +- Resource view counts +- Average time in ResourcePlayerModal +- Return visits to same resource + +--- + +## Version History + +### v1.0 - Current (2025-12-26) +- Simplified design with no progress tracking +- LearningIcon + LearningDrawer pattern +- Three resource types: Article, Video, Storylane +- Admin UI for resource management +- 15 sample resources (14 articles, 1 demo placeholder) +- Integrated in Glossary, Domain, Data Product pages + +### v0.x - Previous (Removed) +- Complex progress tracking system +- Badge/gamification system +- Completion thresholds +- Auto-displaying inline panels +- Manual "Mark Complete" buttons + +--- + +## Appendix: Code Snippets + +### Adding LearningIcon to a New Page + +```tsx +import { LearningIcon } from 'components/Learning/LearningIcon/LearningIcon.component'; + +// In your page header component: + + + {/* other header buttons */} + +``` + +### Creating a New Resource via Admin UI + +1. Navigate to Learning Resources admin page +2. Click "Add Resource" button +3. Fill in form: + - Name: `Intermediate_YourFeature` + - Display Name: "Your Feature Guide" + - Description: Brief summary + - Type: Article/Video/Storylane + - Categories: Select relevant categories + - Difficulty: Intro/Intermediate/Advanced + - Source URL: External link or YouTube/Storylane URL + - (If Article) Embedded Content: Markdown text + - Duration: Estimated minutes + - Contexts: Add pageId(s) where this should appear + - Status: Active +4. Click "Create" + +### Creating a Resource via JSON + +```json +{ + "name": "Intermediate_MyFeature", + "displayName": "Understanding My Feature", + "description": "A comprehensive guide to using My Feature effectively", + "resourceType": "Article", + "categories": ["Discovery", "DataGovernance"], + "difficulty": "Intermediate", + "source": { + "url": "https://www.getcollate.io/learning-center/my-feature", + "provider": "Collate", + "embedConfig": { + "content": "# Understanding My Feature\n\nMarkdown content here..." + } + }, + "estimatedDuration": 600, + "contexts": [ + { + "pageId": "my-feature-page", + "componentId": "feature-header" + } + ], + "status": "Active", + "owners": [] +} +``` + +Save to: `openmetadata-service/src/main/resources/json/data/learning/resource/Intermediate_MyFeature.json` + +--- + +## Support & Questions + +For questions about this system: + +1. **Design Questions:** Refer to this document +2. **Implementation Questions:** Check source files referenced in "File Locations" +3. **Bug Reports:** Standard OpenMetadata issue process +4. **Feature Requests:** Discuss with team, update "Next Steps" section + +--- + +**End of Document** diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java index b8c77358aef3..b457b0b3bba7 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java @@ -294,6 +294,7 @@ public final class Entity { public static final String ALL_RESOURCES = "All"; public static final String DOCUMENT = "document"; + public static final String LEARNING_RESOURCE = "learningResource"; // ServiceType - Service Entity name map static final Map SERVICE_TYPE_ENTITY_MAP = new EnumMap<>(ServiceType.class); // entity type to service entity name map diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java index f08773dbb824..f02259287842 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/CollectionDAO.java @@ -121,6 +121,7 @@ import org.openmetadata.schema.entity.events.FailedEvent; import org.openmetadata.schema.entity.events.FailedEventResponse; import org.openmetadata.schema.entity.events.NotificationTemplate; +import org.openmetadata.schema.entity.learning.LearningResource; import org.openmetadata.schema.entity.policies.Policy; import org.openmetadata.schema.entity.services.ApiService; import org.openmetadata.schema.entity.services.DashboardService; @@ -417,6 +418,9 @@ public interface CollectionDAO { @CreateSqlObject DocStoreDAO docStoreDAO(); + @CreateSqlObject + LearningResourceDAO learningResourceDAO(); + @CreateSqlObject SuggestionDAO suggestionDAO(); @@ -8380,6 +8384,23 @@ List listAfter( void deleteEmailTemplates(); } + interface LearningResourceDAO extends EntityDAO { + @Override + default String getTableName() { + return "learning_resource_entity"; + } + + @Override + default Class getEntityClass() { + return LearningResource.class; + } + + @Override + default String getNameHashColumn() { + return "fqnHash"; + } + } + interface SuggestionDAO { default String getTableName() { return "suggestions"; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LearningResourceRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LearningResourceRepository.java new file mode 100644 index 000000000000..80b5cfcc15b1 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LearningResourceRepository.java @@ -0,0 +1,300 @@ +/* + * Copyright 2024 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.jdbi3; + +import static org.openmetadata.common.utils.CommonUtil.nullOrEmpty; +import static org.openmetadata.service.Entity.ADMIN_USER_NAME; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.openmetadata.schema.api.learning.ResourceCategory; +import org.openmetadata.schema.entity.learning.LearningResource; +import org.openmetadata.schema.entity.learning.LearningResourceContext; +import org.openmetadata.schema.entity.learning.LearningResourceSource; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.type.change.ChangeSource; +import org.openmetadata.service.Entity; +import org.openmetadata.service.exception.BadRequestException; +import org.openmetadata.service.resources.databases.DatasourceConfig; +import org.openmetadata.service.resources.learning.LearningResourceResource; +import org.openmetadata.service.util.EntityUtil.Fields; +import org.openmetadata.service.util.EntityUtil.RelationIncludes; +import org.openmetadata.service.util.FullyQualifiedName; + +@Slf4j +public class LearningResourceRepository extends EntityRepository { + private static final String UPDATE_FIELDS = + "owners,reviewers,tags,contexts,categories,difficulty,source,estimatedDuration,status"; + private static final String PATCH_FIELDS = UPDATE_FIELDS; + + public LearningResourceRepository() { + super( + LearningResourceResource.COLLECTION_PATH, + Entity.LEARNING_RESOURCE, + LearningResource.class, + Entity.getCollectionDAO().learningResourceDAO(), + UPDATE_FIELDS, + PATCH_FIELDS); + supportsSearch = false; + } + + /** + * Initialize seed data with merge/update support. Unlike the default initializeEntity which skips + * existing entities, this method will update existing resources if the seed data has changed. + */ + public void initSeedDataWithMerge() throws java.io.IOException { + List seedEntities = getEntitiesFromSeedData(); + for (LearningResource seedEntity : seedEntities) { + setFullyQualifiedName(seedEntity); + LearningResource existingEntity = + findByNameOrNull(seedEntity.getFullyQualifiedName(), Include.ALL); + + if (existingEntity == null) { + // New entity - create it + LOG.info("Creating new learning resource: {}", seedEntity.getName()); + seedEntity.setUpdatedBy(ADMIN_USER_NAME); + seedEntity.setUpdatedAt(System.currentTimeMillis()); + seedEntity.setId(java.util.UUID.randomUUID()); + create(null, seedEntity); + } else { + // Existing entity - check if update is needed by comparing key fields + boolean needsUpdate = hasChanges(existingEntity, seedEntity); + if (needsUpdate) { + LOG.info("Updating learning resource: {}", seedEntity.getName()); + seedEntity.setId(existingEntity.getId()); + seedEntity.setUpdatedBy(ADMIN_USER_NAME); + seedEntity.setUpdatedAt(System.currentTimeMillis()); + seedEntity.setVersion(existingEntity.getVersion()); + createOrUpdate(null, seedEntity, ADMIN_USER_NAME); + } else { + LOG.debug("Learning resource {} is up to date", seedEntity.getName()); + } + } + } + } + + private boolean hasChanges(LearningResource existing, LearningResource seed) { + // Compare fields that matter for seed data updates + if (!java.util.Objects.equals(existing.getDisplayName(), seed.getDisplayName())) return true; + if (!java.util.Objects.equals(existing.getDescription(), seed.getDescription())) return true; + if (!java.util.Objects.equals(existing.getResourceType(), seed.getResourceType())) return true; + if (!java.util.Objects.equals(existing.getCategories(), seed.getCategories())) return true; + if (!java.util.Objects.equals(existing.getContexts(), seed.getContexts())) return true; + if (!java.util.Objects.equals(existing.getDifficulty(), seed.getDifficulty())) return true; + if (!java.util.Objects.equals(existing.getSource(), seed.getSource())) return true; + if (!java.util.Objects.equals(existing.getStatus(), seed.getStatus())) return true; + return false; + } + + @Override + protected void setFields( + LearningResource entity, Fields fields, RelationIncludes relationIncludes) { + // No additional field resolution for now + } + + @Override + public void setFieldsInBulk(Fields fields, List entities) { + super.setFieldsInBulk(fields, entities); + } + + @Override + protected void clearFields(LearningResource entity, Fields fields) { + // No-op + } + + @Override + public void setFullyQualifiedName(LearningResource entity) { + if (StringUtils.isNotBlank(entity.getFullyQualifiedName())) { + return; + } + entity.setFullyQualifiedName(FullyQualifiedName.build(entity.getName())); + } + + @Override + public void prepare(LearningResource entity, boolean update) { + validateSource(entity.getSource()); + ensureCategories(entity); + validateContexts(entity.getContexts()); + validateDuration(entity.getEstimatedDuration()); + } + + @Override + public void storeEntity(LearningResource entity, boolean update) { + store(entity, update); + } + + @Override + public void storeRelationships(LearningResource entity) { + // All relationships handled centrally (owners, reviewers, tags) + } + + public EntityRepository.EntityUpdater getUpdater( + LearningResource original, + LearningResource updated, + Operation operation, + ChangeSource changeSource) { + return new LearningResourceUpdater(original, updated, operation); + } + + private void validateSource(LearningResourceSource source) { + if (source == null || source.getUrl() == null) { + throw BadRequestException.of("Learning resource source with URL is required"); + } + } + + private void ensureCategories(LearningResource entity) { + List categories = entity.getCategories(); + if (nullOrEmpty(categories)) { + throw BadRequestException.of("Learning resource must include at least one category"); + } + Set unique = new LinkedHashSet<>(categories); + if (unique.size() != categories.size()) { + entity.setCategories(new ArrayList<>(unique)); + } + } + + private void validateContexts(List contexts) { + if (nullOrEmpty(contexts)) { + throw BadRequestException.of("Learning resource requires at least one placement context"); + } + + Set uniqueKeys = new HashSet<>(); + for (LearningResourceContext context : contexts) { + if (context == null || StringUtils.isBlank(context.getPageId())) { + throw BadRequestException.of("Learning resource context requires a non-empty pageId"); + } + String componentId = StringUtils.defaultIfBlank(context.getComponentId(), ""); + String key = context.getPageId() + "::" + componentId; + if (!uniqueKeys.add(key)) { + throw BadRequestException.of( + "Duplicate learning resource context for pageId '%s' and componentId '%s'" + .formatted(context.getPageId(), componentId)); + } + } + } + + private void validateDuration(Integer estimatedDuration) { + if (estimatedDuration != null && estimatedDuration < 0) { + throw BadRequestException.of("Estimated duration must be zero or a positive integer"); + } + } + + public static class LearningResourceFilter extends ListFilter { + public LearningResourceFilter(Include include) { + super(include); + } + + @Override + public String getCondition(String tableName) { + String baseCondition = super.getCondition(tableName); + String placementCondition = buildPlacementCondition(tableName); + if (placementCondition.isEmpty()) { + return baseCondition; + } + if ("WHERE TRUE".equals(baseCondition) || baseCondition.isEmpty()) { + return "WHERE " + placementCondition; + } + return baseCondition + " AND " + placementCondition; + } + + private String buildPlacementCondition(String tableName) { + List conditions = new ArrayList<>(); + if (getQueryParam("pageId") != null) { + conditions.add(pageCondition(tableName)); + } + if (getQueryParam("componentId") != null) { + conditions.add(componentCondition(tableName)); + } + if (getQueryParam("category") != null) { + conditions.add(categoryCondition(tableName)); + } + if (getQueryParam("difficulty") != null) { + conditions.add(difficultyCondition(tableName)); + } + return conditions.isEmpty() ? "" : addCondition(conditions); + } + + private String jsonColumn(String tableName) { + return tableName == null ? "json" : tableName + ".json"; + } + + private String pageCondition(String tableName) { + String column = jsonColumn(tableName); + if (Boolean.TRUE.equals(DatasourceConfig.getInstance().isMySQL())) { + return String.format( + "JSON_SEARCH(%s, 'one', :pageId, NULL, '$.contexts[*].pageId') IS NOT NULL", column); + } + return String.format( + "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(%s->'contexts', '[]'::jsonb)) ctx" + + " WHERE ctx->>'pageId' = :pageId)", + column); + } + + private String componentCondition(String tableName) { + String column = jsonColumn(tableName); + if (Boolean.TRUE.equals(DatasourceConfig.getInstance().isMySQL())) { + return String.format( + "JSON_SEARCH(%s, 'one', :componentId, NULL, '$.contexts[*].componentId') IS NOT NULL", + column); + } + return String.format( + "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(%s->'contexts', '[]'::jsonb)) ctx" + + " WHERE ctx->>'componentId' = :componentId)", + column); + } + + private String categoryCondition(String tableName) { + String column = jsonColumn(tableName); + if (Boolean.TRUE.equals(DatasourceConfig.getInstance().isMySQL())) { + return String.format( + "JSON_SEARCH(%s, 'one', :category, NULL, '$.categories') IS NOT NULL", column); + } + return String.format( + "EXISTS (SELECT 1 FROM jsonb_array_elements_text(COALESCE(%s->'categories', '[]'::jsonb)) cat" + + " WHERE cat = :category)", + column); + } + + private String difficultyCondition(String tableName) { + String column = jsonColumn(tableName); + if (Boolean.TRUE.equals(DatasourceConfig.getInstance().isMySQL())) { + return String.format( + "JSON_UNQUOTE(JSON_EXTRACT(%s, '$.difficulty')) = :difficulty", column); + } + return String.format("%s->>'difficulty' = :difficulty", column); + } + } + + class LearningResourceUpdater extends EntityUpdater { + LearningResourceUpdater( + LearningResource original, LearningResource updated, Operation operation) { + super(original, updated, operation); + } + + @Override + public void entitySpecificUpdate(boolean consolidatingChanges) { + recordChange("categories", original.getCategories(), updated.getCategories()); + recordChange("contexts", original.getContexts(), updated.getContexts(), true); + recordChange("difficulty", original.getDifficulty(), updated.getDifficulty()); + recordChange("source", original.getSource(), updated.getSource(), true); + recordChange( + "estimatedDuration", original.getEstimatedDuration(), updated.getEstimatedDuration()); + } + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/learning/LearningResourceResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/learning/LearningResourceResource.java new file mode 100644 index 000000000000..9bf5d24d59dd --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/learning/LearningResourceResource.java @@ -0,0 +1,434 @@ +/* + * Copyright 2024 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.learning; + +import io.swagger.v3.oas.annotations.ExternalDocumentation; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.json.JsonPatch; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Context; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.SecurityContext; +import jakarta.ws.rs.core.UriInfo; +import java.io.IOException; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.openmetadata.schema.api.data.RestoreEntity; +import org.openmetadata.schema.api.learning.CreateLearningResource; +import org.openmetadata.schema.entity.learning.LearningResource; +import org.openmetadata.schema.type.EntityHistory; +import org.openmetadata.schema.type.Include; +import org.openmetadata.schema.utils.ResultList; +import org.openmetadata.service.Entity; +import org.openmetadata.service.OpenMetadataApplicationConfig; +import org.openmetadata.service.jdbi3.LearningResourceRepository; +import org.openmetadata.service.limits.Limits; +import org.openmetadata.service.resources.Collection; +import org.openmetadata.service.resources.EntityResource; +import org.openmetadata.service.security.Authorizer; + +@Slf4j +@Path("/v1/learning/resources") +@Tag( + name = "Learning Resources", + description = + "Inline tutorials and expert content surfaced across OpenMetadata product surfaces.") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Collection(name = "learning/resources", order = 3) +public class LearningResourceResource + extends EntityResource { + public static final String COLLECTION_PATH = "/v1/learning/resources"; + static final String FIELDS = "owners,reviewers,tags,followers,contexts,categories"; + + public LearningResourceResource(Authorizer authorizer, Limits limits) { + super(Entity.LEARNING_RESOURCE, authorizer, limits); + } + + @Override + public void initialize(OpenMetadataApplicationConfig config) throws IOException { + // Use merge method to add new resources and update existing ones + repository.initSeedDataWithMerge(); + } + + @Override + public LearningResource addHref(UriInfo uriInfo, LearningResource resource) { + super.addHref(uriInfo, resource); + return resource; + } + + public static class LearningResourceList extends ResultList { + /* Required for serde */ + } + + @GET + @Operation( + operationId = "listLearningResources", + summary = "List learning resources", + description = "Get a paginated list of learning resources with optional contextual filters.", + responses = { + @ApiResponse( + responseCode = "200", + description = "List of learning resources", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = LearningResourceList.class))) + }) + public ResultList list( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter( + description = "Fields requested in the returned resource", + schema = @Schema(type = "string", example = FIELDS)) + @QueryParam("fields") + String fieldsParam, + @Parameter(description = "Limit the number of results returned. (1 to 1000000, default = 10)") + @DefaultValue("10") + @Min(0) + @Max(1000000) + @QueryParam("limit") + int limitParam, + @Parameter(description = "Returns list of learning resources before this cursor") + @QueryParam("before") + String before, + @Parameter(description = "Returns list of learning resources after this cursor") + @QueryParam("after") + String after, + @Parameter( + description = "Include all, deleted, or non-deleted entities", + schema = @Schema(implementation = Include.class)) + @QueryParam("include") + @DefaultValue("non-deleted") + Include include, + @Parameter( + description = "Filter resources to a specific page identifier", + schema = @Schema(type = "string")) + @QueryParam("pageId") + String pageId, + @Parameter( + description = "Filter by component identifier within a page", + schema = @Schema(type = "string")) + @QueryParam("componentId") + String componentId, + @Parameter( + description = "Filter by primary category", + schema = @Schema(type = "string", example = "DataGovernance")) + @QueryParam("category") + String category, + @Parameter( + description = "Filter by difficulty tier", + schema = @Schema(type = "string", example = "Intro")) + @QueryParam("difficulty") + String difficulty, + @Parameter( + description = "Filter by lifecycle status", + schema = @Schema(type = "string", example = "Active")) + @QueryParam("status") + String status) { + LearningResourceRepository.LearningResourceFilter filter = + new LearningResourceRepository.LearningResourceFilter(include); + if (pageId != null) { + filter.addQueryParam("pageId", pageId); + } + if (componentId != null) { + filter.addQueryParam("componentId", componentId); + } + if (category != null) { + filter.addQueryParam("category", category); + } + if (difficulty != null) { + filter.addQueryParam("difficulty", difficulty); + } + if (status != null) { + filter.addQueryParam("statusPrefix", status); + } + + return addHref( + uriInfo, + listInternal(uriInfo, securityContext, fieldsParam, filter, limitParam, before, after)); + } + + @GET + @Path("/{id}") + @Operation( + operationId = "getLearningResource", + summary = "Get a learning resource by id", + description = "Get a learning resource by `id`.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The learning resource", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = LearningResource.class))), + @ApiResponse(responseCode = "404", description = "Resource not found") + }) + public LearningResource get( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Fields requested in the returned resource") @QueryParam("fields") + String fieldsParam, + @Parameter(description = "Include all, deleted, or non-deleted entities") + @QueryParam("include") + @DefaultValue("non-deleted") + Include include, + @Parameter(description = "Id of the learning resource", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id) { + return getInternal(uriInfo, securityContext, id, fieldsParam, include); + } + + @GET + @Path("/name/{name}") + @Operation( + operationId = "getLearningResourceByName", + summary = "Get a learning resource by name", + description = "Get a learning resource by fully qualified name.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The learning resource", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = LearningResource.class))), + @ApiResponse(responseCode = "404", description = "Resource not found") + }) + public LearningResource getByName( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Fully qualified name of the learning resource") @PathParam("name") + String name, + @Parameter(description = "Fields requested in the returned resource") @QueryParam("fields") + String fieldsParam, + @Parameter(description = "Include deleted resources") + @QueryParam("include") + @DefaultValue("non-deleted") + Include include) { + return getByNameInternal(uriInfo, securityContext, name, fieldsParam, include); + } + + @GET + @Path("/{id}/versions") + @Operation( + summary = "List learning resource versions", + description = "Get a list of versions for the specified learning resource.", + responses = { + @ApiResponse( + responseCode = "200", + description = "List of versions", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = EntityHistory.class))) + }) + public EntityHistory listVersions( + @Context SecurityContext securityContext, + @Parameter(description = "Id of the learning resource", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id) { + return listVersionsInternal(securityContext, id); + } + + @GET + @Path("/{id}/versions/{version}") + @Operation( + summary = "Get a learning resource version", + description = "Get a specific version of the learning resource.", + responses = { + @ApiResponse( + responseCode = "200", + description = "Learning resource version details", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = LearningResource.class))) + }) + public LearningResource getVersion( + @Context SecurityContext securityContext, + @Parameter(description = "Id of the learning resource", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id, + @Parameter(description = "Learning resource version", schema = @Schema(type = "string")) + @PathParam("version") + String version) { + return getVersionInternal(securityContext, id, version); + } + + @POST + @Operation( + operationId = "createLearningResource", + summary = "Create a learning resource", + description = "Create a new learning resource entry.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The created resource", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = LearningResource.class))) + }) + public Response create( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Valid CreateLearningResource create) { + LearningResource resource = toEntity(create, securityContext.getUserPrincipal().getName()); + return create(uriInfo, securityContext, resource); + } + + @PUT + @Operation( + operationId = "createOrUpdateLearningResource", + summary = "Create or update a learning resource", + description = + "Create a new learning resource, or update an existing one if it already exists.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The updated resource", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = LearningResource.class))) + }) + public Response createOrUpdate( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Valid CreateLearningResource create) { + LearningResource resource = toEntity(create, securityContext.getUserPrincipal().getName()); + return createOrUpdate(uriInfo, securityContext, resource); + } + + @PATCH + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON_PATCH_JSON) + @Operation( + operationId = "patchLearningResource", + summary = "Update a learning resource", + description = "Apply a JSONPatch to a learning resource.", + externalDocs = + @ExternalDocumentation( + description = "JsonPatch RFC", + url = "https://tools.ietf.org/html/rfc6902")) + public Response patch( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Id of the learning resource", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id, + @RequestBody( + description = "JsonPatch with array of operations", + content = + @Content( + mediaType = MediaType.APPLICATION_JSON_PATCH_JSON, + examples = + @ExampleObject("[{op:replace, path:/displayName, value: 'New name'}]"))) + JsonPatch patch) { + return patchInternal(uriInfo, securityContext, id, patch); + } + + @DELETE + @Path("/{id}") + @Operation( + operationId = "deleteLearningResource", + summary = "Delete a learning resource", + description = "Delete a learning resource by `id`.", + responses = { + @ApiResponse(responseCode = "200", description = "OK"), + @ApiResponse(responseCode = "404", description = "Resource not found") + }) + public Response delete( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter(description = "Recursively delete this entity and its children. (Default = false)") + @DefaultValue("false") + @QueryParam("recursive") + boolean recursive, + @Parameter(description = "Hard delete the entity. (Default = false)") + @DefaultValue("false") + @QueryParam("hardDelete") + boolean hardDelete, + @Parameter(description = "Id of the learning resource", schema = @Schema(type = "UUID")) + @PathParam("id") + UUID id) { + return delete(uriInfo, securityContext, id, recursive, hardDelete); + } + + @PUT + @Path("/restore") + @Operation( + operationId = "restoreLearningResource", + summary = "Restore a soft-deleted learning resource", + description = "Restore a previously soft-deleted learning resource.", + responses = { + @ApiResponse( + responseCode = "200", + description = "The restored resource", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = LearningResource.class))) + }) + public Response restore( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @RequestBody( + description = "Id of the learning resource to restore", + content = + @Content( + mediaType = "application/json", + schema = @Schema(type = "string", format = "uuid"))) + RestoreEntity restore) { + return restoreEntity(uriInfo, securityContext, restore.getId()); + } + + private LearningResource toEntity(CreateLearningResource create, String updatedBy) { + LearningResource resource = repository.copy(new LearningResource(), create, updatedBy); + resource.setResourceType(create.getResourceType()); + resource.setCategories(create.getCategories()); + resource.setDifficulty(create.getDifficulty()); + resource.setSource(create.getSource()); + resource.setEstimatedDuration(create.getEstimatedDuration()); + resource.setContexts(create.getContexts()); + resource.setStatus( + create.getStatus() == null + ? null + : LearningResource.Status.fromValue(create.getStatus().value())); + return resource; + } +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_Automations.json b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_Automations.json new file mode 100644 index 000000000000..724a20cf8b6b --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_Automations.json @@ -0,0 +1,15 @@ +{ + "name": "CollateClues_Automations", + "displayName": "Collate Clues: Automations", + "description": "Discover how to automate data governance workflows and tasks in Collate.", + "resourceType": "Video", + "categories": ["DataGovernance", "Administration"], + "source": { + "url": "https://www.youtube.com/watch?v=dJZ8wGAM-ek", + "provider": "YouTube" + }, + "contexts": [ + {"pageId": "automations"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_DataContracts.json b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_DataContracts.json new file mode 100644 index 000000000000..3c6883e77728 --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_DataContracts.json @@ -0,0 +1,20 @@ +{ + "name": "CollateClues_DataContracts", + "displayName": "Collate Clues: Data Contracts", + "description": "Learn how Data Contracts help align data consumers and producers, ensuring data quality and reliability across your organization.", + "resourceType": "Video", + "categories": ["DataGovernance", "DataQuality"], + "source": { + "url": "https://www.youtube.com/watch?v=xtQ_7IOpW7c", + "provider": "YouTube" + }, + "contexts": [ + {"pageId": "table"}, + {"pageId": "topic"}, + {"pageId": "dashboard"}, + {"pageId": "pipeline"}, + {"pageId": "mlmodel"}, + {"pageId": "container"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_DataObservability.json b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_DataObservability.json new file mode 100644 index 000000000000..398b3a0bde9b --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_DataObservability.json @@ -0,0 +1,16 @@ +{ + "name": "CollateClues_DataObservability", + "displayName": "Collate Clues: Data Observability", + "description": "Learn how Data Observability helps you monitor and ensure the health of your data pipelines and assets.", + "resourceType": "Video", + "categories": ["Observability", "DataQuality"], + "source": { + "url": "https://www.youtube.com/watch?v=ZOe71H1EuQ8", + "provider": "YouTube" + }, + "contexts": [ + {"pageId": "dataObservability"}, + {"pageId": "table", "componentId": "profiler"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_DataProducts.json b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_DataProducts.json new file mode 100644 index 000000000000..75b4e336eeae --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_DataProducts.json @@ -0,0 +1,16 @@ +{ + "name": "CollateClues_DataProducts", + "displayName": "Collate Clues: Data Products", + "description": "Quick overview of Data Products in Collate - learn how to organize and manage your data assets as products.", + "resourceType": "Video", + "categories": ["DataGovernance", "Discovery"], + "source": { + "url": "https://www.youtube.com/watch?v=-P7_qO1qYY4", + "provider": "YouTube" + }, + "contexts": [ + {"pageId": "dataProduct"}, + {"pageId": "domain"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_DataQualityAdvanced.json b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_DataQualityAdvanced.json new file mode 100644 index 000000000000..10345de6ee6f --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_DataQualityAdvanced.json @@ -0,0 +1,16 @@ +{ + "name": "CollateClues_DataQualityAdvanced", + "displayName": "Collate Clues: Advanced Data Quality", + "description": "Dive deeper into Data Quality features and learn advanced testing and monitoring capabilities.", + "resourceType": "Video", + "categories": ["DataQuality", "Observability"], + "source": { + "url": "https://www.youtube.com/watch?v=jIrQ-CsLEgg", + "provider": "YouTube" + }, + "contexts": [ + {"pageId": "dataObservability"}, + {"pageId": "dataQuality"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_DataQualityBasics.json b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_DataQualityBasics.json new file mode 100644 index 000000000000..777f9e9ad015 --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_DataQualityBasics.json @@ -0,0 +1,16 @@ +{ + "name": "CollateClues_DataQualityBasics", + "displayName": "Collate Clues: Data Quality Basics", + "description": "Get started with Data Quality in Collate - learn the fundamentals of setting up quality tests and monitoring.", + "resourceType": "Video", + "categories": ["DataQuality", "Observability"], + "source": { + "url": "https://www.youtube.com/watch?v=HypaInv79PQ", + "provider": "YouTube" + }, + "contexts": [ + {"pageId": "dataObservability"}, + {"pageId": "dataQuality"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_GettingStarted.json b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_GettingStarted.json new file mode 100644 index 000000000000..fc222ac34ecb --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_GettingStarted.json @@ -0,0 +1,15 @@ +{ + "name": "CollateClues_GettingStarted", + "displayName": "Collate Clues: Getting Started", + "description": "Quick introduction to getting started with Collate - your first steps to unified metadata management.", + "resourceType": "Video", + "categories": ["Discovery", "Administration"], + "source": { + "url": "https://www.youtube.com/watch?v=DqIT4vWALGk", + "provider": "YouTube" + }, + "contexts": [ + {"pageId": "home"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_GovernanceWorkflows.json b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_GovernanceWorkflows.json new file mode 100644 index 000000000000..db46ea1d7342 --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_GovernanceWorkflows.json @@ -0,0 +1,17 @@ +{ + "name": "CollateClues_GovernanceWorkflows", + "displayName": "Collate Clues: Governance Workflows", + "description": "Learn how Governance Workflows help automate and enforce data governance policies across your organization.", + "resourceType": "Video", + "categories": ["DataGovernance"], + "source": { + "url": "https://www.youtube.com/watch?v=d6GEwZ-FGsw", + "provider": "YouTube" + }, + "contexts": [ + {"pageId": "governanceWorkflows"}, + {"pageId": "domain"}, + {"pageId": "dataProduct"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_Lineage.json b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_Lineage.json new file mode 100644 index 000000000000..426af88fb2a8 --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_Lineage.json @@ -0,0 +1,15 @@ +{ + "name": "CollateClues_Lineage", + "displayName": "Collate Clues: Lineage", + "description": "Understand data lineage visualization and how to trace data flow across your organization.", + "resourceType": "Video", + "categories": ["DataGovernance", "Discovery"], + "source": { + "url": "https://www.youtube.com/watch?v=21mk-I1H5Xo", + "provider": "YouTube" + }, + "contexts": [ + {"pageId": "lineage"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_ReverseMetadata.json b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_ReverseMetadata.json new file mode 100644 index 000000000000..e05fe0b3c1ea --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_ReverseMetadata.json @@ -0,0 +1,16 @@ +{ + "name": "CollateClues_ReverseMetadata", + "displayName": "Collate Clues: Reverse Metadata", + "description": "Discover how Reverse Metadata pushes enriched metadata back to your data sources, keeping everything in sync.", + "resourceType": "Video", + "categories": ["Administration", "Discovery"], + "source": { + "url": "https://www.youtube.com/watch?v=uXnI-rKu12A", + "provider": "YouTube" + }, + "contexts": [ + {"pageId": "table"}, + {"pageId": "explore"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_RolesAndPolicies.json b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_RolesAndPolicies.json new file mode 100644 index 000000000000..1b7d46630adc --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_RolesAndPolicies.json @@ -0,0 +1,17 @@ +{ + "name": "CollateClues_RolesAndPolicies", + "displayName": "Collate Clues: Roles & Policies", + "description": "Learn how to configure access control with Roles and Policies to secure your data assets.", + "resourceType": "Video", + "categories": ["Administration", "DataGovernance"], + "source": { + "url": "https://www.youtube.com/watch?v=Qy28FaNgkgM", + "provider": "YouTube" + }, + "contexts": [ + {"pageId": "roles"}, + {"pageId": "policies"}, + {"pageId": "accessControl"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_Services.json b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_Services.json new file mode 100644 index 000000000000..6e5d387316b2 --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/CollateClues_Services.json @@ -0,0 +1,21 @@ +{ + "name": "CollateClues_Services", + "displayName": "Collate Clues: Services Setup", + "description": "Quick guide to setting up and configuring services in Collate to connect your data sources.", + "resourceType": "Video", + "categories": ["Administration"], + "source": { + "url": "https://www.youtube.com/watch?v=SCqAnJeViCE", + "provider": "YouTube" + }, + "contexts": [ + {"pageId": "services"}, + {"pageId": "databaseServices"}, + {"pageId": "messagingServices"}, + {"pageId": "dashboardServices"}, + {"pageId": "pipelineServices"}, + {"pageId": "mlmodelServices"}, + {"pageId": "storageServices"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/Demo_ConnectorAgents.json b/openmetadata-service/src/main/resources/json/data/learningResource/Demo_ConnectorAgents.json new file mode 100644 index 000000000000..2caea0bcdfd1 --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/Demo_ConnectorAgents.json @@ -0,0 +1,18 @@ +{ + "name": "Demo_ConnectorAgents", + "displayName": "Connector Agents Overview", + "description": "Learn about the variety of connector agents that Collate uses to automate and simplify your work. Get an overview of Metadata Agents and AI Agents designed to help teams automate workflows.", + "resourceType": "Storylane", + "categories": ["Discovery", "Administration"], + "difficulty": "Intro", + "source": { + "url": "https://collate.storylane.io/demo/yquttvft7hxj", + "provider": "Collate" + }, + "estimatedDuration": 300, + "contexts": [ + {"pageId": "explore"}, + {"pageId": "pipeline"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/Demo_DataContracts.json b/openmetadata-service/src/main/resources/json/data/learningResource/Demo_DataContracts.json new file mode 100644 index 000000000000..1c7541b3683c --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/Demo_DataContracts.json @@ -0,0 +1,19 @@ +{ + "name": "Demo_DataContracts", + "displayName": "Data Contracts", + "description": "Learn how Data Contracts help align consumers and producers of data. This demo covers creating contracts, understanding contract segments, and reviewing completion records with run history.", + "resourceType": "Storylane", + "categories": ["DataGovernance", "DataQuality"], + "difficulty": "Intermediate", + "source": { + "url": "https://collate.storylane.io/demo/mhr2lbg0jse6", + "provider": "Collate" + }, + "estimatedDuration": 300, + "contexts": [ + {"pageId": "table"}, + {"pageId": "dataQuality"}, + {"pageId": "glossary"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/Demo_DataQualityBasics.json b/openmetadata-service/src/main/resources/json/data/learningResource/Demo_DataQualityBasics.json new file mode 100644 index 000000000000..9637f727457b --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/Demo_DataQualityBasics.json @@ -0,0 +1,18 @@ +{ + "name": "Demo_DataQualityBasics", + "displayName": "Data Quality Basics", + "description": "Learn how Collate's data observability catches quality issues before they impact your business. This demo covers establishing data quality tests, constructing pipelines, and configuring schedules to evaluate data freshness metrics.", + "resourceType": "Storylane", + "categories": ["DataQuality", "Observability"], + "difficulty": "Intro", + "source": { + "url": "https://collate.storylane.io/demo/c8dmf6yddhkn", + "provider": "Collate" + }, + "estimatedDuration": 300, + "contexts": [ + {"pageId": "table"}, + {"pageId": "dataQuality"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/Demo_GettingStarted.json b/openmetadata-service/src/main/resources/json/data/learningResource/Demo_GettingStarted.json new file mode 100644 index 000000000000..94f0d45ded17 --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/Demo_GettingStarted.json @@ -0,0 +1,17 @@ +{ + "name": "Demo_GettingStarted", + "displayName": "Getting Started with Collate", + "description": "Learn how to get your Collate instance set up in just a couple minutes. This demo covers setting up a Snowflake connector, using AI agents for automatic documentation, and viewing entity relationship diagrams.", + "resourceType": "Storylane", + "categories": ["Discovery", "Administration"], + "difficulty": "Intro", + "source": { + "url": "https://collate.storylane.io/demo/dlumjorwxy9m", + "provider": "Collate" + }, + "estimatedDuration": 300, + "contexts": [ + {"pageId": "explore"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/Demo_LineageBasics.json b/openmetadata-service/src/main/resources/json/data/learningResource/Demo_LineageBasics.json new file mode 100644 index 000000000000..4645d896c485 --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/Demo_LineageBasics.json @@ -0,0 +1,20 @@ +{ + "name": "Demo_LineageBasics", + "displayName": "Lineage Basics", + "description": "Discover how Collate streamlines data governance by providing robust tracing of your data lineage. Learn foundational concepts, configure the Lineage Agent, and navigate table and column-level data lineage visibility.", + "resourceType": "Storylane", + "categories": ["DataGovernance", "Discovery"], + "difficulty": "Intro", + "source": { + "url": "https://collate.storylane.io/demo/vincstg0kjcg", + "provider": "Collate" + }, + "estimatedDuration": 300, + "contexts": [ + {"pageId": "table"}, + {"pageId": "pipeline"}, + {"pageId": "dashboard"}, + {"pageId": "glossary"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/Video_BigQueryIntegration.json b/openmetadata-service/src/main/resources/json/data/learningResource/Video_BigQueryIntegration.json new file mode 100644 index 000000000000..753bdfcb2478 --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/Video_BigQueryIntegration.json @@ -0,0 +1,18 @@ +{ + "name": "Video_BigQueryIntegration", + "displayName": "Connecting BigQuery to Collate", + "description": "Learn how to connect your BigQuery database to Collate's AI-powered platform. Unlock Google's data ecosystem with seamless integration.", + "resourceType": "Video", + "categories": ["Discovery", "Administration"], + "difficulty": "Intermediate", + "source": { + "url": "https://www.youtube.com/watch?v=aOaWniA9K40", + "provider": "YouTube" + }, + "estimatedDuration": 600, + "contexts": [ + {"pageId": "table"}, + {"pageId": "explore"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/main/resources/json/data/learningResource/Video_SnowflakeIntegration.json b/openmetadata-service/src/main/resources/json/data/learningResource/Video_SnowflakeIntegration.json new file mode 100644 index 000000000000..2e6279a5e3e5 --- /dev/null +++ b/openmetadata-service/src/main/resources/json/data/learningResource/Video_SnowflakeIntegration.json @@ -0,0 +1,18 @@ +{ + "name": "Video_SnowflakeIntegration", + "displayName": "Connecting Snowflake to Collate", + "description": "Learn how to connect your Snowflake database to Collate's AI-powered platform. This video covers reverse metadata capability and usage analytics including query execution times and costs.", + "resourceType": "Video", + "categories": ["Discovery", "Administration"], + "difficulty": "Intermediate", + "source": { + "url": "https://www.youtube.com/watch?v=EfycmnM_hPs", + "provider": "YouTube" + }, + "estimatedDuration": 600, + "contexts": [ + {"pageId": "table"}, + {"pageId": "explore"} + ], + "status": "Active" +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/EntityResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/EntityResourceTest.java index 5f7301727200..7c6da3d8aa00 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/EntityResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/EntityResourceTest.java @@ -329,7 +329,7 @@ public abstract class EntityResourceTest { + test.beforeEach(async ({ page }) => { + await redirectToHomePage(page); + await settingClick(page, GlobalSettingOptions.LEARNING_RESOURCES); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); + // Wait for the table to be fully loaded + await page.waitForSelector('.ant-table-tbody'); + }); + + test('should display learning resources page', async ({ page }) => { + await expect(page.getByTestId('learning-resources-page')).toBeVisible(); + await expect(page.getByTestId('page-title')).toContainText('Learning Resource'); + await expect(page.getByTestId('create-resource')).toBeVisible(); + }); + + test('should open and close add resource drawer', async ({ page }) => { + await test.step('Open add resource drawer', async () => { + await page.getByTestId('create-resource').click(); + await expect(page.locator('.drawer-title')).toContainText('Add Resource'); + }); + + await test.step('Close drawer', async () => { + await page.locator('.drawer-close').click(); + await expect(page.locator('.drawer-title')).not.toBeVisible(); + }); + }); + + test('should validate required fields', async ({ page }) => { + await page.getByTestId('create-resource').click(); + await expect(page.locator('.drawer-title')).toBeVisible(); + + // Try to submit without filling required fields + await page.getByTestId('save-resource').click(); + + // Expect validation errors to appear + await expect(page.locator('.ant-form-item-explain-error').first()).toBeVisible(); + + // Close drawer + await page.locator('.drawer-close').click(); + }); + + test('should edit an existing learning resource', async ({ page }) => { + const { apiContext, afterAction } = await getApiContext(page); + const uniqueId = uuid(); + const resource = new LearningResourceClass({ + name: `PW_Edit_Resource_${uniqueId}`, + displayName: `PW Edit Resource ${uniqueId}`, + description: 'Resource to be edited', + }); + + await resource.create(apiContext); + + // Reload to get fresh data after creating resource + await page.reload(); + await page.waitForSelector('.ant-table-tbody'); + + // Search for the resource to find it + await searchResource(page, uniqueId); + await expect(page.getByText(resource.data.displayName ?? '')).toBeVisible({ timeout: 10000 }); + + await test.step('Click edit button and verify drawer opens', async () => { + await page.getByTestId(`edit-${resource.data.name}`).click(); + await expect(page.locator('.drawer-title')).toContainText('Edit Resource'); + // Verify the form is populated with resource data + await expect(page.locator('#name')).toHaveValue(resource.data.name); + }); + + await test.step('Close the drawer', async () => { + await page.locator('.drawer-close').click(); + await expect(page.locator('.drawer-title')).not.toBeVisible(); + }); + + await resource.delete(apiContext); + await afterAction(); + }); + + test('should delete a learning resource', async ({ page }) => { + const { apiContext, afterAction } = await getApiContext(page); + const uniqueId = uuid(); + const resource = new LearningResourceClass({ + name: `PW_Delete_Resource_${uniqueId}`, + displayName: `PW Delete Resource ${uniqueId}`, + }); + + await resource.create(apiContext); + + // Reload to get fresh data after creating resource + await page.reload(); + await page.waitForSelector('.ant-table-tbody'); + + // Search for the resource to find it + await searchResource(page, uniqueId); + await expect(page.getByText(resource.data.displayName ?? '')).toBeVisible({ timeout: 10000 }); + + await test.step('Click delete button and confirm', async () => { + await page.getByTestId(`delete-${resource.data.name}`).click(); + // Wait for the confirmation modal to appear + await expect(page.locator('.ant-modal-confirm')).toBeVisible({ timeout: 5000 }); + // Click the OK/Delete button in the modal + await page.locator('.ant-modal-confirm-btns button').filter({ hasText: /delete|ok/i }).click(); + }); + + await test.step('Verify resource is removed from list', async () => { + // Wait for modal to close and table to update + await expect(page.locator('.ant-modal-confirm')).not.toBeVisible({ timeout: 5000 }); + await page.waitForTimeout(500); + await expect(page.getByText(resource.data.displayName ?? '')).not.toBeVisible(); + }); + + await afterAction(); + }); + + test('should preview a learning resource by clicking on name', async ({ page }) => { + const { apiContext, afterAction } = await getApiContext(page); + const uniqueId = uuid(); + const resource = new LearningResourceClass({ + name: `PW_Preview_Resource_${uniqueId}`, + displayName: `PW Preview Resource ${uniqueId}`, + source: { + url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + provider: 'YouTube', + }, + }); + + await resource.create(apiContext); + + // Reload to get fresh data after creating resource + await page.reload(); + await page.waitForSelector('.ant-table-tbody'); + + // Search for the resource to find it + await searchResource(page, uniqueId); + await expect(page.getByText(resource.data.displayName ?? '')).toBeVisible({ timeout: 10000 }); + + await test.step('Click on resource name to preview', async () => { + await page.getByText(resource.data.displayName ?? '').click(); + }); + + await test.step('Verify preview modal opens', async () => { + await expect(page.locator('.ant-modal')).toBeVisible(); + }); + + await test.step('Close preview modal', async () => { + // Close button is in the modal header with class 'close-button' + await page.locator('.close-button').click(); + await expect(page.locator('.ant-modal')).not.toBeVisible({ timeout: 5000 }); + }); + + await resource.delete(apiContext); + await afterAction(); + }); + + test('should filter resources by type', async ({ page }) => { + const { apiContext, afterAction } = await getApiContext(page); + const uniqueId = uuid(); + const videoResource = new LearningResourceClass({ + name: `PW_Video_Resource_${uniqueId}`, + displayName: `PW Video Resource ${uniqueId}`, + resourceType: 'Video', + }); + + await videoResource.create(apiContext); + + // Reload to get fresh data + await page.reload(); + await page.waitForSelector('.ant-table-tbody'); + + await test.step('Filter by Video type', async () => { + await page.locator('.filter-select').filter({ hasText: 'Type' }).click(); + await selectDropdownOption(page, 'Video'); + + // Search for our specific resource + await searchResource(page, uniqueId); + await expect(page.getByText(`PW Video Resource ${uniqueId}`)).toBeVisible({ timeout: 10000 }); + }); + + await videoResource.delete(apiContext); + await afterAction(); + }); + + test('should search resources by name', async ({ page }) => { + const { apiContext, afterAction } = await getApiContext(page); + const uniqueId = uuid(); + const resource = new LearningResourceClass({ + name: `PW_Search_Resource_${uniqueId}`, + displayName: `PW Search Resource ${uniqueId}`, + }); + + await resource.create(apiContext); + + // Reload to get fresh data + await page.reload(); + await page.waitForSelector('.ant-table-tbody'); + + await test.step('Search for resource', async () => { + await searchResource(page, uniqueId); + await expect(page.getByText(`PW Search Resource ${uniqueId}`)).toBeVisible({ timeout: 10000 }); + }); + + await resource.delete(apiContext); + await afterAction(); + }); +}); + +test.describe('Learning Resources Admin Page - Additional Tests', () => { + test.beforeEach(async ({ page }) => { + await redirectToHomePage(page); + await settingClick(page, GlobalSettingOptions.LEARNING_RESOURCES); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); + await page.waitForSelector('.ant-table-tbody'); + }); + + test('should filter resources by category', async ({ page }) => { + const { apiContext, afterAction } = await getApiContext(page); + const uniqueId = uuid(); + const resource = new LearningResourceClass({ + name: `PW_Category_Resource_${uniqueId}`, + displayName: `PW Category Resource ${uniqueId}`, + categories: ['DataGovernance'], + }); + + await resource.create(apiContext); + + await page.reload(); + await page.waitForSelector('.ant-table-tbody'); + + await test.step('Filter by Governance category', async () => { + await page.locator('.filter-select').filter({ hasText: 'Category' }).click(); + await selectDropdownOption(page, 'Governance'); + + await searchResource(page, uniqueId); + await expect(page.getByText(`PW Category Resource ${uniqueId}`)).toBeVisible({ timeout: 10000 }); + }); + + await resource.delete(apiContext); + await afterAction(); + }); + + test('should filter resources by status', async ({ page }) => { + const { apiContext, afterAction } = await getApiContext(page); + const uniqueId = uuid(); + const resource = new LearningResourceClass({ + name: `PW_Status_Resource_${uniqueId}`, + displayName: `PW Status Resource ${uniqueId}`, + status: 'Draft', + }); + + await resource.create(apiContext); + + await page.reload(); + await page.waitForSelector('.ant-table-tbody'); + + await test.step('Filter by Draft status', async () => { + await page.locator('.filter-select').filter({ hasText: 'Status' }).click(); + await selectDropdownOption(page, 'Draft'); + + await searchResource(page, uniqueId); + await expect(page.getByText(`PW Status Resource ${uniqueId}`)).toBeVisible({ timeout: 10000 }); + }); + + await resource.delete(apiContext); + await afterAction(); + }); + + test('should edit and save resource changes via UI', async ({ page }) => { + const { apiContext, afterAction } = await getApiContext(page); + const uniqueId = uuid(); + const resource = new LearningResourceClass({ + name: `PW_Edit_Save_Resource_${uniqueId}`, + displayName: `PW Edit Save Resource ${uniqueId}`, + description: 'Original description', + }); + + await resource.create(apiContext); + + await page.reload(); + await page.waitForSelector('.ant-table-tbody'); + + await searchResource(page, uniqueId); + await expect(page.getByText(resource.data.displayName ?? '')).toBeVisible({ timeout: 10000 }); + + await test.step('Open edit drawer and modify display name', async () => { + await page.getByTestId(`edit-${resource.data.name}`).click(); + await expect(page.locator('.drawer-title')).toContainText('Edit Resource'); + + // Clear and update display name + await page.locator('#displayName').clear(); + await page.locator('#displayName').fill(`Updated Resource ${uniqueId}`); + }); + + await test.step('Save changes', async () => { + await page.getByTestId('save-resource').click(); + await expect(page.locator('.drawer-title')).not.toBeVisible({ timeout: 10000 }); + }); + + await test.step('Verify updated display name in list', async () => { + await page.reload(); + await page.waitForSelector('.ant-table-tbody'); + await searchResource(page, uniqueId); + await expect(page.getByText(`Updated Resource ${uniqueId}`)).toBeVisible({ timeout: 10000 }); + }); + + await resource.delete(apiContext); + await afterAction(); + }); +}); + +test.describe('Learning Icon on Pages', () => { + test('should display learning icon on glossary page when resources exist', async ({ page }) => { + // Navigate to home first to ensure auth context is established + await redirectToHomePage(page); + + const { apiContext, afterAction } = await getApiContext(page); + const resource = new LearningResourceClass({ + name: `PW_Glossary_Icon_Resource_${uuid()}`, + displayName: `PW Glossary Icon Resource`, + contexts: [{ pageId: 'glossary' }], + status: 'Active', + }); + + await resource.create(apiContext); + + await page.goto('/glossary'); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); + + const learningIcon = page.locator('[data-testid="learning-icon"]'); + await expect(learningIcon).toBeVisible({ timeout: 10000 }); + + await resource.delete(apiContext); + await afterAction(); + }); + + test('should open learning drawer when icon is clicked', async ({ page }) => { + // Navigate to home first to ensure auth context is established + await redirectToHomePage(page); + + const { apiContext, afterAction } = await getApiContext(page); + const resource = new LearningResourceClass({ + name: `PW_Glossary_Drawer_Resource_${uuid()}`, + displayName: `PW Glossary Drawer Resource`, + contexts: [{ pageId: 'glossary' }], + status: 'Active', + }); + + await resource.create(apiContext); + + await page.goto('/glossary'); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); + + await test.step('Click learning icon', async () => { + const learningIcon = page.locator('[data-testid="learning-icon"]'); + await expect(learningIcon).toBeVisible({ timeout: 10000 }); + await learningIcon.click(); + }); + + await test.step('Verify drawer opens with resources', async () => { + await expect(page.locator('.learning-drawer')).toBeVisible(); + }); + + await test.step('Close drawer', async () => { + await page.keyboard.press('Escape'); + }); + + await resource.delete(apiContext); + await afterAction(); + }); + + test('should NOT show draft resources on target pages', async ({ page }) => { + // Navigate to home first to ensure auth context is established + await redirectToHomePage(page); + + const { apiContext, afterAction } = await getApiContext(page); + const uniqueId = uuid(); + const draftResource = new LearningResourceClass({ + name: `PW_Draft_Resource_${uniqueId}`, + displayName: `PW Draft Resource ${uniqueId}`, + contexts: [{ pageId: 'glossary' }], + status: 'Draft', // Draft status should NOT appear on pages + }); + + await draftResource.create(apiContext); + + await page.goto('/glossary'); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); + await page.waitForTimeout(2000); + + // Check if learning icon exists + const learningIcon = page.locator('[data-testid="learning-icon"]'); + const isIconVisible = await learningIcon.isVisible().catch(() => false); + + if (isIconVisible) { + // If icon is visible, our draft resource should NOT be in the drawer + await learningIcon.click(); + await expect(page.locator('.learning-drawer')).toBeVisible(); + await expect(page.getByText(`PW Draft Resource ${uniqueId}`)).not.toBeVisible(); + await page.keyboard.press('Escape'); + } + // If icon is not visible, that's also valid (no active resources) + + await draftResource.delete(apiContext); + await afterAction(); + }); + + test('should show learning icon on lineage page when resources exist', async ({ page }) => { + // Navigate to home first to ensure auth context is established + await redirectToHomePage(page); + + const { apiContext, afterAction } = await getApiContext(page); + const resource = new LearningResourceClass({ + name: `PW_Lineage_Resource_${uuid()}`, + displayName: `PW Lineage Resource`, + contexts: [{ pageId: 'lineage' }], + status: 'Active', + }); + + await resource.create(apiContext); + + await page.goto('/lineage'); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); + + const learningIcon = page.locator('[data-testid="learning-icon"]'); + await expect(learningIcon).toBeVisible({ timeout: 15000 }); + + // Click and verify resource is shown + await learningIcon.click(); + await expect(page.locator('.learning-drawer')).toBeVisible(); + await expect(page.getByText('PW Lineage Resource')).toBeVisible(); + await page.keyboard.press('Escape'); + + await resource.delete(apiContext); + await afterAction(); + }); + + test('should open resource player when clicking on resource card in drawer', async ({ page }) => { + // Navigate to home first to ensure auth context is established + await redirectToHomePage(page); + + const { apiContext, afterAction } = await getApiContext(page); + const uniqueId = uuid(); + const resource = new LearningResourceClass({ + name: `PW_Player_Resource_${uniqueId}`, + displayName: `PW Player Resource ${uniqueId}`, + contexts: [{ pageId: 'glossary' }], + status: 'Active', + source: { + url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + provider: 'YouTube', + }, + }); + + await resource.create(apiContext); + + await page.goto('/glossary'); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); + + await test.step('Open learning drawer', async () => { + const learningIcon = page.locator('[data-testid="learning-icon"]'); + await expect(learningIcon).toBeVisible({ timeout: 15000 }); + await learningIcon.click(); + await expect(page.locator('.learning-drawer')).toBeVisible(); + }); + + await test.step('Click on resource card to open player', async () => { + // Click on the resource card + await page.getByText(`PW Player Resource ${uniqueId}`).click(); + // Verify player modal opens + await expect(page.locator('.ant-modal')).toBeVisible({ timeout: 10000 }); + }); + + await test.step('Close player modal', async () => { + await page.locator('.close-button').click(); + await expect(page.locator('.ant-modal')).not.toBeVisible({ timeout: 5000 }); + }); + + await resource.delete(apiContext); + await afterAction(); + }); +}); + +test.describe.serial('Learning Resources E2E Flow', () => { + test('should create resource via UI and verify learning icon appears on target page', async ({ page }) => { + const uniqueId = uuid(); + const resourceName = `PW_Create_E2E_${uniqueId}`; + + await test.step('Navigate to Learning Resources admin page', async () => { + await redirectToHomePage(page); + await settingClick(page, GlobalSettingOptions.LEARNING_RESOURCES); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); + await page.waitForSelector('.ant-table-tbody', { timeout: 30000 }); + }); + + await test.step('Open add resource drawer and fill form', async () => { + await page.getByTestId('create-resource').click(); + await expect(page.locator('.drawer-title')).toContainText('Add Resource'); + + // Fill required fields + await page.locator('#name').fill(resourceName); + await page.locator('#displayName').fill(`E2E Test Resource ${uniqueId}`); + await page.locator('textarea#description').fill('E2E test learning resource'); + + // Select type + await page.getByTestId('resource-type-form-item').locator('.ant-select-selector').click(); + await selectDropdownOption(page, 'Video'); + + // Select category + await page.getByTestId('categories-form-item').locator('.ant-select-selector').click(); + await selectDropdownOption(page, 'Discovery'); + await page.keyboard.press('Escape'); + + // Select context - Glossary page + await page.getByTestId('contexts-form-item').locator('.ant-select-selector').click(); + await selectDropdownOption(page, 'Glossary'); + await page.keyboard.press('Escape'); + + // Fill source URL + await page.locator('#sourceUrl').fill('https://www.youtube.com/watch?v=test123'); + + // Set status to Active + await page.locator('.ant-form-item').filter({ hasText: 'Status' }).locator('.ant-select-selector').click(); + await selectDropdownOption(page, 'Active'); + }); + + await test.step('Save the resource', async () => { + await page.getByTestId('save-resource').click(); + // Wait for drawer to close indicating success + await expect(page.locator('.drawer-title')).not.toBeVisible({ timeout: 10000 }); + }); + + await test.step('Navigate to Glossary page and verify learning icon appears', async () => { + await page.goto('/glossary'); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); + + const learningIcon = page.locator('[data-testid="learning-icon"]'); + await expect(learningIcon).toBeVisible({ timeout: 15000 }); + }); + + await test.step('Click learning icon and verify the created resource is shown', async () => { + await page.locator('[data-testid="learning-icon"]').click(); + await expect(page.locator('.learning-drawer')).toBeVisible(); + await expect(page.getByText(`E2E Test Resource ${uniqueId}`)).toBeVisible(); + await page.keyboard.press('Escape'); + }); + + await test.step('Cleanup - delete the created resource', async () => { + // Navigate back to Learning Resources admin page + await page.goto('/settings/preferences/learning-resources'); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached', timeout: 30000 }); + await page.waitForSelector('.ant-table-tbody', { timeout: 30000 }); + + await searchResource(page, uniqueId); + await expect(page.getByText(`E2E Test Resource ${uniqueId}`)).toBeVisible({ timeout: 10000 }); + + await page.getByTestId(`delete-${resourceName}`).click(); + await expect(page.locator('.ant-modal-confirm')).toBeVisible({ timeout: 5000 }); + await page.locator('.ant-modal-confirm-btns button').filter({ hasText: /delete|ok/i }).click(); + await expect(page.locator('.ant-modal-confirm')).not.toBeVisible({ timeout: 5000 }); + }); + }); + + test('should update resource context and verify learning icon moves to new page', async ({ page }) => { + // Navigate to home first to ensure auth context is established + await redirectToHomePage(page); + + const { apiContext, afterAction } = await getApiContext(page); + const uniqueId = uuid(); + const resource = new LearningResourceClass({ + name: `PW_Update_Context_${uniqueId}`, + displayName: `Update Context Resource ${uniqueId}`, + contexts: [{ pageId: 'glossary' }], + status: 'Active', + }); + + const createdResource = await resource.create(apiContext); + expect(createdResource, `Failed to create resource: ${JSON.stringify(createdResource)}`).toBeDefined(); + expect(createdResource.id, `Resource ID is undefined: ${JSON.stringify(createdResource)}`).toBeDefined(); + expect(createdResource.displayName).toBe(`Update Context Resource ${uniqueId}`); + + await test.step('Verify resource appears on Glossary page initially', async () => { + await page.goto('/glossary'); + await page.waitForLoadState('networkidle'); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached', timeout: 30000 }); + + const learningIcon = page.locator('[data-testid="learning-icon"]'); + await expect(learningIcon).toBeVisible({ timeout: 20000 }); + + // Verify our resource is in the drawer + await learningIcon.click(); + await expect(page.locator('.learning-drawer')).toBeVisible(); + await expect(page.getByText(`Update Context Resource ${uniqueId}`)).toBeVisible({ timeout: 10000 }); + await page.keyboard.press('Escape'); + }); + + await test.step('Navigate to admin page and update resource context to Lineage', async () => { + await page.goto('/settings/preferences/learning-resources'); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached', timeout: 30000 }); + await page.waitForSelector('.ant-table-tbody', { timeout: 30000 }); + + await searchResource(page, uniqueId); + await expect(page.getByText(`Update Context Resource ${uniqueId}`)).toBeVisible({ timeout: 10000 }); + + // Click edit button + await page.getByTestId(`edit-${resource.data.name}`).click(); + await expect(page.locator('.drawer-title')).toContainText('Edit Resource'); + + // Clear existing contexts and add new one - Lineage + await page.getByTestId('contexts-form-item').locator('.ant-select-selection-item-remove').click(); + await page.getByTestId('contexts-form-item').locator('.ant-select-selector').click(); + await selectDropdownOption(page, 'Lineage'); + await page.keyboard.press('Escape'); + + // Save changes + await page.getByTestId('save-resource').click(); + await expect(page.locator('.drawer-title')).not.toBeVisible({ timeout: 10000 }); + }); + + await test.step('Verify learning icon no longer appears on Glossary page', async () => { + await page.goto('/glossary'); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); + await page.waitForTimeout(2000); // Give time for API to respond + + // The learning icon should not be visible or should not show our resource + const learningIcon = page.locator('[data-testid="learning-icon"]'); + const isIconVisible = await learningIcon.isVisible().catch(() => false); + + if (isIconVisible) { + // If icon is visible, our resource should not be in the drawer + await learningIcon.click(); + await expect(page.locator('.learning-drawer')).toBeVisible(); + await expect(page.getByText(`Update Context Resource ${uniqueId}`)).not.toBeVisible(); + await page.keyboard.press('Escape'); + } + }); + + await test.step('Verify learning icon now appears on Lineage page', async () => { + await page.goto('/lineage'); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); + + const learningIcon = page.locator('[data-testid="learning-icon"]'); + await expect(learningIcon).toBeVisible({ timeout: 15000 }); + + // Verify our resource is in the drawer + await learningIcon.click(); + await expect(page.locator('.learning-drawer')).toBeVisible(); + await expect(page.getByText(`Update Context Resource ${uniqueId}`)).toBeVisible(); + await page.keyboard.press('Escape'); + }); + + await resource.delete(apiContext); + await afterAction(); + }); + + test('should delete resource and verify learning icon disappears from target page', async ({ page }) => { + // Navigate to home first to ensure auth context is established + await redirectToHomePage(page); + + const { apiContext, afterAction } = await getApiContext(page); + const uniqueId = uuid(); + const resource = new LearningResourceClass({ + name: `PW_Delete_E2E_${uniqueId}`, + displayName: `Delete E2E Resource ${uniqueId}`, + contexts: [{ pageId: 'glossary' }], + status: 'Active', + }); + + const createdResource = await resource.create(apiContext); + expect(createdResource.id).toBeDefined(); + expect(createdResource.displayName).toBe(`Delete E2E Resource ${uniqueId}`); + + await test.step('Verify resource appears on Glossary page initially', async () => { + await page.goto('/glossary'); + await page.waitForLoadState('networkidle'); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached', timeout: 30000 }); + + const learningIcon = page.locator('[data-testid="learning-icon"]'); + await expect(learningIcon).toBeVisible({ timeout: 20000 }); + + // Verify our resource is in the drawer + await learningIcon.click(); + await expect(page.locator('.learning-drawer')).toBeVisible(); + await expect(page.getByText(`Delete E2E Resource ${uniqueId}`)).toBeVisible({ timeout: 10000 }); + await page.keyboard.press('Escape'); + }); + + await test.step('Navigate to admin page and delete the resource', async () => { + await page.goto('/settings/preferences/learning-resources'); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached', timeout: 30000 }); + await page.waitForSelector('.ant-table-tbody', { timeout: 30000 }); + + await searchResource(page, uniqueId); + await expect(page.getByText(`Delete E2E Resource ${uniqueId}`)).toBeVisible({ timeout: 10000 }); + + await page.getByTestId(`delete-${resource.data.name}`).click(); + await expect(page.locator('.ant-modal-confirm')).toBeVisible({ timeout: 5000 }); + await page.locator('.ant-modal-confirm-btns button').filter({ hasText: /delete|ok/i }).click(); + await expect(page.locator('.ant-modal-confirm')).not.toBeVisible({ timeout: 5000 }); + }); + + await test.step('Verify learning icon no longer shows deleted resource on Glossary page', async () => { + await page.goto('/glossary'); + await page.waitForSelector('[data-testid="loader"]', { state: 'detached' }); + await page.waitForTimeout(2000); // Give time for API to respond + + const learningIcon = page.locator('[data-testid="learning-icon"]'); + const isIconVisible = await learningIcon.isVisible().catch(() => false); + + if (isIconVisible) { + // If icon is visible, our deleted resource should not be in the drawer + await learningIcon.click(); + await expect(page.locator('.learning-drawer')).toBeVisible(); + await expect(page.getByText(`Delete E2E Resource ${uniqueId}`)).not.toBeVisible(); + await page.keyboard.press('Escape'); + } + // If icon is not visible at all, that's also valid (no resources for glossary) + }); + + await afterAction(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/learning/LearningResourceClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/learning/LearningResourceClass.ts new file mode 100644 index 000000000000..18e8320012aa --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/learning/LearningResourceClass.ts @@ -0,0 +1,123 @@ +/* + * Copyright 2024 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 { APIRequestContext } from '@playwright/test'; +import { uuid } from '../../utils/common'; + +type LearningResourceContext = { + pageId: string; + componentId?: string; +}; + +type LearningResourceSource = { + url: string; + provider?: string; + embedConfig?: Record; +}; + +type LearningResourceData = { + name: string; + displayName?: string; + description?: string; + resourceType: 'Article' | 'Video' | 'Storylane'; + categories: string[]; + difficulty?: 'Intro' | 'Intermediate' | 'Advanced'; + source: LearningResourceSource; + contexts: LearningResourceContext[]; + status?: 'Draft' | 'Active' | 'Deprecated'; + estimatedDuration?: number; +}; + +type ResponseDataType = LearningResourceData & { + id?: string; + fullyQualifiedName?: string; +}; + +export class LearningResourceClass { + id: string; + data: LearningResourceData; + responseData: ResponseDataType = {} as ResponseDataType; + + constructor(data?: Partial) { + this.id = uuid(); + this.data = { + name: `PW_LearningResource_${this.id}`, + displayName: `PW Learning Resource ${this.id}`, + description: 'Playwright test learning resource description', + resourceType: 'Video', + categories: ['Discovery'], + difficulty: 'Intro', + source: { + url: 'https://www.youtube.com/watch?v=test123', + provider: 'YouTube', + }, + contexts: [{ pageId: 'glossary' }], + status: 'Active', + ...data, + }; + } + + get() { + return this.responseData; + } + + async create(apiContext: APIRequestContext) { + const response = await apiContext.post('/api/v1/learning/resources', { + data: this.data, + }); + + if (!response.ok()) { + const errorText = await response.text(); + throw new Error( + `Failed to create learning resource: ${response.status()} ${errorText}` + ); + } + + const data = await response.json(); + this.responseData = data; + + return data; + } + + async patch( + apiContext: APIRequestContext, + patchData: Partial + ) { + const response = await apiContext.patch( + `/api/v1/learning/resources/${this.responseData.id}`, + { + data: [ + ...Object.entries(patchData).map(([key, value]) => ({ + op: 'replace', + path: `/${key}`, + value, + })), + ], + headers: { + 'Content-Type': 'application/json-patch+json', + }, + } + ); + const data = await response.json(); + this.responseData = data; + + return data; + } + + async delete(apiContext: APIRequestContext) { + const response = await apiContext.delete( + `/api/v1/learning/resources/${this.responseData.id}?hardDelete=true` + ); + + return await response.json(); + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/artical.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/artical.svg new file mode 100644 index 000000000000..37a1f0f65272 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/artical.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-learning.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-learning.svg new file mode 100644 index 000000000000..6acef7c8432d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-learning.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/learning-colored.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/learning-colored.svg new file mode 100644 index 000000000000..f55c0833e900 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/learning-colored.svg @@ -0,0 +1,4 @@ + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/story-lane.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/story-lane.svg new file mode 100644 index 000000000000..17925afacbfd --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/story-lane.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/video.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/video.svg new file mode 100644 index 000000000000..ae04987fa8e7 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/video.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/SettingsRouter.tsx b/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/SettingsRouter.tsx index 4fdfc487e73e..a8e730e135b8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/SettingsRouter.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/AppRouter/SettingsRouter.tsx @@ -39,6 +39,7 @@ import EditEmailConfigPage from '../../pages/EditEmailConfigPage/EditEmailConfig import EmailConfigSettingsPage from '../../pages/EmailConfigSettingsPage/EmailConfigSettingsPage.component'; import GlobalSettingCategoryPage from '../../pages/GlobalSettingPage/GlobalSettingCategory/GlobalSettingCategoryPage'; import GlobalSettingPage from '../../pages/GlobalSettingPage/GlobalSettingPage'; +import { LearningResourcesPage } from '../../pages/LearningResourcesPage/LearningResourcesPage'; import LineageConfigPage from '../../pages/LineageConfigPage/LineageConfigPage'; import NotificationListPage from '../../pages/NotificationListPage/NotificationListPage'; import OmHealthPage from '../../pages/OmHealth/OmHealthPage'; @@ -646,6 +647,17 @@ const SettingsRouter = () => { GlobalSettingOptions.OM_HEALTH )} /> + + + + } + path={getSettingPathRelative( + GlobalSettingsMenuCategory.PREFERENCES, + GlobalSettingOptions.LEARNING_RESOURCES + )} + /> } path={getSettingPathRelative(GlobalSettingsMenuCategory.SSO)} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx index 1491c3918f4b..d9cfdcd18d68 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx @@ -97,6 +97,7 @@ import RetentionPeriod from '../../Database/RetentionPeriod/RetentionPeriod.comp import { EntityStatusBadge } from '../../Entity/EntityStatusBadge/EntityStatusBadge.component'; import Voting from '../../Entity/Voting/Voting.component'; import { VotingDataProps } from '../../Entity/Voting/voting.interface'; +import { LearningIcon } from '../../Learning/LearningIcon/LearningIcon.component'; import MetricHeaderInfo from '../../Metric/MetricHeaderInfo/MetricHeaderInfo'; import SuggestionsAlert from '../../Suggestions/SuggestionsAlert/SuggestionsAlert'; import { useSuggestionsContext } from '../../Suggestions/SuggestionsProvider/SuggestionsProvider'; @@ -622,6 +623,7 @@ export const DataAssetsHeader = ({ isFollowingLoading={isFollowingLoading} name={dataAsset?.name} serviceName={dataAssetServiceName} + suffix={} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProduct/DataProductListPage.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProduct/DataProductListPage.tsx index 8cc88055d65e..76ddebf3e083 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProduct/DataProductListPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProduct/DataProductListPage.tsx @@ -132,6 +132,7 @@ const DataProductListPage = () => { createPermission: permissions.dataProduct?.Create || false, addButtonLabelKey: 'label.add-data-product', onAddClick: openDrawer, + learningPageId: 'dataProduct', }); const { titleAndCount } = useTitleAndCount({ diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx index 946113577cd9..2665f23b2515 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/DataProductsDetailsPage/DataProductsDetailsPage.component.tsx @@ -51,9 +51,7 @@ import { useCustomPages } from '../../../hooks/useCustomPages'; import { useFqn } from '../../../hooks/useFqn'; import { FeedCounts } from '../../../interface/feed.interface'; import { QueryFilterInterface } from '../../../pages/ExplorePage/ExplorePage.interface'; -import { - getDataProductPortsView, -} from '../../../rest/dataProductAPI'; +import { getDataProductPortsView } from '../../../rest/dataProductAPI'; import { getActiveAnnouncement } from '../../../rest/feedsAPI'; import { searchQuery } from '../../../rest/searchAPI'; import { @@ -100,6 +98,7 @@ import { EntityStatusBadge } from '../../Entity/EntityStatusBadge/EntityStatusBa import { EntityDetailsObjectInterface } from '../../Explore/ExplorePage.interface'; import { AssetsTabRef } from '../../Glossary/GlossaryTerms/tabs/AssetsTabs.component'; import { AssetsOfEntity } from '../../Glossary/GlossaryTerms/tabs/AssetsTabs.interface'; +import { LearningIcon } from '../../Learning/LearningIcon/LearningIcon.component'; import EntityDeleteModal from '../../Modals/EntityDeleteModal/EntityDeleteModal'; import EntityNameModal from '../../Modals/EntityNameModal/EntityNameModal.component'; import StyleModal from '../../Modals/StyleModal/StyleModal.component'; @@ -676,6 +675,7 @@ const DataProductsDetailsPage = ({ isFollowing={isFollowing} isFollowingLoading={isFollowingLoading} serviceName="" + suffix={} titleColor={dataProduct.style?.color} /> diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/InputOutputPortsTab/InputOutputPortsTab.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/InputOutputPortsTab/InputOutputPortsTab.component.tsx index bdf241ecb1d4..8409d25c6a85 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/InputOutputPortsTab/InputOutputPortsTab.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataProducts/InputOutputPortsTab/InputOutputPortsTab.component.tsx @@ -22,6 +22,7 @@ import { useTheme, } from '@mui/material'; import { ChevronDown, ChevronUp } from '@untitledui/icons'; +import { Button } from 'antd'; import { AxiosError } from 'axios'; import React, { forwardRef, @@ -48,7 +49,6 @@ import { } from './InputOutputPortsTab.types'; import { PortsLineageView } from './PortsLineageView'; import { PortsListView, PortsListViewRef } from './PortsListView'; -import { Button } from 'antd'; export const InputOutputPortsTab = forwardRef< InputOutputPortsTabRef, @@ -334,7 +334,7 @@ export const InputOutputPortsTab = forwardRef< {permissions.EditAll && !isInputPortsCollapsed && ( + + ); + + return ( + <> + + +

+ +
+ + + + + + ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningIcon/LearningIcon.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningIcon/LearningIcon.interface.ts new file mode 100644 index 000000000000..13bfd18000b2 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningIcon/LearningIcon.interface.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2024 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. + */ + +export interface LearningIconProps { + pageId: string; + title?: string; + className?: string; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningIcon/LearningIcon.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningIcon/LearningIcon.test.tsx new file mode 100644 index 000000000000..c8249b168c43 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningIcon/LearningIcon.test.tsx @@ -0,0 +1,191 @@ +/* + * Copyright 2024 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 { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { LearningIcon } from './LearningIcon.component'; + +const mockGetLearningResourcesByContext = jest.fn(); + +jest.mock('../../../rest/learningResourceAPI', () => ({ + getLearningResourcesByContext: jest + .fn() + .mockImplementation((...args) => + mockGetLearningResourcesByContext(...args) + ), +})); + +jest.mock('../LearningDrawer/LearningDrawer.component', () => ({ + LearningDrawer: jest.fn().mockImplementation(({ open, onClose }) => + open ? ( +
+ +
+ ) : null + ), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +describe('LearningIcon', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetLearningResourcesByContext.mockResolvedValue({ + data: [], + paging: { total: 5 }, + }); + }); + + it('should render learning icon when resources exist', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('learning-icon')).toBeInTheDocument(); + }); + }); + + it('should not render when resource count is 0', async () => { + mockGetLearningResourcesByContext.mockResolvedValue({ + data: [], + paging: { total: 0 }, + }); + + const { container } = render(); + + await waitFor(() => { + expect(mockGetLearningResourcesByContext).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(screen.queryByTestId('learning-icon')).not.toBeInTheDocument(); + }); + + expect(container.firstChild).toBeNull(); + }); + + it('should open drawer when icon is clicked', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('learning-icon')).toBeInTheDocument(); + }); + + const iconContainer = screen.getByTestId('learning-icon'); + + await act(async () => { + fireEvent.click(iconContainer); + }); + + expect(screen.getByTestId('learning-drawer')).toBeInTheDocument(); + }); + + it('should close drawer when close button is clicked', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('learning-icon')).toBeInTheDocument(); + }); + + const iconContainer = screen.getByTestId('learning-icon'); + + await act(async () => { + fireEvent.click(iconContainer); + }); + + expect(screen.getByTestId('learning-drawer')).toBeInTheDocument(); + + const closeButton = screen.getByTestId('close-drawer'); + + await act(async () => { + fireEvent.click(closeButton); + }); + + expect(screen.queryByTestId('learning-drawer')).not.toBeInTheDocument(); + }); + + it('should fetch resource count on mount', async () => { + render(); + + await waitFor(() => { + expect(mockGetLearningResourcesByContext).toHaveBeenCalledWith( + 'glossary', + { limit: 1 } + ); + }); + }); + + it('should not fetch resource count again if already fetched', async () => { + render(); + + await waitFor(() => { + expect(mockGetLearningResourcesByContext).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(screen.getByTestId('learning-icon')).toBeInTheDocument(); + }); + + expect(mockGetLearningResourcesByContext).toHaveBeenCalledTimes(1); + }); + + it('should handle API error gracefully', async () => { + mockGetLearningResourcesByContext.mockRejectedValueOnce( + new Error('API Error') + ); + + const { container } = render(); + + await waitFor(() => { + expect(mockGetLearningResourcesByContext).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(container.firstChild).toBeNull(); + }); + }); + + it('should apply custom className', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('learning-icon')).toBeInTheDocument(); + }); + + const badge = screen + .getByTestId('learning-icon') + .closest('.learning-icon-badge'); + + expect(badge).toHaveClass('custom-class'); + }); + + it('should display resource count in badge', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('learning-icon')).toBeInTheDocument(); + }); + + expect(screen.getByText('5')).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningIcon/learning-icon.less b/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningIcon/learning-icon.less new file mode 100644 index 000000000000..a4f2f2e7cdfa --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningIcon/learning-icon.less @@ -0,0 +1,76 @@ +/* + * Copyright 2024 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 (reference) '../../../styles/variables.less'; + +.learning-icon-container { + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + transform: scale(1.1); + } +} + +.ant-badge.learning-icon-badge { + cursor: pointer; + display: inline-flex; + align-items: center; + vertical-align: middle; + position: relative; + border-radius: 16px; + padding: 4px !important; + + .ant-badge-count { + background-color: @red-14; + font-size: 10px; + min-width: 16px; + height: 16px; + line-height: 16px; + padding: 0 4px; + box-shadow: 0 0 0 1px @white; + position: absolute !important; + top: -4px !important; + right: -6px !important; + transform: none !important; + } +} + +.learning-tooltip-popover { + .ant-popover-inner { + padding: 0; + border-radius: @border-rad-sm; + background-color: @grey-50; + } +} + +.learning-tooltip-content { + display: flex; + align-items: center; + gap: @size-sm; + + .learning-tooltip-text { + font-size: 13px; + white-space: nowrap; + } + + .learning-tooltip-button { + font-size: 12px; + border-radius: @border-rad-sm; + background-color: @white; + border: none; + color: @grey-700; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningResourceCard/LearningResourceCard.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningResourceCard/LearningResourceCard.component.tsx new file mode 100644 index 000000000000..b891d806f1f9 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningResourceCard/LearningResourceCard.component.tsx @@ -0,0 +1,169 @@ +/* + * Copyright 2024 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 { Tag, Typography } from 'antd'; +import classNames from 'classnames'; +import { DateTime } from 'luxon'; +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as ArticalIcon } from '../../../assets/svg/artical.svg'; +import { ReactComponent as StoryLaneIcon } from '../../../assets/svg/story-lane.svg'; +import { ReactComponent as VideoIcon } from '../../../assets/svg/video.svg'; +import { LEARNING_CATEGORIES } from '../Learning.interface'; +import './learning-resource-card.less'; +import { LearningResourceCardProps } from './LearningResourceCard.interface'; + +const { Text, Paragraph, Link } = Typography; + +const MAX_VISIBLE_TAGS = 3; + +export const LearningResourceCard: React.FC = ({ + resource, + onClick, +}) => { + const { t } = useTranslation(); + const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); + const [isDescriptionTruncated, setIsDescriptionTruncated] = useState(false); + + const resourceTypeIcon = useMemo(() => { + switch (resource.resourceType) { + case 'Video': + return ; + case 'Storylane': + return ; + case 'Article': + return ; + default: + return ; + } + }, [resource.resourceType]); + + const formattedDuration = useMemo(() => { + if (!resource.estimatedDuration) { + return null; + } + const minutes = Math.floor(resource.estimatedDuration / 60); + + return `${minutes} ${t('label.min-read')}`; + }, [resource.estimatedDuration, t]); + + const formattedDate = useMemo(() => { + if (!resource.updatedAt) { + return null; + } + + return DateTime.fromMillis(resource.updatedAt).toFormat('LLL dd, yyyy'); + }, [resource.updatedAt]); + + const categoryTags = useMemo(() => { + if (!resource.categories || resource.categories.length === 0) { + return { visible: [], remaining: 0 }; + } + + const visible = resource.categories.slice(0, MAX_VISIBLE_TAGS); + const remaining = resource.categories.length - MAX_VISIBLE_TAGS; + + return { visible, remaining }; + }, [resource.categories]); + + const getCategoryColor = (category: string) => { + const categoryInfo = + LEARNING_CATEGORIES[category as keyof typeof LEARNING_CATEGORIES]; + + return categoryInfo?.color ?? '#1890ff'; + }; + + const handleViewMoreClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsDescriptionExpanded(!isDescriptionExpanded); + }; + + return ( +
onClick?.(resource)}> +
+
+ {resourceTypeIcon} + + {resource.displayName || resource.name} + +
+ + {resource.description && ( +
+ + {resource.description} + + {(isDescriptionTruncated || isDescriptionExpanded) && ( + + {isDescriptionExpanded + ? t('label.view-less') + : t('label.view-more')} + + )} +
+ )} + +
+
+ {categoryTags.visible.map((category) => ( + + {LEARNING_CATEGORIES[ + category as keyof typeof LEARNING_CATEGORIES + ]?.label ?? category} + + ))} + {categoryTags.remaining > 0 && ( + + +{categoryTags.remaining} + + )} +
+ +
+ {formattedDate && ( + + {formattedDate} + + )} + {formattedDate && formattedDuration && ( + | + )} + {formattedDuration && ( + + {formattedDuration} + + )} +
+
+
+
+ ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningResourceCard/LearningResourceCard.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningResourceCard/LearningResourceCard.interface.ts new file mode 100644 index 000000000000..5d8085802378 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningResourceCard/LearningResourceCard.interface.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2024 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 { LearningResource } from '../../../rest/learningResourceAPI'; + +export interface LearningResourceCardProps { + resource: LearningResource; + onClick?: (resource: LearningResource) => void; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningResourceCard/LearningResourceCard.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningResourceCard/LearningResourceCard.test.tsx new file mode 100644 index 000000000000..4915d45560cc --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningResourceCard/LearningResourceCard.test.tsx @@ -0,0 +1,230 @@ +/* + * Copyright 2024 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 { fireEvent, render, screen } from '@testing-library/react'; +import { LearningResource } from '../../../rest/learningResourceAPI'; +import { LearningResourceCard } from './LearningResourceCard.component'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + 'label.min-read': 'min read', + 'label.view-more': 'View More', + 'label.view-less': 'View Less', + }; + + return translations[key] ?? key; + }, + }), +})); + +const mockVideoResource: LearningResource = { + id: 'video-resource-1', + name: 'TestVideoResource', + displayName: 'Test Video Resource', + description: 'A test video learning resource', + resourceType: 'Video', + categories: ['Discovery'], + difficulty: 'Intro', + estimatedDuration: 300, + source: { + url: 'https://youtube.com/watch?v=test', + provider: 'YouTube', + }, + contexts: [{ pageId: 'glossary' }], + status: 'Active', + fullyQualifiedName: 'TestVideoResource', + version: 0.1, + updatedAt: Date.now(), + updatedBy: 'admin', +}; + +const mockStorylaneResource: LearningResource = { + ...mockVideoResource, + id: 'storylane-resource-1', + name: 'TestStorylaneResource', + displayName: 'Test Storylane Resource', + resourceType: 'Storylane', +}; + +const mockArticleResource: LearningResource = { + ...mockVideoResource, + id: 'article-resource-1', + name: 'TestArticleResource', + displayName: 'Test Article Resource', + resourceType: 'Article', +}; + +const mockResourceWithMultipleCategories: LearningResource = { + ...mockVideoResource, + categories: ['Discovery', 'Administration', 'DataGovernance', 'DataQuality'], +}; + +describe('LearningResourceCard', () => { + it('should render card with resource display name', () => { + render(); + + expect(screen.getByText('Test Video Resource')).toBeInTheDocument(); + }); + + it('should render card with resource name when displayName is not provided', () => { + const resourceWithoutDisplayName = { + ...mockVideoResource, + displayName: undefined, + }; + render(); + + expect(screen.getByText('TestVideoResource')).toBeInTheDocument(); + }); + + it('should render resource description', () => { + render(); + + expect( + screen.getByText(/A test video learning resource/i) + ).toBeInTheDocument(); + }); + + it('should render description with ellipsis configuration', () => { + render(); + + const descriptionElement = screen.getByLabelText( + /A test video learning resource/i + ); + + expect(descriptionElement).toBeInTheDocument(); + expect(descriptionElement).toHaveClass('learning-resource-description'); + }); + + it('should render category tag', () => { + render(); + + expect(screen.getByText('Discovery')).toBeInTheDocument(); + }); + + it('should render formatted duration', () => { + render(); + + expect(screen.getByText('5 min read')).toBeInTheDocument(); + }); + + it('should not render duration when estimatedDuration is not provided', () => { + const resourceWithoutDuration = { + ...mockVideoResource, + estimatedDuration: undefined, + }; + render(); + + expect(screen.queryByText(/min read/)).not.toBeInTheDocument(); + }); + + it('should render play icon for Video resource type', () => { + render(); + + expect( + screen.getByTestId(`learning-resource-card-${mockVideoResource.name}`) + ).toBeInTheDocument(); + }); + + it('should render rocket icon for Storylane resource type', () => { + render(); + + expect( + screen.getByTestId(`learning-resource-card-${mockStorylaneResource.name}`) + ).toBeInTheDocument(); + }); + + it('should render file icon for Article resource type', () => { + render(); + + expect( + screen.getByTestId(`learning-resource-card-${mockArticleResource.name}`) + ).toBeInTheDocument(); + }); + + it('should call onClick when card is clicked', () => { + const mockOnClick = jest.fn(); + render( + + ); + + const card = screen.getByTestId( + `learning-resource-card-${mockVideoResource.name}` + ); + fireEvent.click(card); + + expect(mockOnClick).toHaveBeenCalledWith(mockVideoResource); + }); + + it('should not throw error when onClick is not provided', () => { + render(); + + const card = screen.getByTestId( + `learning-resource-card-${mockVideoResource.name}` + ); + + expect(() => fireEvent.click(card)).not.toThrow(); + }); + + it('should have correct data-testid attribute', () => { + render(); + + expect( + screen.getByTestId(`learning-resource-card-${mockVideoResource.name}`) + ).toBeInTheDocument(); + }); + + it('should be clickable when onClick is provided', () => { + const mockOnClick = jest.fn(); + render( + + ); + + const card = screen.getByTestId( + `learning-resource-card-${mockVideoResource.name}` + ); + + expect(card).toHaveClass('learning-resource-card-clickable'); + }); + + it('should show only first 3 categories and +N for remaining', () => { + render( + + ); + + // Categories are mapped to labels: Administration -> Admin, DataGovernance -> Governance + expect(screen.getByText('Discovery')).toBeInTheDocument(); + expect(screen.getByText('Admin')).toBeInTheDocument(); + expect(screen.getByText('Governance')).toBeInTheDocument(); + expect(screen.getByText('+1')).toBeInTheDocument(); + }); + + it('should not render description section when description is not provided', () => { + const resourceWithoutDescription = { + ...mockVideoResource, + description: undefined, + }; + render(); + + expect( + screen.queryByLabelText(/learning resource/i) + ).not.toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningResourceCard/learning-resource-card.less b/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningResourceCard/learning-resource-card.less new file mode 100644 index 000000000000..d0147d359397 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Learning/LearningResourceCard/learning-resource-card.less @@ -0,0 +1,140 @@ +/* + * Copyright 2024 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 (reference) '../../../styles/variables.less'; + +.learning-resource-card { + padding: @padding-md; + border-radius: @border-rad-sm; + background-color: @white; + transition: all 0.2s ease; + border: 1px solid @border-color-1; + + &.learning-resource-card-clickable { + cursor: pointer; + + &:hover { + box-shadow: @box-shadow-card-subtle; + border-color: @grey-300; + } + } + + .learning-resource-content { + display: flex; + flex-direction: column; + gap: @size-xs; + } + + .learning-resource-header { + display: flex; + align-items: center; + gap: @size-xs; + + .resource-type-icon { + font-size: 16px; + flex-shrink: 0; + margin-top: 2px; + + &.video-icon { + color: #1570ef; + } + + &.storylane-icon { + color: #7147e8; + } + + &.article-icon { + color: #17b26a; + } + } + + .learning-resource-title { + font-size: @font-size-base; + line-height: 20px; + color: @grey-900; + } + } + + .learning-resource-description-container { + display: flex; + flex-wrap: wrap; + gap: @size-xxs; + align-items: baseline; + } + + .learning-resource-description { + margin: 0; + font-size: 12px; + line-height: 18px; + color: @grey-600; + display: inline; + } + + .view-more-link { + font-size: 12px; + color: @primary-color; + cursor: pointer; + flex-shrink: 0; + + &:hover { + text-decoration: underline; + } + } + + .learning-resource-footer { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: @size-xs; + margin-top: @size-xxs; + } + + .learning-resource-tags { + display: flex; + flex-wrap: wrap; + gap: @size-xxs; + + .category-tag { + margin: 0; + font-size: 11px; + line-height: 16px; + padding: 2px 6px; + border-radius: @border-rad-base; + font-weight: @font-medium; + + &.more-tag { + background-color: @grey-100; + border-color: @grey-300; + color: @grey-600; + } + } + } + + .learning-resource-meta { + display: flex; + align-items: center; + gap: @size-xxs; + flex-shrink: 0; + + .meta-text { + font-size: 12px; + color: @grey-500; + } + + .meta-separator { + color: @grey-400; + font-size: 12px; + } + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/ArticleViewer.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/ArticleViewer.component.tsx new file mode 100644 index 000000000000..2ca8d81858db --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/ArticleViewer.component.tsx @@ -0,0 +1,114 @@ +/* + * Copyright 2024 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 { LinkOutlined } from '@ant-design/icons'; +import { Alert, Button, Spin } from 'antd'; +import { AxiosError } from 'axios'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LearningResource } from '../../../rest/learningResourceAPI'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import RichTextEditorPreviewer from '../../common/RichTextEditor/RichTextEditorPreviewer'; +import './article-viewer.less'; + +interface ArticleViewerProps { + resource: LearningResource; +} + +export const ArticleViewer: React.FC = ({ resource }) => { + const { t } = useTranslation(); + const [content, setContent] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchContent = useCallback(async () => { + try { + setIsLoading(true); + setError(null); + + // Check for embedded content in embedConfig first + if (resource.source.embedConfig?.content) { + setContent(resource.source.embedConfig.content as string); + } else if (resource.source.url.startsWith('http')) { + const response = await fetch(resource.source.url); + if (!response.ok) { + throw new Error(`Failed to fetch article: ${response.statusText}`); + } + const text = await response.text(); + setContent(text); + } else { + setContent(resource.source.url); + } + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to load article'; + setError(errorMessage); + showErrorToast(err as AxiosError, t('server.article-fetch-error')); + } finally { + setIsLoading(false); + } + }, [resource.source.url, resource.source.embedConfig, t]); + + useEffect(() => { + fetchContent(); + }, [fetchContent]); + + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + if (error) { + return ( +
+
+ } + rel="noopener noreferrer" + size="small" + target="_blank" + type="link"> + {t('label.open-original')} + + } + description={error} + message={t('message.failed-to-load-article')} + type="error" + /> +
+
+ ); + } + + return ( +
+
+
+ +
+
+
+ ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/ArticleViewer.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/ArticleViewer.test.tsx new file mode 100644 index 000000000000..0bfe4531c6d4 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/ArticleViewer.test.tsx @@ -0,0 +1,171 @@ +/* + * Copyright 2024 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, waitFor } from '@testing-library/react'; +import { LearningResource } from '../../../rest/learningResourceAPI'; +import { ArticleViewer } from './ArticleViewer.component'; + +jest.mock('../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), +})); + +jest.mock('../../common/RichTextEditor/RichTextEditorPreviewer', () => { + return jest + .fn() + .mockImplementation(({ markdown }) => ( +
{markdown}
+ )); +}); + +const createMockResource = ( + url: string, + embedContent?: string +): LearningResource => ({ + id: 'test-id', + name: 'test-article', + displayName: 'Test Article', + resourceType: 'Article', + source: { + url, + embedConfig: embedContent ? { content: embedContent } : undefined, + }, + contexts: [{ pageId: 'glossary' }], +}); + +describe('ArticleViewer', () => { + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should render loading state initially', () => { + const resource = createMockResource('https://example.com/article.md'); + (global.fetch as jest.Mock).mockImplementation(() => new Promise(() => {})); + + render(); + + expect(screen.getByText('label.loading-article')).toBeInTheDocument(); + }); + + it('should render embedded content from embedConfig', async () => { + const embeddedContent = '# Test Article Content'; + const resource = createMockResource('https://example.com', embeddedContent); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('rich-text-previewer')).toBeInTheDocument(); + }); + + expect(screen.getByText(embeddedContent)).toBeInTheDocument(); + }); + + it('should fetch and render content from URL', async () => { + const resource = createMockResource('https://example.com/article.md'); + const fetchedContent = '# Fetched Article'; + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + text: () => Promise.resolve(fetchedContent), + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('rich-text-previewer')).toBeInTheDocument(); + }); + + expect(screen.getByText(fetchedContent)).toBeInTheDocument(); + }); + + it('should render error state when fetch fails', async () => { + const resource = createMockResource('https://example.com/article.md'); + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + statusText: 'Not Found', + }); + + render(); + + await waitFor(() => { + expect( + screen.getByText('message.failed-to-load-article') + ).toBeInTheDocument(); + }); + }); + + it('should render error state when fetch throws', async () => { + const resource = createMockResource('https://example.com/article.md'); + + (global.fetch as jest.Mock).mockRejectedValue(new Error('Network error')); + + render(); + + await waitFor(() => { + expect( + screen.getByText('message.failed-to-load-article') + ).toBeInTheDocument(); + }); + }); + + it('should render URL directly if not http URL and no embedConfig', async () => { + const resource = createMockResource('Some direct markdown content'); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('rich-text-previewer')).toBeInTheDocument(); + }); + + expect( + screen.getByText('Some direct markdown content') + ).toBeInTheDocument(); + }); + + it('should show open original link button on error', async () => { + const resource = createMockResource('https://example.com/article.md'); + + (global.fetch as jest.Mock).mockResolvedValue({ + ok: false, + statusText: 'Not Found', + }); + + render(); + + await waitFor(() => { + expect(screen.getByText('label.open-original')).toBeInTheDocument(); + }); + }); + + it('should prioritize embedConfig over URL fetch', async () => { + const embeddedContent = '# Embedded Content'; + const resource = createMockResource( + 'https://example.com/article.md', + embeddedContent + ); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('rich-text-previewer')).toBeInTheDocument(); + }); + + expect(global.fetch).not.toHaveBeenCalled(); + expect(screen.getByText(embeddedContent)).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/ResourcePlayerModal.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/ResourcePlayerModal.component.tsx new file mode 100644 index 000000000000..bffc05e4bf43 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/ResourcePlayerModal.component.tsx @@ -0,0 +1,203 @@ +/* + * Copyright 2024 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 { + CloseOutlined, + ExpandAltOutlined, + ShrinkOutlined, +} from '@ant-design/icons'; +import { Button, Modal, Tag, Typography } from 'antd'; +import { DateTime } from 'luxon'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { LEARNING_CATEGORIES } from '../Learning.interface'; +import { ArticleViewer } from './ArticleViewer.component'; +import './resource-player-modal.less'; +import { ResourcePlayerModalProps } from './ResourcePlayerModal.interface'; +import { StorylaneTour } from './StorylaneTour.component'; +import { VideoPlayer } from './VideoPlayer.component'; + +const { Link, Paragraph, Text } = Typography; + +const MAX_VISIBLE_TAGS = 3; + +export const ResourcePlayerModal: React.FC = ({ + open, + resource, + onClose, +}) => { + const { t } = useTranslation(); + const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); + const [isFullScreen, setIsFullScreen] = useState(false); + const [isDescriptionTruncated, setIsDescriptionTruncated] = useState(false); + + const formattedDuration = useMemo(() => { + if (!resource.estimatedDuration) { + return null; + } + const minutes = Math.floor(resource.estimatedDuration / 60); + + return `${minutes} ${t('label.min-watch')}`; + }, [resource.estimatedDuration, t]); + + const formattedDate = useMemo(() => { + if (!resource.updatedAt) { + return null; + } + + return DateTime.fromMillis(resource.updatedAt).toFormat('LLL d, yyyy'); + }, [resource.updatedAt]); + + const categoryTags = useMemo(() => { + if (!resource.categories || resource.categories.length === 0) { + return { visible: [], remaining: 0 }; + } + + const visible = resource.categories.slice(0, MAX_VISIBLE_TAGS); + const remaining = resource.categories.length - MAX_VISIBLE_TAGS; + + return { visible, remaining }; + }, [resource.categories]); + + const getCategoryColors = useCallback((category: string) => { + const categoryInfo = + LEARNING_CATEGORIES[category as keyof typeof LEARNING_CATEGORIES]; + + return { + bgColor: categoryInfo?.bgColor ?? '#f8f9fc', + borderColor: categoryInfo?.borderColor ?? '#d5d9eb', + color: categoryInfo?.color ?? '#363f72', + }; + }, []); + + const handleViewMoreClick = useCallback(() => { + setIsDescriptionExpanded(!isDescriptionExpanded); + }, [isDescriptionExpanded]); + + const handleFullScreenToggle = useCallback(() => { + setIsFullScreen((prev) => !prev); + }, []); + + const renderPlayer = useMemo(() => { + switch (resource.resourceType) { + case 'Video': + return ; + case 'Storylane': + return ; + case 'Article': + return ; + default: + return
{t('message.unsupported-resource-type')}
; + } + }, [resource, t]); + + return ( + +
+
+ + {resource.displayName || resource.name} + + + {resource.description && ( +
+ + {resource.description} + + {(isDescriptionTruncated || isDescriptionExpanded) && ( + + {isDescriptionExpanded + ? t('label.view-less') + : t('label.view-more')} + + )} +
+ )} + +
+
+ {categoryTags.visible.map((category) => { + const colors = getCategoryColors(category); + + return ( + + {LEARNING_CATEGORIES[ + category as keyof typeof LEARNING_CATEGORIES + ]?.label ?? category} + + ); + })} + {categoryTags.remaining > 0 && ( + + +{categoryTags.remaining} + + )} +
+ +
+ {formattedDate && ( + {formattedDate} + )} + {formattedDate && formattedDuration && ( + | + )} + {formattedDuration && ( + {formattedDuration} + )} +
+
+
+ +
+ + +
+
+ +
{renderPlayer}
+
+ ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/ResourcePlayerModal.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/ResourcePlayerModal.interface.ts new file mode 100644 index 000000000000..8534f3efe396 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/ResourcePlayerModal.interface.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2024 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 { LearningResource } from '../../../rest/learningResourceAPI'; + +export interface ResourcePlayerModalProps { + open: boolean; + resource: LearningResource; + onClose: () => void; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/ResourcePlayerModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/ResourcePlayerModal.test.tsx new file mode 100644 index 000000000000..b4f12f74ff9f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/ResourcePlayerModal.test.tsx @@ -0,0 +1,231 @@ +/* + * Copyright 2024 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 { fireEvent, render, screen } from '@testing-library/react'; +import { LearningResource } from '../../../rest/learningResourceAPI'; +import { ResourcePlayerModal } from './ResourcePlayerModal.component'; + +jest.mock('./VideoPlayer.component', () => ({ + VideoPlayer: jest + .fn() + .mockImplementation(() =>
), +})); + +jest.mock('./StorylaneTour.component', () => ({ + StorylaneTour: jest + .fn() + .mockImplementation(() =>
), +})); + +jest.mock('./ArticleViewer.component', () => ({ + ArticleViewer: jest + .fn() + .mockImplementation(() =>
), +})); + +const mockOnClose = jest.fn(); + +const createMockResource = ( + resourceType: 'Video' | 'Storylane' | 'Article', + overrides?: Partial +): LearningResource => ({ + id: 'test-id', + name: 'test-resource', + displayName: 'Test Resource', + description: 'This is a test resource description', + resourceType, + source: { url: 'https://example.com/resource' }, + contexts: [{ pageId: 'glossary' }], + categories: ['Discovery', 'DataGovernance'], + estimatedDuration: 300, + updatedAt: 1704067200000, + ...overrides, +}); + +describe('ResourcePlayerModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render modal when open is true', () => { + const resource = createMockResource('Video'); + render( + + ); + + expect(screen.getByText('Test Resource')).toBeInTheDocument(); + }); + + it('should not render modal content when open is false', () => { + const resource = createMockResource('Video'); + render( + + ); + + expect(screen.queryByText('Test Resource')).not.toBeInTheDocument(); + }); + + it('should render VideoPlayer for Video resource type', () => { + const resource = createMockResource('Video'); + render( + + ); + + expect(screen.getByTestId('video-player')).toBeInTheDocument(); + }); + + it('should render StorylaneTour for Storylane resource type', () => { + const resource = createMockResource('Storylane'); + render( + + ); + + expect(screen.getByTestId('storylane-tour')).toBeInTheDocument(); + }); + + it('should render ArticleViewer for Article resource type', () => { + const resource = createMockResource('Article'); + render( + + ); + + expect(screen.getByTestId('article-viewer')).toBeInTheDocument(); + }); + + it('should display resource description', () => { + const resource = createMockResource('Video'); + render( + + ); + + expect( + screen.getByLabelText('This is a test resource description') + ).toBeInTheDocument(); + }); + + it('should display category tags', () => { + const resource = createMockResource('Video'); + render( + + ); + + expect(screen.getByText('Discovery')).toBeInTheDocument(); + expect(screen.getByText('Governance')).toBeInTheDocument(); + }); + + it('should display formatted duration', () => { + const resource = createMockResource('Video'); + render( + + ); + + expect(screen.getByText('5 label.min-watch')).toBeInTheDocument(); + }); + + it('should call onClose when close button is clicked', () => { + const resource = createMockResource('Video'); + render( + + ); + + const closeButton = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('should use resource name as fallback when displayName is not provided', () => { + const resource = createMockResource('Video', { + displayName: undefined, + }); + render( + + ); + + expect(screen.getByText('test-resource')).toBeInTheDocument(); + }); + + it('should show +N tag when more than 3 categories', () => { + const resource = createMockResource('Video', { + categories: [ + 'Discovery', + 'DataGovernance', + 'Observability', + 'DataQuality', + 'Lineage', + ], + }); + render( + + ); + + expect(screen.getByText('+2')).toBeInTheDocument(); + }); + + it('should not display duration when not provided', () => { + const resource = createMockResource('Video', { + estimatedDuration: undefined, + }); + render( + + ); + + expect(screen.queryByText(/label.min-watch/)).not.toBeInTheDocument(); + }); + + it('should not display description section when description is not provided', () => { + const resource = createMockResource('Video', { + description: undefined, + }); + render( + + ); + + expect( + screen.queryByLabelText(/test resource description/i) + ).not.toBeInTheDocument(); + }); + + it('should render description with ellipsis configuration when description is provided', () => { + const resource = createMockResource('Video', { + description: 'A very long description that should be truncated initially', + }); + render( + + ); + + const descriptionElement = screen.getByLabelText( + /A very long description that should be truncated initially/i + ); + + expect(descriptionElement).toBeInTheDocument(); + expect(descriptionElement).toHaveClass('resource-description'); + }); + + it('should display unsupported message for unknown resource type', () => { + const resource = createMockResource('Video', { + resourceType: 'Unknown' as 'Video', + }); + render( + + ); + + expect( + screen.getByText('message.unsupported-resource-type') + ).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/StorylaneTour.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/StorylaneTour.component.tsx new file mode 100644 index 000000000000..4a21753605b3 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Learning/ResourcePlayer/StorylaneTour.component.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2024 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 { Spin } from 'antd'; +import React, { useCallback, useState } from 'react'; +import { LearningResource } from '../../../rest/learningResourceAPI'; +import './storylane-tour.less'; + +interface StorylaneTourProps { + resource: LearningResource; +} + +export const StorylaneTour: React.FC = ({ resource }) => { + const [isLoading, setIsLoading] = useState(true); + + const handleLoad = useCallback(() => { + setIsLoading(false); + }, []); + + return ( +
+
+ {isLoading && ( +
+ +
+ )} +