From 897f9cfd1b32cd498cd8ef90ca930a08d4cd2faa Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Thu, 9 Apr 2026 23:05:52 -0700 Subject: [PATCH] Add changeSummary API endpoint and UI components (#26533) * Add changeSummary API endpoint and UI components for description source tracking Add a new /v1/changeSummary/{entityType}/{id|name/fqn} endpoint that returns per-field change metadata (who changed it, source type, timestamp). Supports fieldPrefix filtering for column-level queries on large tables and limit/offset pagination. UI additions: - DescriptionSourceBadge component showing AI badge on AI-generated descriptions - useChangeSummary hook for fetching change summary data - changeSummaryAPI REST client - "accepted-by" translation key Integration tests added to BaseEntityIT covering all entity types: get by ID, get by FQN, fieldPrefix filtering, pagination, and 404 cases. Closes #1648 Co-Authored-By: Claude Opus 4.6 (1M context) * Fix checkstyle * Integrate DescriptionSourceBadge into UI and address PR review comments - Add authorization checks (VIEW_BASIC) to ChangeSummaryResource endpoints - Fix pagination defaults and simplify pagination logic - Remove silent try-catch in integration tests, use assertThrows for 404 - Wire useChangeSummary hook into GenericProvider context - Render AI-generated badge on entity descriptions (DescriptionV1) - Render AI-generated badge on column descriptions (TableDescription) - Pass changeSummary entry to ColumnDetailPanel's DescriptionSection - Fix stale useMemo dependency in DescriptionV1 header - Fix missing Less variable import in description-source-badge.less - Add Playwright E2E tests for ChangeSummary badge feature Co-Authored-By: Claude Opus 4.6 * Address PR review feedback: fix column FQN parsing, badge labels, backend optimization - Fix ColumnDetailPanel to use EntityLink.getTableColumnNameFromColumnFqn instead of naive substring for changeSummary key lookup - Show correct badge label per source type (AI/Automated/Propagated) instead of always showing "Automated" - Fetch only changeDescription field instead of * in both endpoints - Add @Min/@Max validation for limit and offset parameters - Pass uriInfo to repository calls instead of null - Pass explicit limit=1000 in GenericProvider to avoid truncated results - Import ChangeSource from generated/type/changeSummaryMap (correct schema) - Add getChangeSummaryByFqn to REST client matching API surface - Add keyboard accessibility (tabIndex, role) to badge - Remove untranslated "accepted-by" from non-English locales (fallback to en-us) - Add "ai" label key to en-us locale - Strengthen integration test assertions to verify non-empty results Co-Authored-By: Claude Opus 4.6 * Improve change summary description layout * Expand change summary support across assets * Fix changeSummary race condition and LLMModel entity type mismatch - Add request cancellation to useChangeSummary hook to prevent stale data when users switch entities rapidly - Fix LLMModelResourceIT entity type from "llmmodel" to "llmModel" to match the registered entity type in Entity.java Co-Authored-By: Claude Opus 4.6 (1M context) * Fix Playwright strict mode violation and sync i18n translations - Use .first() for ai-suggested-badge locator in ChangeSummaryBadge test to handle DescriptionV1 rendering the badge in both header and metadata - Reorder imports per organize-imports rules - Sync ai-suggested and authored-by keys to all 18 locale files Co-Authored-By: Claude Opus 4.6 (1M context) * fix * fix * fix * implemented the new UI changes for AI description * fixed the lint issues * addressed gitar comment * fixed the translations * fixed unit test * addressed PR comment * fixed odcs playwright test --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Pere Miquel Brull Co-authored-by: Rohit0301 Co-authored-by: Rohit Jain <60229265+Rohit0301@users.noreply.github.com> --- .gitignore | 4 +- .../it/tests/APIEndpointResourceIT.java | 2 + .../openmetadata/it/tests/BaseEntityIT.java | 164 ++++++++ .../it/tests/ChangeSummaryResourceIT.java | 367 ++++++++++++++++++ .../it/tests/LLMModelResourceIT.java | 2 +- .../it/tests/MlModelServiceResourceIT.java | 2 +- .../it/tests/TopicResourceIT.java | 15 +- .../service/jdbi3/APIEndpointRepository.java | 6 +- .../service/jdbi3/ChangeSummarizer.java | 115 ++++-- .../service/jdbi3/CollectionDAO.java | 27 ++ .../service/jdbi3/ContainerRepository.java | 5 +- .../jdbi3/DashboardDataModelRepository.java | 5 +- .../service/jdbi3/EntityRepository.java | 48 ++- .../service/jdbi3/FileRepository.java | 5 +- .../service/jdbi3/GlossaryTermRepository.java | 12 +- .../service/jdbi3/MlModelRepository.java | 30 +- .../service/jdbi3/PipelineRepository.java | 4 +- .../service/jdbi3/SearchIndexRepository.java | 5 +- .../service/jdbi3/SuggestionRepository.java | 73 +++- .../service/jdbi3/TopicRepository.java | 5 +- .../resources/ChangeSummaryResource.java | 228 +++++++++++ .../service/jdbi3/ChangeSummarizerTest.java | 16 + .../ui/playwright/constant/dataContracts.ts | 2 - .../e2e/Features/ChangeSummaryBadge.spec.ts | 311 +++++++++++++++ .../playwright/support/entity/TableClass.ts | 12 +- .../ui/src/assets/svg/ic-ai-suggestion.svg | 9 + .../ui/src/assets/svg/ic-automated.svg | 5 + .../ui/src/assets/svg/ic-check-circle.svg | 2 +- .../ui/src/assets/svg/ic-propagated.svg | 3 + .../GenericProvider.interface.ts | 2 + .../GenericProvider/GenericProvider.tsx | 12 + .../DataAssetSummaryPanelV1.test.tsx | 12 + .../DataAssetSummaryPanelV1.tsx | 13 + .../ColumnDetailPanel.component.tsx | 11 +- .../TableDescription.component.tsx | 15 +- .../DescriptionSection.interface.ts | 2 + .../DescriptionSection.less | 18 + .../DescriptionSection/DescriptionSection.tsx | 33 +- .../DescriptionSourceBadge.interface.ts | 21 + .../DescriptionSourceBadge.test.tsx | 216 +++++++++++ .../DescriptionSourceBadge.tsx | 188 +++++++++ .../description-source-badge.less | 95 +++++ .../EntityDescription/DescriptionV1.tsx | 39 +- .../EntityDescription/description-v1.less | 12 + .../ui/src/hooks/useChangeSummary.ts | 102 +++++ .../ui/src/locale/languages/ar-sa.json | 6 + .../ui/src/locale/languages/de-de.json | 6 + .../ui/src/locale/languages/en-us.json | 6 + .../ui/src/locale/languages/es-es.json | 6 + .../ui/src/locale/languages/fr-fr.json | 6 + .../ui/src/locale/languages/gl-es.json | 6 + .../ui/src/locale/languages/he-he.json | 6 + .../ui/src/locale/languages/ja-jp.json | 6 + .../ui/src/locale/languages/ko-kr.json | 6 + .../ui/src/locale/languages/mr-in.json | 6 + .../ui/src/locale/languages/nl-nl.json | 6 + .../ui/src/locale/languages/pr-pr.json | 6 + .../ui/src/locale/languages/pt-br.json | 6 + .../ui/src/locale/languages/pt-pt.json | 6 + .../ui/src/locale/languages/ru-ru.json | 6 + .../ui/src/locale/languages/th-th.json | 6 + .../ui/src/locale/languages/tr-tr.json | 6 + .../ui/src/locale/languages/zh-cn.json | 6 + .../ui/src/locale/languages/zh-tw.json | 6 + .../ui/src/rest/changeSummaryAPI.test.ts | 124 ++++++ .../resources/ui/src/rest/changeSummaryAPI.ts | 62 +++ 66 files changed, 2479 insertions(+), 96 deletions(-) create mode 100644 openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ChangeSummaryResourceIT.java create mode 100644 openmetadata-service/src/main/java/org/openmetadata/service/resources/ChangeSummaryResource.java create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ChangeSummaryBadge.spec.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-ai-suggestion.svg create mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-automated.svg create mode 100644 openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-propagated.svg create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSourceBadge/DescriptionSourceBadge.interface.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSourceBadge/DescriptionSourceBadge.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSourceBadge/DescriptionSourceBadge.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSourceBadge/description-source-badge.less create mode 100644 openmetadata-ui/src/main/resources/ui/src/hooks/useChangeSummary.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/rest/changeSummaryAPI.test.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/rest/changeSummaryAPI.ts diff --git a/.gitignore b/.gitignore index 65ca3ee92c1..da34d1d0262 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,7 @@ release.properties dependency-reduced-pom.xml buildNumber.properties .mvn/timing.properties - +.maestro catalog-services/catalog-services.iml # local docker volume @@ -187,7 +187,9 @@ hive-mind-prompt-*.txt .claude-flow/ memory .claude/agents +.claude/hooks/ ingestion/.claude/agents +.claustre_session_id # AI scaffold working documents — stay local, never committed **/CONNECTOR_CONTEXT.md diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/APIEndpointResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/APIEndpointResourceIT.java index 84a0e7a484a..70384f2e72e 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/APIEndpointResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/APIEndpointResourceIT.java @@ -557,6 +557,7 @@ public class APIEndpointResourceIT extends BaseEntityIT listWithTagsOnly = client.apiEndpoints().list(paramsTagsOnly); assertNotNull(listWithTagsOnly.getData()); @@ -594,6 +595,7 @@ public class APIEndpointResourceIT extends BaseEntityIT listWithSchemas = client.apiEndpoints().list(paramsWithSchemas); assertNotNull(listWithSchemas.getData()); diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseEntityIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseEntityIT.java index 22f4e09c594..9c559e477d3 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseEntityIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseEntityIT.java @@ -6240,4 +6240,168 @@ public abstract class BaseEntityIT { } } } + + // =================================================================== + // CHANGE SUMMARY TESTS + // =================================================================== + + /** + * Test: Retrieve changeSummary by entity ID after updating the entity. + * The changeSummary API returns metadata about who changed each field, + * the source of the change, and when it was changed. + */ + @Test + void get_changeSummaryById_200(TestNamespace ns) throws Exception { + K createRequest = createMinimalRequest(ns); + T created = createEntity(createRequest); + + created.setDescription("Updated description for changeSummary test"); + T updated = patchEntity(created.getId().toString(), created); + + OpenMetadataClient client = SdkClients.adminClient(); + String response = + client + .getHttpClient() + .executeForString( + HttpMethod.GET, + "/v1/changeSummary/" + getEntityType() + "/" + updated.getId(), + null); + assertNotNull(response, "ChangeSummary response should not be null"); + JsonNode result = MAPPER.readTree(response); + assertTrue(result.has("changeSummary"), "Response must contain changeSummary field"); + assertTrue(result.has("totalEntries"), "Response must contain totalEntries field"); + assertTrue( + result.get("totalEntries").asInt() > 0, + "totalEntries should be > 0 after patching description"); + JsonNode changeSummaryNode = result.get("changeSummary"); + assertTrue( + changeSummaryNode.isObject() && changeSummaryNode.size() > 0, + "changeSummary should contain at least one entry after patching description"); + } + + /** + * Test: Retrieve changeSummary by entity FQN after updating the entity. + */ + @Test + void get_changeSummaryByFqn_200(TestNamespace ns) throws Exception { + K createRequest = createMinimalRequest(ns); + T created = createEntity(createRequest); + + created.setDescription("Updated description for changeSummary FQN test"); + T updated = patchEntity(created.getId().toString(), created); + + OpenMetadataClient client = SdkClients.adminClient(); + String fqn = updated.getFullyQualifiedName(); + String response = + client + .getHttpClient() + .executeForString( + HttpMethod.GET, "/v1/changeSummary/" + getEntityType() + "/name/" + fqn, null); + assertNotNull(response, "ChangeSummary response should not be null"); + JsonNode result = MAPPER.readTree(response); + assertTrue(result.has("changeSummary"), "Response must contain changeSummary field"); + assertTrue(result.has("totalEntries"), "Response must contain totalEntries field"); + assertTrue( + result.get("totalEntries").asInt() > 0, + "totalEntries should be > 0 after patching description"); + JsonNode changeSummaryNode = result.get("changeSummary"); + assertTrue( + changeSummaryNode.isObject() && changeSummaryNode.size() > 0, + "changeSummary should contain at least one entry after patching description"); + } + + /** + * Test: Retrieve changeSummary with fieldPrefix filter. + * Verifies that the filtering parameter works correctly. + */ + @Test + void get_changeSummaryWithFieldPrefix_200(TestNamespace ns) throws Exception { + K createRequest = createMinimalRequest(ns); + T created = createEntity(createRequest); + + created.setDescription("Updated for fieldPrefix test"); + T updated = patchEntity(created.getId().toString(), created); + + OpenMetadataClient client = SdkClients.adminClient(); + String response = + client + .getHttpClient() + .executeForString( + HttpMethod.GET, + "/v1/changeSummary/" + + getEntityType() + + "/" + + updated.getId() + + "?fieldPrefix=description", + null); + assertNotNull(response, "ChangeSummary filtered response should not be null"); + JsonNode result = MAPPER.readTree(response); + assertTrue(result.has("changeSummary"), "Response must contain changeSummary field"); + assertTrue(result.has("totalEntries"), "Response must contain totalEntries field"); + + JsonNode changeSummary = result.get("changeSummary"); + assertTrue( + changeSummary.isObject() && changeSummary.size() > 0, + "Filtered changeSummary should contain at least one entry matching 'description' prefix"); + changeSummary + .fieldNames() + .forEachRemaining( + key -> + assertTrue( + key.startsWith("description"), + "All keys should start with 'description', but found: " + key)); + } + + /** + * Test: Retrieve changeSummary with pagination parameters. + */ + @Test + void get_changeSummaryWithPagination_200(TestNamespace ns) throws Exception { + K createRequest = createMinimalRequest(ns); + T created = createEntity(createRequest); + + created.setDescription("Updated for pagination test"); + patchEntity(created.getId().toString(), created); + + OpenMetadataClient client = SdkClients.adminClient(); + String response = + client + .getHttpClient() + .executeForString( + HttpMethod.GET, + "/v1/changeSummary/" + + getEntityType() + + "/" + + created.getId() + + "?limit=1&offset=0", + null); + assertNotNull(response, "ChangeSummary paginated response should not be null"); + JsonNode result = MAPPER.readTree(response); + assertTrue(result.has("changeSummary"), "Response must contain changeSummary field"); + assertTrue(result.has("totalEntries"), "Response must contain totalEntries field"); + assertTrue(result.has("offset"), "Paginated response must contain offset field"); + assertTrue(result.has("limit"), "Paginated response must contain limit field"); + } + + /** + * Test: changeSummary returns 404 for non-existent entity. + */ + @Test + void get_changeSummaryNotFound_404(TestNamespace ns) { + OpenMetadataClient client = SdkClients.adminClient(); + UUID randomId = UUID.randomUUID(); + Exception thrown = + assertThrows( + Exception.class, + () -> + client + .getHttpClient() + .executeForString( + HttpMethod.GET, + "/v1/changeSummary/" + getEntityType() + "/" + randomId, + null)); + assertTrue( + thrown.getMessage().contains("404") || thrown.getMessage().contains("not found"), + "Should get 404 for non-existent entity, got: " + thrown.getMessage()); + } } diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ChangeSummaryResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ChangeSummaryResourceIT.java new file mode 100644 index 00000000000..83cb59c7e00 --- /dev/null +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/ChangeSummaryResourceIT.java @@ -0,0 +1,367 @@ +package org.openmetadata.it.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.openmetadata.it.factories.DatabaseSchemaTestFactory; +import org.openmetadata.it.factories.DatabaseServiceTestFactory; +import org.openmetadata.it.factories.TableTestFactory; +import org.openmetadata.it.util.SdkClients; +import org.openmetadata.it.util.TestNamespace; +import org.openmetadata.it.util.TestNamespaceExtension; +import org.openmetadata.schema.api.feed.CreateSuggestion; +import org.openmetadata.schema.entity.data.DatabaseSchema; +import org.openmetadata.schema.entity.data.Table; +import org.openmetadata.schema.entity.feed.Suggestion; +import org.openmetadata.schema.entity.services.DatabaseService; +import org.openmetadata.schema.type.Column; +import org.openmetadata.schema.type.SuggestionType; +import org.openmetadata.sdk.client.OpenMetadataClient; +import org.openmetadata.sdk.fluent.Tables; +import org.openmetadata.sdk.fluent.builders.ColumnBuilder; +import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.sdk.network.RequestOptions; + +/** + * Integration tests for the changeSummary API, focusing on correct tracking of who changed each + * field when multiple users accept suggestions sequentially. + * + *

Key bug scenario: when two suggestions propose the same description text (common with + * AI-generated suggestions), the second acceptance produces no JSON patch diff, so the + * changeSummary never updates to reflect the second user. The "same value" tests below reproduce + * this exact issue. + */ +@Execution(ExecutionMode.CONCURRENT) +@ExtendWith(TestNamespaceExtension.class) +public class ChangeSummaryResourceIT { + + private static final ObjectMapper MAPPER = + new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + private static final String SHARED_DESCRIPTION = + "AI-suggested description for analytics and reporting"; + + @BeforeAll + public static void setup() { + SdkClients.adminClient(); + } + + /** + * Reproduces the core bug: two suggestions with the SAME description text accepted by different + * users. The second acceptance sets an identical value, producing an empty JSON patch, so the + * changeSummary still shows the first user. + */ + @Test + void testChangeSummaryUpdatesWhenSameValueAcceptedByDifferentUser(TestNamespace ns) + throws Exception { + Table table = createTestTable(ns); + String entityLink = String.format("<#E::table::%s>", table.getFullyQualifiedName()); + + OpenMetadataClient user1Client = SdkClients.user1Client(); + OpenMetadataClient adminClient = SdkClients.adminClient(); + + // User1 accepts a description suggestion + Suggestion suggestion1 = + createSuggestion( + new CreateSuggestion() + .withDescription(SHARED_DESCRIPTION) + .withType(SuggestionType.SuggestDescription) + .withEntityLink(entityLink)); + acceptSuggestion(user1Client, suggestion1.getId().toString()); + + // Verify changeSummary reflects user1 + Map summary1 = getChangeSummary("table", table.getFullyQualifiedName()); + Map> entries1 = extractChangeSummary(summary1); + assertNotNull(entries1.get("description")); + + String firstChangedBy = (String) entries1.get("description").get("changedBy"); + long firstChangedAt = ((Number) entries1.get("description").get("changedAt")).longValue(); + assertTrue( + firstChangedBy.contains("shared_user1"), + "Expected changedBy to contain shared_user1 but was: " + firstChangedBy); + + // Admin accepts a second suggestion with the SAME description value + Suggestion suggestion2 = + createSuggestion( + new CreateSuggestion() + .withDescription(SHARED_DESCRIPTION) + .withType(SuggestionType.SuggestDescription) + .withEntityLink(entityLink)); + acceptSuggestion(adminClient, suggestion2.getId().toString()); + + // The description value is the same, but a different user accepted it. + // changeSummary must reflect the latest acceptor. + Map summary2 = getChangeSummary("table", table.getFullyQualifiedName()); + Map> entries2 = extractChangeSummary(summary2); + assertNotNull(entries2.get("description")); + + String secondChangedBy = (String) entries2.get("description").get("changedBy"); + long secondChangedAt = ((Number) entries2.get("description").get("changedAt")).longValue(); + + assertTrue( + secondChangedBy.contains("admin"), + "Expected changedBy to contain admin after second acceptance but was: " + secondChangedBy); + assertTrue( + secondChangedAt >= firstChangedAt, + "Expected second changedAt (%d) >= first changedAt (%d)" + .formatted(secondChangedAt, firstChangedAt)); + } + + /** Same bug at the column level: same column description accepted by two different users. */ + @Test + void testChangeSummaryUpdatesColumnWhenSameValueAcceptedByDifferentUser(TestNamespace ns) + throws Exception { + Table table = createTestTableWithColumns(ns); + String columnLink = + String.format("<#E::table::%s::columns::name>", table.getFullyQualifiedName()); + + OpenMetadataClient user1Client = SdkClients.user1Client(); + OpenMetadataClient adminClient = SdkClients.adminClient(); + + // User1 accepts a column description suggestion + Suggestion suggestion1 = + createSuggestion( + new CreateSuggestion() + .withDescription(SHARED_DESCRIPTION) + .withType(SuggestionType.SuggestDescription) + .withEntityLink(columnLink)); + acceptSuggestion(user1Client, suggestion1.getId().toString()); + + Map summary1 = getChangeSummary("table", table.getFullyQualifiedName()); + Map> entries1 = extractChangeSummary(summary1); + Map colEntry1 = findEntryByPrefix(entries1, "columns.name.description"); + assertNotNull(colEntry1); + + String firstChangedBy = (String) colEntry1.get("changedBy"); + long firstChangedAt = ((Number) colEntry1.get("changedAt")).longValue(); + assertTrue( + firstChangedBy.contains("shared_user1"), + "Expected changedBy to contain shared_user1 but was: " + firstChangedBy); + + // Admin accepts a second suggestion with the SAME description + Suggestion suggestion2 = + createSuggestion( + new CreateSuggestion() + .withDescription(SHARED_DESCRIPTION) + .withType(SuggestionType.SuggestDescription) + .withEntityLink(columnLink)); + acceptSuggestion(adminClient, suggestion2.getId().toString()); + + // changeSummary must reflect admin, not user1 + Map summary2 = getChangeSummary("table", table.getFullyQualifiedName()); + Map> entries2 = extractChangeSummary(summary2); + Map colEntry2 = findEntryByPrefix(entries2, "columns.name.description"); + assertNotNull(colEntry2); + + String secondChangedBy = (String) colEntry2.get("changedBy"); + long secondChangedAt = ((Number) colEntry2.get("changedAt")).longValue(); + assertTrue( + secondChangedBy.contains("admin"), + "Expected changedBy to contain admin after second acceptance but was: " + secondChangedBy); + assertTrue( + secondChangedAt >= firstChangedAt, + "Expected second changedAt (%d) >= first changedAt (%d)" + .formatted(secondChangedAt, firstChangedAt)); + } + + /** + * Sanity check: when two suggestions have DIFFERENT values, the changeSummary updates correctly. + * This already works today; the bug only manifests when the value is the same. + */ + @Test + void testChangeSummaryUpdatesWhenDifferentValuesAcceptedByDifferentUsers(TestNamespace ns) + throws Exception { + Table table = createTestTable(ns); + String entityLink = String.format("<#E::table::%s>", table.getFullyQualifiedName()); + + OpenMetadataClient user1Client = SdkClients.user1Client(); + OpenMetadataClient adminClient = SdkClients.adminClient(); + + // User1 accepts a description suggestion + Suggestion suggestion1 = + createSuggestion( + new CreateSuggestion() + .withDescription("Description from first suggestion") + .withType(SuggestionType.SuggestDescription) + .withEntityLink(entityLink)); + acceptSuggestion(user1Client, suggestion1.getId().toString()); + + Map summary1 = getChangeSummary("table", table.getFullyQualifiedName()); + Map> entries1 = extractChangeSummary(summary1); + assertNotNull(entries1.get("description")); + + String firstChangedBy = (String) entries1.get("description").get("changedBy"); + long firstChangedAt = ((Number) entries1.get("description").get("changedAt")).longValue(); + assertTrue( + firstChangedBy.contains("shared_user1"), + "Expected changedBy to contain shared_user1 but was: " + firstChangedBy); + + // Admin accepts a second suggestion with a DIFFERENT description + Suggestion suggestion2 = + createSuggestion( + new CreateSuggestion() + .withDescription("Description from second suggestion") + .withType(SuggestionType.SuggestDescription) + .withEntityLink(entityLink)); + acceptSuggestion(adminClient, suggestion2.getId().toString()); + + Table updatedTable = Tables.findByName(table.getFullyQualifiedName()).fetch().get(); + assertEquals("Description from second suggestion", updatedTable.getDescription()); + + Map summary2 = getChangeSummary("table", table.getFullyQualifiedName()); + Map> entries2 = extractChangeSummary(summary2); + assertNotNull(entries2.get("description")); + + String secondChangedBy = (String) entries2.get("description").get("changedBy"); + long secondChangedAt = ((Number) entries2.get("description").get("changedAt")).longValue(); + + assertTrue( + secondChangedBy.contains("admin"), + "Expected changedBy to contain admin but was: " + secondChangedBy); + assertTrue( + secondChangedAt >= firstChangedAt, + "Expected second changedAt (%d) >= first changedAt (%d)" + .formatted(secondChangedAt, firstChangedAt)); + } + + /** Verifies independent columns are tracked correctly with different users. */ + @Test + void testChangeSummaryTracksMultipleColumnsIndependently(TestNamespace ns) throws Exception { + Table table = createTestTableWithColumns(ns); + String col1Link = + String.format("<#E::table::%s::columns::name>", table.getFullyQualifiedName()); + String col2Link = + String.format("<#E::table::%s::columns::email>", table.getFullyQualifiedName()); + + OpenMetadataClient user1Client = SdkClients.user1Client(); + OpenMetadataClient adminClient = SdkClients.adminClient(); + + // User1 accepts a suggestion on column "name" + Suggestion suggestion1 = + createSuggestion( + new CreateSuggestion() + .withDescription("Name column description by user1") + .withType(SuggestionType.SuggestDescription) + .withEntityLink(col1Link)); + acceptSuggestion(user1Client, suggestion1.getId().toString()); + + // Admin accepts a suggestion on column "email" + Suggestion suggestion2 = + createSuggestion( + new CreateSuggestion() + .withDescription("Email column description by admin") + .withType(SuggestionType.SuggestDescription) + .withEntityLink(col2Link)); + acceptSuggestion(adminClient, suggestion2.getId().toString()); + + Map summary = getChangeSummary("table", table.getFullyQualifiedName()); + Map> entries = extractChangeSummary(summary); + + Map nameEntry = findEntryByPrefix(entries, "columns.name.description"); + Map emailEntry = findEntryByPrefix(entries, "columns.email.description"); + + assertNotNull(nameEntry, "Expected changeSummary entry for columns.name.description"); + assertNotNull(emailEntry, "Expected changeSummary entry for columns.email.description"); + + String nameChangedBy = (String) nameEntry.get("changedBy"); + String emailChangedBy = (String) emailEntry.get("changedBy"); + + assertTrue( + nameChangedBy.contains("shared_user1"), + "Expected name column changedBy to contain shared_user1 but was: " + nameChangedBy); + assertTrue( + emailChangedBy.contains("admin"), + "Expected email column changedBy to contain admin but was: " + emailChangedBy); + } + + // --- Helper methods --- + + private Table createTestTable(TestNamespace ns) { + String shortId = ns.shortPrefix(); + DatabaseService service = + DatabaseServiceTestFactory.createPostgresWithName("svc" + shortId, ns); + DatabaseSchema schema = + DatabaseSchemaTestFactory.createSimpleWithName("sc" + shortId, ns, service); + return TableTestFactory.createSimpleWithName( + "tbl" + shortId, ns, schema.getFullyQualifiedName()); + } + + private Table createTestTableWithColumns(TestNamespace ns) { + String shortId = ns.shortPrefix(); + DatabaseService service = + DatabaseServiceTestFactory.createPostgresWithName("svc" + shortId, ns); + DatabaseSchema schema = + DatabaseSchemaTestFactory.createSimpleWithName("sc" + shortId, ns, service); + + List columns = + List.of( + ColumnBuilder.of("id", "BIGINT").primaryKey().notNull().build(), + ColumnBuilder.of("name", "VARCHAR").dataLength(255).build(), + ColumnBuilder.of("email", "VARCHAR").dataLength(255).build()); + + return Tables.create() + .name("tbl" + shortId) + .inSchema(schema.getFullyQualifiedName()) + .withColumns(columns) + .execute(); + } + + private Suggestion createSuggestion(CreateSuggestion createSuggestion) throws Exception { + String response = + SdkClients.adminClient() + .getHttpClient() + .executeForString( + HttpMethod.POST, + "/v1/suggestions", + createSuggestion, + RequestOptions.builder().build()); + return MAPPER.readValue(response, Suggestion.class); + } + + private void acceptSuggestion(OpenMetadataClient client, String suggestionId) throws Exception { + client + .getHttpClient() + .executeForString( + HttpMethod.PUT, + "/v1/suggestions/" + suggestionId + "/accept", + null, + RequestOptions.builder().build()); + } + + private Map getChangeSummary(String entityType, String fqn) throws Exception { + String response = + SdkClients.adminClient() + .getHttpClient() + .executeForString( + HttpMethod.GET, + "/v1/changeSummary/" + entityType + "/name/" + fqn, + null, + RequestOptions.builder().build()); + return MAPPER.readValue(response, new TypeReference>() {}); + } + + @SuppressWarnings("unchecked") + private Map> extractChangeSummary(Map response) { + return (Map>) response.get("changeSummary"); + } + + private Map findEntryByPrefix( + Map> entries, String prefix) { + return entries.entrySet().stream() + .filter(e -> e.getKey().startsWith(prefix) || e.getKey().equals(prefix)) + .map(Map.Entry::getValue) + .findFirst() + .orElse(null); + } +} diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/LLMModelResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/LLMModelResourceIT.java index a4983858f3e..2e662e1e959 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/LLMModelResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/LLMModelResourceIT.java @@ -101,7 +101,7 @@ public class LLMModelResourceIT extends BaseEntityIT { @Override protected String getEntityType() { - return "llmmodel"; + return "llmModel"; } @Override diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/MlModelServiceResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/MlModelServiceResourceIT.java index 6eea0aef293..857039ffc40 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/MlModelServiceResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/MlModelServiceResourceIT.java @@ -106,7 +106,7 @@ public class MlModelServiceResourceIT extends BaseServiceIT { // List topics ListParams params = new ListParams(); + params.setFields("service"); params.setLimit(100); + params.setService(service.getFullyQualifiedName()); ListResponse response = listEntities(params); assertNotNull(response); - // Verify we have at least our 3 topics - long serviceCount = + assertTrue(response.getData().size() >= 3); + assertTrue( response.getData().stream() - .filter( - t -> t.getService().getFullyQualifiedName().equals(service.getFullyQualifiedName())) - .count(); - assertTrue(serviceCount >= 3); + .allMatch( + t -> + t.getService() + .getFullyQualifiedName() + .equals(service.getFullyQualifiedName()))); } @Test diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/APIEndpointRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/APIEndpointRepository.java index a523c459ee2..22d63a42de5 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/APIEndpointRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/APIEndpointRepository.java @@ -35,6 +35,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.function.BiPredicate; import java.util.function.Function; @@ -64,6 +65,8 @@ import org.openmetadata.service.util.EntityUtil.RelationIncludes; import org.openmetadata.service.util.FullyQualifiedName; public class APIEndpointRepository extends EntityRepository { + private static final Set CHANGE_SUMMARY_FIELDS = + Set.of("requestSchema.schemaFields.description", "responseSchema.schemaFields.description"); private static final ReadPrefetchKey PREFETCH_DEFAULT_FIELDS = ReadPrefetchKey.API_ENDPOINT_DEFAULT_FIELDS; @@ -74,7 +77,8 @@ public class APIEndpointRepository extends EntityRepository { APIEndpoint.class, Entity.getCollectionDAO().apiEndpointDAO(), "", - ""); + "", + CHANGE_SUMMARY_FIELDS); supportsSearch = true; // Register bulk field fetchers for efficient database operations diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ChangeSummarizer.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ChangeSummarizer.java index e362979ec63..40f32f731d2 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ChangeSummarizer.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ChangeSummarizer.java @@ -2,6 +2,7 @@ package org.openmetadata.service.jdbi3; import java.lang.reflect.Field; import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; import java.util.HashSet; import java.util.List; import java.util.Map; @@ -63,37 +64,11 @@ public class ChangeSummarizer { } private boolean isFieldTracked(String fieldName) { - // Check if the field is explicitly tracked or if it is a nested field - if (fields.contains(fieldName)) { - return true; - } - // Check for nested fields - String[] parts = FullyQualifiedName.split(fieldName); - return fields.contains(parts[0] + "." + parts[parts.length - 1]); + return fields.stream().anyMatch(trackedField -> matchesTrackedField(trackedField, fieldName)); } private boolean hasField(Class clazz, String fieldName) { - // Check if the class has the specified field - try { - clazz.getDeclaredField(fieldName); - return true; - } catch (NoSuchFieldException e) { - // Ignore - } - // Handle nested fields (e.g. columns.column1.description) - try { - String[] parts = FullyQualifiedName.split(fieldName); - String field = parts[0]; - String nestedField = parts[parts.length - 1]; - Field fieldObj = clazz.getDeclaredField(field); - // Handles list types. We might want to expand this to cover other types in the future - ((Class) ((ParameterizedType) fieldObj.getGenericType()).getActualTypeArguments()[0]) - .getDeclaredField(nestedField); - return true; - } catch (NoSuchFieldException e) { - // Ignore - } - return false; + return hasField(clazz, FullyQualifiedName.split(fieldName), 0); } /** @@ -123,21 +98,12 @@ public class ChangeSummarizer { .collect(Collectors.toSet()); for (FieldChange fieldChange : nestedFields) { - String fieldName = field.split("\\.")[0]; - String nestedField = field.split("\\.")[1]; - try { - if (!clazz.getDeclaredField(fieldName).getType().isAssignableFrom(List.class)) { - // skip non List types - continue; - } - } catch (NoSuchFieldException e) { - LOG.warn( - "No field {} found in class {}", - fieldChange.getName().split("\\.")[0], - clazz.getName()); + if (!isListField(clazz, fieldChange.getName())) { + continue; } try { + String nestedField = field.substring(fieldChange.getName().length() + 1); JsonUtils.readObjects((String) fieldChange.getOldValue(), Map.class).stream() .map(map -> (Map) map) .map(map -> (String) map.get("name")) @@ -152,4 +118,73 @@ public class ChangeSummarizer { } return keysToDelete; } + + private boolean matchesTrackedField(String trackedField, String fieldName) { + if (trackedField.equals(fieldName)) { + return true; + } + + String[] trackedParts = FullyQualifiedName.split(trackedField); + String[] fieldParts = FullyQualifiedName.split(fieldName); + + if (trackedParts.length == 1 || fieldParts.length < trackedParts.length) { + return false; + } + + for (int i = 0; i < trackedParts.length - 1; i++) { + if (!trackedParts[i].equals(fieldParts[i])) { + return false; + } + } + + return trackedParts[trackedParts.length - 1].equals(fieldParts[fieldParts.length - 1]); + } + + private boolean hasField(Class currentClass, String[] fieldParts, int index) { + try { + Field field = currentClass.getDeclaredField(fieldParts[index]); + if (index == fieldParts.length - 1) { + return true; + } + + return hasField(getFieldClass(field), fieldParts, index + 1); + } catch (NoSuchFieldException e) { + return false; + } + } + + private boolean isListField(Class currentClass, String fieldName) { + try { + String[] fieldParts = FullyQualifiedName.split(fieldName); + Field field = null; + Class fieldClass = currentClass; + + for (String fieldPart : fieldParts) { + field = fieldClass.getDeclaredField(fieldPart); + fieldClass = getFieldClass(field); + } + + return field != null && List.class.isAssignableFrom(field.getType()); + } catch (NoSuchFieldException e) { + LOG.warn("No field {} found in class {}", fieldName, currentClass.getName()); + return false; + } + } + + private Class getFieldClass(Field field) { + if (List.class.isAssignableFrom(field.getType())) { + Type genericType = field.getGenericType(); + if (genericType instanceof ParameterizedType parameterizedType) { + Type elementType = parameterizedType.getActualTypeArguments()[0]; + if (elementType instanceof Class elementClass) { + return elementClass; + } + if (elementType instanceof ParameterizedType nestedParameterizedType) { + return (Class) nestedParameterizedType.getRawType(); + } + } + } + + return field.getType(); + } } 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 7791ca19a66..561e405b7dc 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 @@ -1912,6 +1912,26 @@ public interface CollectionDAO { @Bind("toEntity") String toEntity, @Bind("relation") int relation); + @ConnectionAwareSqlQuery( + value = + "SELECT COALESCE(JSON_UNQUOTE(JSON_EXTRACT(json, '$.relationType')), 'relatedTo') as relationType, " + + "COUNT(*) as cnt FROM entity_relationship " + + "WHERE fromEntity = :fromEntity AND toEntity = :toEntity AND relation = :relation " + + "GROUP BY relationType", + connectionType = MYSQL) + @ConnectionAwareSqlQuery( + value = + "SELECT COALESCE(json->>'relationType', 'relatedTo') as relationType, " + + "COUNT(*) as cnt FROM entity_relationship " + + "WHERE fromEntity = :fromEntity AND toEntity = :toEntity AND relation = :relation " + + "GROUP BY relationType", + connectionType = POSTGRES) + @RegisterRowMapper(RelationTypeCountMapper.class) + List> countByRelationType( + @Bind("fromEntity") String fromEntity, + @Bind("toEntity") String toEntity, + @Bind("relation") int relation); + @SqlQuery( "SELECT COUNT(toId) FROM entity_relationship WHERE fromId = :fromId AND fromEntity = :fromEntity " + "AND relation IN ()") @@ -2466,6 +2486,13 @@ public interface CollectionDAO { } } + class RelationTypeCountMapper implements RowMapper> { + @Override + public List map(ResultSet rs, StatementContext ctx) throws SQLException { + return Arrays.asList(rs.getString("relationType"), rs.getString("cnt")); + } + } + class RelationshipObjectMapper implements RowMapper { @Override public EntityRelationshipObject map(ResultSet rs, StatementContext ctx) throws SQLException { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContainerRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContainerRepository.java index 20e69f205b2..13c08437baa 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContainerRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContainerRepository.java @@ -21,6 +21,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import org.jdbi.v3.sqlobject.transaction.Transaction; import org.openmetadata.schema.EntityInterface; @@ -50,6 +51,7 @@ import org.openmetadata.service.util.FullyQualifiedName; public class ContainerRepository extends EntityRepository { private static final String CONTAINER_UPDATE_FIELDS = "dataModel"; private static final String CONTAINER_PATCH_FIELDS = "dataModel"; + private static final Set CHANGE_SUMMARY_FIELDS = Set.of("dataModel.columns.description"); public ContainerRepository() { super( @@ -58,7 +60,8 @@ public class ContainerRepository extends EntityRepository { Container.class, Entity.getCollectionDAO().containerDAO(), CONTAINER_PATCH_FIELDS, - CONTAINER_UPDATE_FIELDS); + CONTAINER_UPDATE_FIELDS, + CHANGE_SUMMARY_FIELDS); supportsSearch = true; // Register bulk field fetchers for efficient database operations diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DashboardDataModelRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DashboardDataModelRepository.java index f5636a74d0f..f407b322303 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DashboardDataModelRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DashboardDataModelRepository.java @@ -54,6 +54,8 @@ import org.openmetadata.service.util.FullyQualifiedName; @Slf4j public class DashboardDataModelRepository extends EntityRepository { + private static final Set CHANGE_SUMMARY_FIELDS = Set.of("columns.description"); + public DashboardDataModelRepository() { super( DashboardDataModelResource.COLLECTION_PATH, @@ -61,7 +63,8 @@ public class DashboardDataModelRepository extends EntityRepository { } } - changeSummarizer = new ChangeSummarizer<>(entityClass, changeSummaryFields); + changeSummarizer = + new ChangeSummarizer<>(entityClass, getEffectiveChangeSummaryFields(changeSummaryFields)); Entity.registerEntity(entityClass, entityType, this); } + private Set getEffectiveChangeSummaryFields(Set configuredFields) { + Set effectiveFields = new HashSet<>(configuredFields); + + if (allowedFields.contains(FIELD_DESCRIPTION)) { + effectiveFields.add(FIELD_DESCRIPTION); + } + if (allowedFields.contains(FIELD_OWNERS)) { + effectiveFields.add(FIELD_OWNERS); + } + + return effectiveFields; + } + /** * Set the requested fields in an entity. This is used for requesting specific fields in the object during GET * operations. It is also used during PUT and PATCH operations to set up fields that can be updated. @@ -3255,6 +3269,38 @@ public abstract class EntityRepository { return new PatchResponse<>(Status.OK, withHref(uriInfo, updated), ENTITY_NO_CHANGE); } + /** + * Update only the changeSummary entry for a specific field without modifying the entity data. + * Used when accepting a suggestion that sets the same value already present — the JSON patch is + * empty so the normal update path produces no FieldChange, but the changeSummary must still + * reflect who accepted the suggestion. + */ + @Transaction + public void patchChangeSummary( + UUID entityId, String fieldName, ChangeSource changeSource, String user) { + T entity = get(null, entityId, getFields("changeDescription")); + ChangeDescription cd = entity.getChangeDescription(); + if (cd == null) { + cd = new ChangeDescription().withPreviousVersion(entity.getVersion()); + } + ChangeSummaryMap csm = cd.getChangeSummary(); + if (csm == null) { + csm = new ChangeSummaryMap(); + cd.setChangeSummary(csm); + } + + csm.getAdditionalProperties() + .put( + fieldName, + new ChangeSummary() + .withChangeSource(changeSource) + .withChangedBy(user) + .withChangedAt(System.currentTimeMillis())); + + entity.setChangeDescription(cd); + dao.update(entity.getId(), entity.getFullyQualifiedName(), JsonUtils.pojoToJson(entity)); + } + @Transaction public final PutResponse addFollower(String updatedBy, UUID entityId, UUID userId) { T entity = find(entityId, NON_DELETED); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/FileRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/FileRepository.java index 21f11e61571..193a23ab622 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/FileRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/FileRepository.java @@ -31,6 +31,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; @@ -68,6 +69,7 @@ public class FileRepository extends EntityRepository { public static final String FILE_SAMPLE_DATA_EXTENSION = "file.sampleData"; static final String PATCH_FIELDS = "columns"; static final String UPDATE_FIELDS = "columns"; + private static final Set CHANGE_SUMMARY_FIELDS = Set.of("columns.description"); public FileRepository() { super( @@ -76,7 +78,8 @@ public class FileRepository extends EntityRepository { File.class, Entity.getCollectionDAO().fileDAO(), PATCH_FIELDS, - UPDATE_FIELDS); + UPDATE_FIELDS, + CHANGE_SUMMARY_FIELDS); supportsSearch = true; } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryTermRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryTermRepository.java index 74b705e44de..82e37412c60 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryTermRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryTermRepository.java @@ -236,17 +236,15 @@ public class GlossaryTermRepository extends EntityRepository { } public Map getRelationTypeUsageCounts() { - Map usageCounts = new HashMap<>(); - List records = + List> rows = daoCollection .relationshipDAO() - .findAllByEntityTypes(entityType, entityType, Relationship.RELATED_TO.ordinal()); + .countByRelationType(entityType, entityType, Relationship.RELATED_TO.ordinal()); - for (EntityRelationshipRecord record : records) { - String relationType = extractRelationType(record.getJson()); - usageCounts.merge(relationType, 1, Integer::sum); + Map usageCounts = new HashMap<>(); + for (List row : rows) { + usageCounts.put(row.get(0), Integer.parseInt(row.get(1))); } - return usageCounts; } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MlModelRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MlModelRepository.java index d6d6f0eb16f..b134ac2e659 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MlModelRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MlModelRepository.java @@ -29,6 +29,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import lombok.extern.slf4j.Slf4j; import org.jdbi.v3.sqlobject.transaction.Transaction; @@ -61,6 +62,7 @@ import org.openmetadata.service.util.FullyQualifiedName; public class MlModelRepository extends EntityRepository { private static final String MODEL_UPDATE_FIELDS = "dashboard"; private static final String MODEL_PATCH_FIELDS = "dashboard"; + private static final Set CHANGE_SUMMARY_FIELDS = Set.of("mlFeatures.description"); public MlModelRepository() { super( @@ -69,7 +71,8 @@ public class MlModelRepository extends EntityRepository { MlModel.class, Entity.getCollectionDAO().mlModelDAO(), MODEL_PATCH_FIELDS, - MODEL_UPDATE_FIELDS); + MODEL_UPDATE_FIELDS, + CHANGE_SUMMARY_FIELDS); supportsSearch = true; // Register bulk field fetchers for efficient database operations @@ -519,6 +522,31 @@ public class MlModelRepository extends EntityRepository { addedList, deletedList, mlFeatureMatch); + + for (MlFeature updatedFeature : listOrEmpty(updatedModel.getMlFeatures())) { + MlFeature storedFeature = + listOrEmpty(origModel.getMlFeatures()).stream() + .filter(feature -> mlFeatureMatch.test(feature, updatedFeature)) + .findAny() + .orElse(null); + if (storedFeature == null) { + continue; + } + + updateMlFeatureDescription(storedFeature, updatedFeature); + } + } + + private void updateMlFeatureDescription(MlFeature originalFeature, MlFeature updatedFeature) { + if (operation.isPut() && !nullOrEmpty(originalFeature.getDescription()) && updatedByBot()) { + updatedFeature.setDescription(originalFeature.getDescription()); + return; + } + + recordChange( + "mlFeatures." + originalFeature.getName() + ".description", + originalFeature.getDescription(), + updatedFeature.getDescription()); } private void updateMlHyperParameters(MlModel origModel, MlModel updatedModel) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PipelineRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PipelineRepository.java index ee5755cb0f1..8684fed27e8 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PipelineRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PipelineRepository.java @@ -98,6 +98,7 @@ public class PipelineRepository extends EntityRepository { private static final String TASKS_FIELD = "tasks"; private static final String PIPELINE_UPDATE_FIELDS = "tasks"; private static final String PIPELINE_PATCH_FIELDS = "tasks"; + private static final Set CHANGE_SUMMARY_FIELDS = Set.of("tasks.description"); public static final String PIPELINE_STATUS_EXTENSION = "pipeline.pipelineStatus"; public PipelineRepository() { @@ -107,7 +108,8 @@ public class PipelineRepository extends EntityRepository { Pipeline.class, Entity.getCollectionDAO().pipelineDAO(), PIPELINE_PATCH_FIELDS, - PIPELINE_UPDATE_FIELDS); + PIPELINE_UPDATE_FIELDS, + CHANGE_SUMMARY_FIELDS); supportsSearch = true; // Register bulk field fetchers for efficient database operations diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SearchIndexRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SearchIndexRepository.java index eff2714e7eb..d388487e11f 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SearchIndexRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SearchIndexRepository.java @@ -33,6 +33,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.function.BiPredicate; import java.util.function.Function; @@ -64,6 +65,7 @@ import org.openmetadata.service.util.EntityUtil.RelationIncludes; import org.openmetadata.service.util.FullyQualifiedName; public class SearchIndexRepository extends EntityRepository { + private static final Set CHANGE_SUMMARY_FIELDS = Set.of("fields.description"); public SearchIndexRepository() { super( @@ -72,7 +74,8 @@ public class SearchIndexRepository extends EntityRepository { SearchIndex.class, Entity.getCollectionDAO().searchIndexDAO(), "", - ""); + "", + CHANGE_SUMMARY_FIELDS); supportsSearch = true; // Register bulk field fetchers for efficient database operations diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SuggestionRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SuggestionRepository.java index 16cc0aa53ab..ddcbbaa090c 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SuggestionRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SuggestionRepository.java @@ -273,12 +273,21 @@ public class SuggestionRepository { // Patch the entity with the updated suggestions JsonPatch patch = JsonUtils.getJsonPatch(origJson, updatedEntityJson); - OperationContext operationContext = new OperationContext(entityLink.getEntityType(), patch); - authorizer.authorize( - securityContext, - operationContext, - new ResourceContext<>(entityLink.getEntityType(), entity.getId(), null)); - repository.patch(null, entity.getId(), user, patch, ChangeSource.SUGGESTED); + if (!patch.toJsonArray().isEmpty()) { + OperationContext operationContext = new OperationContext(entityLink.getEntityType(), patch); + authorizer.authorize( + securityContext, + operationContext, + new ResourceContext<>(entityLink.getEntityType(), entity.getId(), null)); + repository.patch(null, entity.getId(), user, patch, ChangeSource.SUGGESTED); + } else { + // The suggestion sets the same value already present — update changeSummary only + String changeSummaryField = resolveChangeSummaryField(suggestion, entityLink); + if (changeSummaryField != null) { + repository.patchChangeSummary( + entity.getId(), changeSummaryField, ChangeSource.SUGGESTED, user); + } + } suggestion.setStatus(SuggestionStatus.Accepted); update(suggestion, user); } @@ -293,6 +302,7 @@ public class SuggestionRepository { EntityRepository repository = null; String origJson = null; SuggestionWorkflow suggestionWorkflow = null; + List noOpSuggestions = new ArrayList<>(); for (Suggestion suggestion : suggestions) { MessageParser.EntityLink entityLink = @@ -309,20 +319,37 @@ public class SuggestionRepository { } else if (!entity.getFullyQualifiedName().equals(entityLink.getEntityFQN())) { throw new SuggestionException("All suggestions must be for the same entity"); } - // update entity with the suggestion + // Track whether this suggestion changes anything + String beforeJson = JsonUtils.pojoToJson(entity); entity = suggestionWorkflow.acceptSuggestion(suggestion, entity); + String afterJson = JsonUtils.pojoToJson(entity); + if (beforeJson.equals(afterJson)) { + noOpSuggestions.add(suggestion); + } } // Patch the entity with the updated suggestions String updatedEntityJson = JsonUtils.pojoToJson(entity); JsonPatch patch = JsonUtils.getJsonPatch(origJson, updatedEntityJson); - OperationContext operationContext = new OperationContext(repository.getEntityType(), patch); - authorizer.authorize( - securityContext, - operationContext, - new ResourceContext<>(repository.getEntityType(), entity.getId(), null)); - repository.patch(null, entity.getId(), user, patch, ChangeSource.SUGGESTED); + if (!patch.toJsonArray().isEmpty()) { + OperationContext operationContext = new OperationContext(repository.getEntityType(), patch); + authorizer.authorize( + securityContext, + operationContext, + new ResourceContext<>(repository.getEntityType(), entity.getId(), null)); + repository.patch(null, entity.getId(), user, patch, ChangeSource.SUGGESTED); + } + + // Record changeSummary for no-op suggestions (value already present on entity) + for (Suggestion suggestion : noOpSuggestions) { + MessageParser.EntityLink link = MessageParser.EntityLink.parse(suggestion.getEntityLink()); + String changeSummaryField = resolveChangeSummaryField(suggestion, link); + if (changeSummaryField != null) { + repository.patchChangeSummary( + entity.getId(), changeSummaryField, ChangeSource.SUGGESTED, user); + } + } // Only mark the suggestions as accepted after the entity has been successfully updated for (Suggestion suggestion : suggestions) { @@ -331,6 +358,26 @@ public class SuggestionRepository { } } + /** + * Determine the changeSummary field name for a suggestion based on its type and entity link. + * Returns null if the suggestion type does not map to a tracked changeSummary field. + */ + private static String resolveChangeSummaryField( + Suggestion suggestion, MessageParser.EntityLink entityLink) { + if (suggestion.getType() != SuggestionType.SuggestDescription) { + return null; + } + if (entityLink.getFieldName() == null) { + return "description"; + } + // Column-level: "columns.columnName.description" + if (entityLink.getArrayFieldName() != null) { + return FullyQualifiedName.build( + entityLink.getFieldName(), entityLink.getArrayFieldName(), "description"); + } + return "description"; + } + public RestUtil.PutResponse rejectSuggestion( UriInfo uriInfo, Suggestion suggestion, String user) { suggestion.setStatus(SuggestionStatus.Rejected); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TopicRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TopicRepository.java index 4494016e2cb..3768074fa9b 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TopicRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TopicRepository.java @@ -67,6 +67,8 @@ import org.openmetadata.service.util.EntityUtil.RelationIncludes; import org.openmetadata.service.util.FullyQualifiedName; public class TopicRepository extends EntityRepository { + private static final Set CHANGE_SUMMARY_FIELDS = + Set.of("messageSchema.schemaFields.description"); public TopicRepository() { super( @@ -75,7 +77,8 @@ public class TopicRepository extends EntityRepository { Topic.class, Entity.getCollectionDAO().topicDAO(), "", - ""); + "", + CHANGE_SUMMARY_FIELDS); supportsSearch = true; // Register bulk field fetchers for efficient database operations diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/ChangeSummaryResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ChangeSummaryResource.java new file mode 100644 index 00000000000..3b0f7b0e2d5 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/ChangeSummaryResource.java @@ -0,0 +1,228 @@ +/* + * Copyright 2021 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; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +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.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.openmetadata.schema.EntityInterface; +import org.openmetadata.schema.type.ChangeDescription; +import org.openmetadata.schema.type.ChangeSummaryMap; +import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.change.ChangeSummary; +import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.EntityRepository; +import org.openmetadata.service.security.Authorizer; +import org.openmetadata.service.security.policyevaluator.OperationContext; +import org.openmetadata.service.security.policyevaluator.ResourceContext; + +@Slf4j +@Path("/v1/changeSummary") +@Tag( + name = "ChangeSummary", + description = + "APIs to retrieve change summary metadata for entities. " + + "Change summary tracks who changed each field, the source of the change " + + "(e.g., Suggested for AI-generated, Manual for user edits), and when the change occurred.") +@Produces(MediaType.APPLICATION_JSON) +@Collection(name = "changeSummary") +public class ChangeSummaryResource { + + private final Authorizer authorizer; + + public ChangeSummaryResource(Authorizer authorizer) { + this.authorizer = authorizer; + } + + @GET + @Path("/{entityType}/{id}") + @Operation( + operationId = "getChangeSummaryById", + summary = "Get change summary for an entity by ID", + description = + "Returns the change summary map for the specified entity, showing who changed each field, " + + "the source of the change (Manual, Suggested, Automated, etc.), and when it was changed. " + + "Use fieldPrefix to filter entries (e.g., 'columns.' for column-level changes only).", + responses = { + @ApiResponse(responseCode = "200", description = "Change summary map"), + @ApiResponse(responseCode = "404", description = "Entity not found") + }) + public Response getChangeSummaryById( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter( + description = "Entity type (e.g., table, topic, dashboard)", + schema = @Schema(type = "string")) + @PathParam("entityType") + String entityType, + @Parameter(description = "Entity ID", schema = @Schema(type = "UUID")) @PathParam("id") + UUID id, + @Parameter( + description = + "Filter entries by field name prefix (e.g., 'columns.' to get only column-level changes)", + schema = @Schema(type = "string")) + @QueryParam("fieldPrefix") + String fieldPrefix, + @Parameter( + description = "Limit the number of entries returned (1-1000)", + schema = @Schema(type = "integer")) + @QueryParam("limit") + @DefaultValue("10") + @Min(1) + @Max(1000) + int limit, + @Parameter(description = "Offset for pagination", schema = @Schema(type = "integer")) + @QueryParam("offset") + @DefaultValue("0") + @Min(0) + int offset) { + + OperationContext operationContext = + new OperationContext(entityType, MetadataOperation.VIEW_BASIC); + ResourceContext resourceContext = new ResourceContext<>(entityType, id, null); + authorizer.authorize(securityContext, operationContext, resourceContext); + + EntityRepository repository = Entity.getEntityRepository(entityType); + EntityInterface entity = + (EntityInterface) repository.get(uriInfo, id, repository.getFields("changeDescription")); + + return buildResponse(entity, fieldPrefix, limit, offset); + } + + @GET + @Path("/{entityType}/name/{fqn}") + @Operation( + operationId = "getChangeSummaryByFqn", + summary = "Get change summary for an entity by fully qualified name", + description = + "Returns the change summary map for the specified entity identified by its " + + "fully qualified name (FQN). Use fieldPrefix to filter entries " + + "(e.g., 'columns.' for column-level changes only).", + responses = { + @ApiResponse(responseCode = "200", description = "Change summary map"), + @ApiResponse(responseCode = "404", description = "Entity not found") + }) + public Response getChangeSummaryByFqn( + @Context UriInfo uriInfo, + @Context SecurityContext securityContext, + @Parameter( + description = "Entity type (e.g., table, topic, dashboard)", + schema = @Schema(type = "string")) + @PathParam("entityType") + String entityType, + @Parameter( + description = "Fully qualified name of the entity", + schema = @Schema(type = "string")) + @PathParam("fqn") + String fqn, + @Parameter( + description = + "Filter entries by field name prefix (e.g., 'columns.' to get only column-level changes)", + schema = @Schema(type = "string")) + @QueryParam("fieldPrefix") + String fieldPrefix, + @Parameter( + description = "Limit the number of entries returned (1-1000)", + schema = @Schema(type = "integer")) + @QueryParam("limit") + @DefaultValue("10") + @Min(1) + @Max(1000) + int limit, + @Parameter(description = "Offset for pagination", schema = @Schema(type = "integer")) + @QueryParam("offset") + @DefaultValue("0") + @Min(0) + int offset) { + + OperationContext operationContext = + new OperationContext(entityType, MetadataOperation.VIEW_BASIC); + ResourceContext resourceContext = new ResourceContext<>(entityType, null, fqn); + authorizer.authorize(securityContext, operationContext, resourceContext); + + EntityRepository repository = Entity.getEntityRepository(entityType); + EntityInterface entity = + (EntityInterface) + repository.getByName(uriInfo, fqn, repository.getFields("changeDescription")); + + return buildResponse(entity, fieldPrefix, limit, offset); + } + + private Response buildResponse( + EntityInterface entity, String fieldPrefix, int limit, int offset) { + ChangeDescription changeDescription = entity.getChangeDescription(); + ChangeSummaryMap changeSummaryMap = + changeDescription != null ? changeDescription.getChangeSummary() : null; + Map changeSummary = + changeSummaryMap != null ? changeSummaryMap.getAdditionalProperties() : null; + + if (changeSummary == null || changeSummary.isEmpty()) { + return Response.ok(Map.of("changeSummary", Map.of(), "totalEntries", 0)).build(); + } + + // Apply field prefix filter + Map filtered; + if (fieldPrefix != null && !fieldPrefix.isEmpty()) { + filtered = new LinkedHashMap<>(); + for (Map.Entry entry : changeSummary.entrySet()) { + if (entry.getKey().startsWith(fieldPrefix)) { + filtered.put(entry.getKey(), entry.getValue()); + } + } + } else { + filtered = changeSummary; + } + + // Apply pagination + Map paginated = new LinkedHashMap<>(); + int count = 0; + int added = 0; + for (Map.Entry entry : filtered.entrySet()) { + if (count >= offset) { + if (added >= limit) { + break; + } + paginated.put(entry.getKey(), entry.getValue()); + added++; + } + count++; + } + return Response.ok( + Map.of( + "changeSummary", paginated, + "totalEntries", filtered.size(), + "offset", offset, + "limit", limit)) + .build(); + } +} diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/ChangeSummarizerTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/ChangeSummarizerTest.java index 6b9a51a387f..8217c807f76 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/ChangeSummarizerTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/jdbi3/ChangeSummarizerTest.java @@ -8,6 +8,7 @@ import java.util.Set; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.openmetadata.schema.entity.data.Container; import org.openmetadata.schema.entity.data.Table; import org.openmetadata.schema.type.FieldChange; import org.openmetadata.schema.type.change.ChangeSource; @@ -76,6 +77,21 @@ public class ChangeSummarizerTest { Assertions.assertTrue(result.containsKey(fieldName)); } + @Test + public void test_multiLevelNestedDescription() { + ChangeSummarizer containerChangeSummarizer = + new ChangeSummarizer<>(Container.class, Set.of("dataModel.columns.description")); + String fieldName = "dataModel.columns.column1.description"; + List changes = List.of(new FieldChange().withName(fieldName)); + + Map result = + containerChangeSummarizer.summarizeChanges( + Map.of(), changes, ChangeSource.MANUAL, "testUser", System.currentTimeMillis()); + + assertEquals(1, result.size()); + Assertions.assertTrue(result.containsKey(fieldName)); + } + @Test public void test_nonExistentField() { String fieldName = "nonExistentField"; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/dataContracts.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/dataContracts.ts index aaa6bc32e6c..4ff47552451 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/dataContracts.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/dataContracts.ts @@ -506,8 +506,6 @@ version: "2.1.0" status: active description: purpose: Comprehensive data contract for customer analytics. - limitations: Historical data only, no PII exposed. - usage: For internal analytics dashboards and ML models. slaProperties: - property: freshness value: "12" diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ChangeSummaryBadge.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ChangeSummaryBadge.spec.ts new file mode 100644 index 00000000000..74de3c722e3 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/ChangeSummaryBadge.spec.ts @@ -0,0 +1,311 @@ +/* + * Copyright 2026 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 { expect } from '@playwright/test'; +import { DOMAIN_TAGS } from '../../constant/config'; +import { TableClass } from '../../support/entity/TableClass'; +import { performAdminLogin } from '../../utils/admin'; +import { redirectToHomePage } from '../../utils/common'; +import { waitForAllLoadersToDisappear } from '../../utils/entity'; +import { test } from '../fixtures/pages'; + +const table = new TableClass(); + +test.describe( + 'ChangeSummary DescriptionSourceBadge', + { tag: [DOMAIN_TAGS.DISCOVERY] }, + () => { + test.beforeAll('Setup test entities', async ({ browser }) => { + const { apiContext, afterAction } = await performAdminLogin(browser); + + await table.create(apiContext); + + await table.patch({ + apiContext, + patchData: [ + { + op: 'add', + path: '/description', + value: 'AI-generated entity description for badge test', + }, + ], + queryParams: { changeSource: 'Suggested' }, + }); + + const columnName = table.entityResponseData?.columns?.[0]?.name; + + await table.patch({ + apiContext, + patchData: [ + { + op: 'add', + path: `/columns/0/description`, + value: 'AI-generated column description for badge test', + }, + ], + queryParams: { changeSource: 'Suggested' }, + }); + + const changeSummaryResponse = await apiContext.get( + `/api/v1/changeSummary/table/${table.entityResponseData?.id}` + ); + + expect(changeSummaryResponse.status()).toBe(200); + + const changeSummaryData = await changeSummaryResponse.json(); + + expect(changeSummaryData.changeSummary).toHaveProperty('description'); + expect(changeSummaryData.changeSummary.description.changeSource).toBe( + 'Suggested' + ); + + expect( + changeSummaryData.changeSummary[`columns.${columnName}.description`] + ).toBeDefined(); + + await afterAction(); + }); + + test('AI badge should appear on entity description with Suggested source', async ({ + page, + }) => { + await redirectToHomePage(page); + + await test.step('Navigate to entity page and verify AI badge', async () => { + const changeSummaryResponse = page.waitForResponse((response) => + response.url().includes('/api/v1/changeSummary/') + ); + + await table.visitEntityPage(page); + await changeSummaryResponse; + await waitForAllLoadersToDisappear(page); + + const descriptionContainer = page.getByTestId( + 'asset-description-container' + ); + + await expect(descriptionContainer).toBeVisible(); + + const badge = descriptionContainer + .getByTestId('ai-suggested-badge') + .first(); + + await expect(badge).toBeVisible(); + }); + + await test.step('Verify badge tooltip shows metadata', async () => { + const badge = page + .getByTestId('asset-description-container') + .getByTestId('ai-suggested-badge') + .first(); + + await badge.hover(); + + const tooltip = page.locator('.ant-tooltip:visible'); + + await expect(tooltip).toBeVisible(); + }); + }); + + test('AI badge should appear on column description with Suggested source', async ({ + page, + }) => { + await redirectToHomePage(page); + + await test.step('Navigate to entity page and verify column badge', async () => { + const changeSummaryResponse = page.waitForResponse((response) => + response.url().includes('/api/v1/changeSummary/') + ); + + await table.visitEntityPage(page); + await changeSummaryResponse; + await waitForAllLoadersToDisappear(page); + + const descriptionCells = page + .getByTestId('description') + .getByTestId('ai-suggested-badge'); + + await expect(descriptionCells.first()).toBeVisible(); + }); + }); + + test('Automated badge should appear on entity description with Automated source', async ({ + browser, + page, + }) => { + const automatedTable = new TableClass(); + + await test.step('Create table with Automated description', async () => { + const { apiContext, afterAction } = await performAdminLogin(browser); + + await automatedTable.create(apiContext); + + await automatedTable.patch({ + apiContext, + patchData: [ + { + op: 'add', + path: '/description', + value: 'Automated description for badge test', + }, + ], + queryParams: { changeSource: 'Automated' }, + }); + + await automatedTable.patch({ + apiContext, + patchData: [ + { + op: 'add', + path: '/columns/0/description', + value: 'AI-generated column description for automated badge test', + }, + ], + queryParams: { changeSource: 'Automated' }, + }); + + await afterAction(); + }); + + await test.step('Navigate and verify Automated badge on entity description', async () => { + await redirectToHomePage(page); + + const changeSummaryResponse = page.waitForResponse((response) => + response.url().includes('/api/v1/changeSummary/') + ); + + await automatedTable.visitEntityPage(page); + await changeSummaryResponse; + await waitForAllLoadersToDisappear(page); + + const descriptionContainer = page.getByTestId( + 'asset-description-container' + ); + + await expect(descriptionContainer).toBeVisible(); + + const badge = descriptionContainer + .getByTestId('automated-badge') + .first(); + + await expect(badge).toBeVisible(); + }); + + await test.step('Verify AI badge on column description with Suggested source', async () => { + const columnBadge = page + .getByTestId('description') + .getByTestId('automated-badge'); + + await expect(columnBadge.first()).toBeVisible(); + }); + }); + + test('Propagated badge should appear on entity description with Propagated source', async ({ + browser, + page, + }) => { + const propagatedTable = new TableClass(); + + await test.step('Create table with Propagated description', async () => { + const { apiContext, afterAction } = await performAdminLogin(browser); + + await propagatedTable.create(apiContext); + + await propagatedTable.patch({ + apiContext, + patchData: [ + { + op: 'add', + path: '/description', + value: 'Propagated description for badge test', + }, + ], + queryParams: { changeSource: 'Propagated' }, + }); + + await afterAction(); + }); + + await test.step('Navigate and verify Propagated badge', async () => { + await redirectToHomePage(page); + + const changeSummaryResponse = page.waitForResponse((response) => + response.url().includes('/api/v1/changeSummary/') + ); + + await propagatedTable.visitEntityPage(page); + await changeSummaryResponse; + await waitForAllLoadersToDisappear(page); + + const descriptionContainer = page.getByTestId( + 'asset-description-container' + ); + + await expect(descriptionContainer).toBeVisible(); + + const badge = descriptionContainer + .getByTestId('propagated-badge') + .first(); + + await expect(badge).toBeVisible(); + }); + }); + + test('AI badge should NOT appear for manually-edited descriptions', async ({ + browser, + page, + }) => { + const manualTable = new TableClass(); + + await test.step('Create table with manual description', async () => { + const { apiContext, afterAction } = await performAdminLogin(browser); + + await manualTable.create(apiContext); + + await manualTable.patch({ + apiContext, + patchData: [ + { + op: 'add', + path: '/description', + value: 'Manually written description', + }, + ], + }); + await afterAction(); + }); + + await test.step('Navigate and verify no AI badge', async () => { + await redirectToHomePage(page); + + const changeSummaryResponse = page.waitForResponse((response) => + response.url().includes('/api/v1/changeSummary/') + ); + + await manualTable.visitEntityPage(page); + await changeSummaryResponse; + await waitForAllLoadersToDisappear(page); + + const descriptionContainer = page.getByTestId( + 'asset-description-container' + ); + + await expect(descriptionContainer).toBeVisible(); + + const badge = descriptionContainer.getByTestId('ai-suggested-badge'); + + await expect(badge).not.toBeVisible(); + }); + }); + } +); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/TableClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/TableClass.ts index e43f8cd81c7..880389aab1a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/TableClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/TableClass.ts @@ -496,14 +496,20 @@ export class TableClass extends EntityClass { async patch({ apiContext, patchData, + queryParams, }: { apiContext: APIRequestContext; patchData: Operation[]; + queryParams?: Record; }) { + const fqn = encodeURIComponent( + this.entityResponseData?.fullyQualifiedName ?? '' + ); + const queryString = queryParams + ? `?${new URLSearchParams(queryParams).toString()}` + : ''; const response = await apiContext.patch( - `/api/v1/tables/name/${encodeURIComponent( - this.entityResponseData?.fullyQualifiedName ?? '' - )}`, + `/api/v1/tables/name/${fqn}${queryString}`, { data: patchData, headers: { diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-ai-suggestion.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-ai-suggestion.svg new file mode 100644 index 00000000000..551fabfee44 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-ai-suggestion.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-automated.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-automated.svg new file mode 100644 index 00000000000..d15d257604e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-automated.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-check-circle.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-check-circle.svg index 027c5b96b4e..948d15e023c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-check-circle.svg +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-check-circle.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-propagated.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-propagated.svg new file mode 100644 index 00000000000..9bc048f36eb --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-propagated.svg @@ -0,0 +1,3 @@ + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Customization/GenericProvider/GenericProvider.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Customization/GenericProvider/GenericProvider.interface.ts index 9613895c193..61402fad997 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Customization/GenericProvider/GenericProvider.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Customization/GenericProvider/GenericProvider.interface.ts @@ -18,6 +18,7 @@ import { ThreadType } from '../../../generated/entity/feed/thread'; import { EntityReference } from '../../../generated/entity/type'; import { Page } from '../../../generated/system/ui/page'; import { WidgetConfig } from '../../../pages/CustomizablePage/CustomizablePage.interface'; +import { ChangeSummaryEntry } from '../../../rest/changeSummaryAPI'; import { ColumnOrTask } from '../../Database/ColumnDetailPanel/ColumnDetailPanel.interface'; export interface GenericProviderProps> { @@ -56,4 +57,5 @@ export interface GenericContextType> { openColumnDetailPanel: (column: ColumnOrTask) => void; closeColumnDetailPanel: () => void; setDisplayedColumns: (columns: ColumnOrTask[]) => void; + changeSummary?: Record; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Customization/GenericProvider/GenericProvider.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Customization/GenericProvider/GenericProvider.tsx index 4ca806f3f1b..d9d1d28c8fd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Customization/GenericProvider/GenericProvider.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Customization/GenericProvider/GenericProvider.tsx @@ -30,6 +30,7 @@ import { CreateThread } from '../../../generated/api/feed/createThread'; import { Column, Table } from '../../../generated/entity/data/table'; import { ThreadType } from '../../../generated/entity/feed/thread'; import { EntityReference } from '../../../generated/entity/type'; +import { useChangeSummary } from '../../../hooks/useChangeSummary'; import { useEntityRules } from '../../../hooks/useEntityRules'; import { WidgetConfig } from '../../../pages/CustomizablePage/CustomizablePage.interface'; import { postThread } from '../../../rest/feedsAPI'; @@ -113,6 +114,15 @@ export const GenericProvider = >({ const { entityRules } = useEntityRules(type); + // limit=1000 is the backend max. Entities with more tracked field changes + // will have entries beyond this limit silently omitted. Use fieldPrefix + // filtering when targeting a specific section (e.g., 'columns.'). + const { changeSummary } = useChangeSummary( + isVersionView ? '' : type, + isVersionView ? '' : data.id ?? '', + { limit: 1000 } + ); + // Extract columns from data const extractedColumns = useMemo(() => { return extractColumnsFromData(data, type) as ColumnOrTask[]; @@ -417,6 +427,7 @@ export const GenericProvider = >({ openColumnDetailPanel, closeColumnDetailPanel, setDisplayedColumns, + changeSummary, }), [ data, @@ -438,6 +449,7 @@ export const GenericProvider = >({ openColumnDetailPanel, closeColumnDetailPanel, setDisplayedColumns, + changeSummary, ] ); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssetSummaryPanelV1/DataAssetSummaryPanelV1.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssetSummaryPanelV1/DataAssetSummaryPanelV1.test.tsx index f4caff39344..6f58fbf5c85 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssetSummaryPanelV1/DataAssetSummaryPanelV1.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssetSummaryPanelV1/DataAssetSummaryPanelV1.test.tsx @@ -26,6 +26,7 @@ import { useTourProvider } from '../../context/TourProvider/TourProvider'; import { EntityType } from '../../enums/entity.enum'; import { TestCaseStatus } from '../../generated/tests/testCase'; import { TagSource } from '../../generated/type/tagLabel'; +import { useChangeSummary } from '../../hooks/useChangeSummary'; import { patchDashboardDetails } from '../../rest/dashboardAPI'; import { getListTestCaseIncidentStatus } from '../../rest/incidentManagerAPI'; import { patchTableDetails } from '../../rest/tableAPI'; @@ -70,6 +71,10 @@ jest.mock('../../context/TourProvider/TourProvider', () => ({ useTourProvider: jest.fn(), })); +jest.mock('../../hooks/useChangeSummary', () => ({ + useChangeSummary: jest.fn(), +})); + jest.mock('../../rest/incidentManagerAPI', () => ({ getListTestCaseIncidentStatus: jest.fn(), })); @@ -404,6 +409,13 @@ describe('DataAssetSummaryPanelV1', () => { (useTourProvider as jest.Mock).mockReturnValue({ isTourPage: false, }); + (useChangeSummary as jest.Mock).mockReturnValue({ + changeSummary: {}, + totalEntries: 0, + isLoading: false, + error: null, + refetch: jest.fn(), + }); // Setup API mocks with fresh instances mockGetEntityPermission.mockResolvedValue(mockEntityPermissions); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssetSummaryPanelV1/DataAssetSummaryPanelV1.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssetSummaryPanelV1/DataAssetSummaryPanelV1.tsx index 9d80e5599d6..77ed70bebf4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssetSummaryPanelV1/DataAssetSummaryPanelV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssetSummaryPanelV1/DataAssetSummaryPanelV1.tsx @@ -37,6 +37,7 @@ import { EntityType } from '../../enums/entity.enum'; import { EntityReference } from '../../generated/entity/type'; import { TagLabel, TestCaseStatus } from '../../generated/tests/testCase'; import { TagSource } from '../../generated/type/tagLabel'; +import { useChangeSummary } from '../../hooks/useChangeSummary'; import { getListTestCaseIncidentStatus } from '../../rest/incidentManagerAPI'; import { updateTableColumn } from '../../rest/tableAPI'; import { listTestCases } from '../../rest/testAPI'; @@ -173,6 +174,11 @@ export const DataAssetSummaryPanelV1 = ({ [entityType] ); + const { changeSummary } = useChangeSummary(entityType, dataAsset.id ?? '', { + fieldPrefix: 'description', + limit: 1, + }); + const fetchIncidentCount = useCallback(async () => { if ( dataAsset?.fullyQualifiedName && @@ -368,6 +374,8 @@ export const DataAssetSummaryPanelV1 = ({ }, [entityPermissions, dataAsset?.fullyQualifiedName]); const commonEntitySummaryInfo = useMemo(() => { + const descriptionChangeSummaryEntry = changeSummary?.description; + switch (entityType) { case EntityType.API_COLLECTION: case EntityType.API_ENDPOINT: @@ -463,6 +471,7 @@ export const DataAssetSummaryPanelV1 = ({ /> )} { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Database/ColumnDetailPanel/ColumnDetailPanel.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Database/ColumnDetailPanel/ColumnDetailPanel.component.tsx index 03393004b9a..feb291e7c47 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Database/ColumnDetailPanel/ColumnDetailPanel.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Database/ColumnDetailPanel/ColumnDetailPanel.component.tsx @@ -43,6 +43,7 @@ import { } from '../../../rest/tableAPI'; import { listTestCases } from '../../../rest/testAPI'; import { calculateTestCaseStatusCounts } from '../../../utils/DataQuality/DataQualityUtils'; +import EntityLink from '../../../utils/EntityLink'; import { toEntityData } from '../../../utils/EntitySummaryPanelUtils'; import { getEntityName } from '../../../utils/EntityUtils'; import { getErrorText, stringToHTML } from '../../../utils/StringsUtils'; @@ -101,7 +102,7 @@ export const ColumnDetailPanel = ({ onColumnsUpdate, }: ColumnDetailPanelProps) => { const { t } = useTranslation(); - const { permissions } = useGenericContext(); + const { permissions, changeSummary } = useGenericContext(); const previousFqnRef = useRef(); const fetchedColumnFqnRef = useRef(); @@ -690,6 +691,14 @@ export const ColumnDetailPanel = ({ ) : ( { const { t } = useTranslation(); const { selectedUserSuggestions } = useSuggestionsContext(); - const { onThreadLinkSelect } = useGenericContext(); + const { onThreadLinkSelect, changeSummary } = useGenericContext(); + + const changeSummaryKey = useMemo(() => { + const columnName = + entityType === EntityType.TABLE + ? EntityLink.getTableColumnNameFromColumnFqn(columnData.fqn, false) + : columnData.fqn; + + return `columns.${columnName}.description`; + }, [entityType, columnData.fqn]); const entityLink = useMemo( () => @@ -92,6 +102,9 @@ const TableDescription = ({ direction="vertical" id={`field-description-${index}`}> {descriptionContent} + {!suggestionData && !isReadOnly ? (

diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSection/DescriptionSection.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSection/DescriptionSection.interface.ts index e75c2e5dad3..b162cf0faf8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSection/DescriptionSection.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSection/DescriptionSection.interface.ts @@ -11,6 +11,7 @@ * limitations under the License. */ import { EntityType } from '../../../enums/entity.enum'; +import { ChangeSummaryEntry } from '../../../rest/changeSummaryAPI'; export interface DescriptionSectionProps { description?: string; @@ -19,4 +20,5 @@ export interface DescriptionSectionProps { entityFqn?: string; entityType: EntityType; hasPermission?: boolean; + changeSummaryEntry?: ChangeSummaryEntry; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSection/DescriptionSection.less b/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSection/DescriptionSection.less index e6bd3fb60f6..33e26687fbe 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSection/DescriptionSection.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSection/DescriptionSection.less @@ -23,9 +23,19 @@ } .description-header { display: flex; + align-items: flex-start; + justify-content: space-between; gap: 8px; margin-bottom: 12px; + .description-title-row { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + min-width: 0; + } + .description-title { font-weight: 600; font-size: 13px; @@ -78,11 +88,19 @@ color: @primary-6; } } + + .description-metadata { + margin-top: 10px; + } } .no-data-placeholder { color: @grey-500; font-size: 12px; } + + .description-metadata { + margin-top: 10px; + } } } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSection/DescriptionSection.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSection/DescriptionSection.tsx index 18e720faa5e..17543fcf531 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSection/DescriptionSection.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSection/DescriptionSection.tsx @@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next'; import { ReactComponent as EditIcon } from '../../../assets/svg/edit-new.svg'; import { DE_ACTIVE_COLOR } from '../../../constants/constants'; import { ModalWithMarkdownEditor } from '../../Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor'; +import DescriptionSourceBadge from '../DescriptionSourceBadge/DescriptionSourceBadge'; import { EntityAttachmentProvider } from '../EntityDescription/EntityAttachmentProvider/EntityAttachmentProvider'; import { EditIconButton } from '../IconButtons/EditIconButton'; import RichTextEditorPreviewerV1 from '../RichTextEditor/RichTextEditorPreviewerV1'; @@ -28,6 +29,7 @@ const DescriptionSection: React.FC = ({ hasPermission = false, entityFqn, entityType, + changeSummaryEntry, }) => { const { t } = useTranslation(); const [isExpanded, setIsExpanded] = useState(false); @@ -133,13 +135,31 @@ const DescriptionSection: React.FC = ({ const canShowEditButton = showEditButton && hasPermission && onDescriptionUpdate; + const shouldShowMetadata = changeSummaryEntry?.changeSource != null; + + const headerBadge = ( + + ); + + const metadataRow = ( + + ); if (!description?.trim()) { return (
- {t('label.description')} +
+ + {t('label.description')} + + {headerBadge} +
{canShowEditButton && ( = ({ entity: t('label.description-lowercase'), })} + {shouldShowMetadata ? ( +
{metadataRow}
+ ) : null} = ({
- {t('label.description')} +
+ {t('label.description')} + {headerBadge} +
{canShowEditButton && ( = ({ {isExpanded ? t('label.show-less') : t('label.show-more')} )} + {shouldShowMetadata ? ( +
{metadataRow}
+ ) : null} ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +jest.mock('../../../utils/date-time/DateTimeUtils', () => ({ + formatDate: jest.fn((ts: number) => `date-${ts}`), + formatDateTime: jest.fn((ts: number) => `formatted-${ts}`), + getShortRelativeTime: jest.fn((ts: number) => `relative-${ts}`), +})); + +jest.mock('../../../assets/svg/ic-ai-suggestion.svg', () => ({ + ReactComponent: () =>
, +})); + +jest.mock('../../../assets/svg/ic-automated.svg', () => ({ + ReactComponent: () =>
, +})); + +jest.mock('../../../assets/svg/ic-propagated.svg', () => ({ + ReactComponent: () =>
, +})); + +jest.mock('../../../assets/svg/ic-check-circle.svg', () => ({ + ReactComponent: () =>
, +})); + +jest.mock('../PopOverCard/UserPopOverCard', () => ({ + __esModule: true, + default: ({ displayName }: { displayName: string }) => ( + {displayName} + ), +})); + +jest.mock('antd', () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +describe('DescriptionSourceBadge', () => { + it('should render nothing when changeSummaryEntry is undefined', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should render nothing when changeSource is undefined', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should render authored-by metadata for Manual changeSource', () => { + render( + + ); + + expect(screen.getByTestId('source-actor')).toHaveTextContent( + /label\.authored-by.*admin/ + ); + expect(screen.getByTestId('source-timestamp')).toHaveTextContent( + 'relative-1700000000000' + ); + }); + + it('should render AI suggestion icon for Suggested changeSource', () => { + render( + + ); + + const badge = screen.getByTestId('ai-suggested-badge'); + + expect(badge).toBeInTheDocument(); + expect(badge.tagName).toBe('OUTPUT'); + expect(screen.queryByText('label.ai')).not.toBeInTheDocument(); + }); + + it('should render Automated badge for Automated changeSource', () => { + render( + + ); + + const badge = screen.getByTestId('automated-badge'); + + expect(badge).toBeInTheDocument(); + expect(badge.tagName).toBe('OUTPUT'); + expect(screen.queryByText('label.automated')).not.toBeInTheDocument(); + }); + + it('should render Propagated badge for Propagated changeSource', () => { + render( + + ); + + const badge = screen.getByTestId('propagated-badge'); + + expect(badge).toBeInTheDocument(); + expect(badge.tagName).toBe('OUTPUT'); + expect(screen.queryByText('label.propagated')).not.toBeInTheDocument(); + }); + + it('should render accepted-by metadata when changedBy is present', () => { + render( + + ); + + const actor = screen.getByTestId('source-actor'); + + expect(actor).toBeInTheDocument(); + expect(actor).toHaveTextContent('John Doe'); + }); + + it('should not render accepted-by metadata when changedBy is absent', () => { + render( + + ); + + expect(screen.queryByTestId('source-actor')).not.toBeInTheDocument(); + }); + + it('should render badge only when metadata is disabled', () => { + render( + + ); + + expect(screen.getByTestId('ai-suggested-badge')).toBeInTheDocument(); + expect(screen.queryByTestId('source-actor')).not.toBeInTheDocument(); + expect(screen.queryByTestId('source-timestamp')).not.toBeInTheDocument(); + }); + + it('should not render badge for Ingested changeSource', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); + + it('should not render badge for Derived changeSource', () => { + const { container } = render( + + ); + + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSourceBadge/DescriptionSourceBadge.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSourceBadge/DescriptionSourceBadge.tsx new file mode 100644 index 00000000000..af2ca2a433b --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSourceBadge/DescriptionSourceBadge.tsx @@ -0,0 +1,188 @@ +/* + * 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 { Tooltip } from 'antd'; +import classNames from 'classnames'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as AISuggestionIcon } from '../../../assets/svg/ic-ai-suggestion.svg'; +import { ReactComponent as AutomatedIcon } from '../../../assets/svg/ic-automated.svg'; +import { ReactComponent as CheckCircleIcon } from '../../../assets/svg/ic-check-circle.svg'; +import { ReactComponent as PropagatedIcon } from '../../../assets/svg/ic-propagated.svg'; +import { ChangeSource } from '../../../generated/type/changeSummaryMap'; +import { + formatDate, + formatDateTime, + getShortRelativeTime, +} from '../../../utils/date-time/DateTimeUtils'; +import UserPopOverCard from '../PopOverCard/UserPopOverCard'; +import './description-source-badge.less'; +import { DescriptionSourceBadgeProps } from './DescriptionSourceBadge.interface'; + +interface BadgeConfig { + labelKey: string; + tooltipKey: string; + icon: React.ReactNode; + className: string; + testId: string; + iconOnly?: boolean; +} + +const BADGE_CONFIG: Partial> = { + [ChangeSource.Suggested]: { + labelKey: 'label.ai', + tooltipKey: 'label.ai-generated-description', + icon: , + className: 'badge-suggested', + testId: 'ai-suggested-badge', + iconOnly: true, + }, + [ChangeSource.Automated]: { + labelKey: 'label.automated', + tooltipKey: 'label.automated-description', + icon: , + className: 'badge-automated', + testId: 'automated-badge', + iconOnly: true, + }, + [ChangeSource.Propagated]: { + labelKey: 'label.propagated', + tooltipKey: 'label.description-inherited-from-parent-entity', + icon: , + className: 'badge-propagated', + testId: 'propagated-badge', + iconOnly: true, + }, +}; + +const DescriptionSourceBadge = ({ + changeSummaryEntry, + showAcceptedBy = true, + showBadge = true, + showTimestamp = true, +}: DescriptionSourceBadgeProps) => { + const { t } = useTranslation(); + + const config = useMemo(() => { + if (!changeSummaryEntry?.changeSource) { + return null; + } + + return BADGE_CONFIG[changeSummaryEntry.changeSource] ?? null; + }, [changeSummaryEntry?.changeSource]); + + const tooltipContent = changeSummaryEntry?.changedAt + ? formatDateTime(changeSummaryEntry.changedAt) + : undefined; + + const renderTooltipContent = useMemo(() => { + if (!showBadge || !config) { + return ''; + } + + return ( + <> + {config.iconOnly ? ( + + + {config.icon} + + + ) : ( + +
+ {config.icon} + {t(config.labelKey)} +
+
+ )} + + ); + }, [showBadge, config, t, tooltipContent]); + + const isManualChange = + changeSummaryEntry?.changeSource === ChangeSource.Manual; + + if (!config && !isManualChange) { + return null; + } + + const actorLabel = config ? t('label.accepted-by') : t('label.authored-by'); + + const relativeTime = changeSummaryEntry?.changedAt + ? getShortRelativeTime(changeSummaryEntry.changedAt) || + formatDate(changeSummaryEntry.changedAt) + : ''; + + const actorInfo = + showAcceptedBy && changeSummaryEntry?.changedBy ? ( + + {config ? ( + + ) : null} + + {actorLabel} + + + + + ) : null; + + const timestampInfo = + showTimestamp && relativeTime ? ( + + {relativeTime} + + ) : null; + + if (!showBadge && !actorInfo && !timestampInfo) { + return null; + } + + return ( +
+ {renderTooltipContent} + {(actorInfo || timestampInfo) && ( +
+ {actorInfo} + {actorInfo && timestampInfo ? ( + + ) : null} + {timestampInfo} +
+ )} +
+ ); +}; + +export default DescriptionSourceBadge; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSourceBadge/description-source-badge.less b/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSourceBadge/description-source-badge.less new file mode 100644 index 00000000000..169a64dd3d5 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/DescriptionSourceBadge/description-source-badge.less @@ -0,0 +1,95 @@ +/* + * Copyright 2026 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'; + +.description-source-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 14px; + font-weight: 600; + line-height: 1; + cursor: default; + white-space: nowrap; + transition: filter 0.15s ease; + + svg { + flex-shrink: 0; + } + + &:hover { + filter: brightness(0.96); + } + + &.badge-suggested { + color: @purple-5; + + span { + background: linear-gradient(270deg, @purple-5 0%, @blue-7 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + } + + &.badge-automated { + color: @green-10; + } + + &.badge-propagated { + color: @blue-21; + } +} + +.description-source-container { + display: inline-flex; + align-items: self-start; + gap: 8px; + flex-wrap: nowrap; + + .description-source-icon { + margin-right: 4px; + display: flex; + } +} + +.description-source-metadata { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.description-source-text { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 500; + + .actor-username { + max-width: 90px; + } +} + +.description-source-separator { + color: @grey-400; + font-size: 12px; + line-height: 1; +} + +.description-source-time { + color: @grey-500; + font-size: 12px; + font-weight: 500; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/EntityDescription/DescriptionV1.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/EntityDescription/DescriptionV1.tsx index 95709571f95..f5991fd0f5b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/EntityDescription/DescriptionV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/EntityDescription/DescriptionV1.tsx @@ -31,6 +31,7 @@ import { ModalWithMarkdownEditor } from '../../Modals/ModalWithMarkdownEditor/Mo import SuggestionsAlert from '../../Suggestions/SuggestionsAlert/SuggestionsAlert'; import { useSuggestionsContext } from '../../Suggestions/SuggestionsProvider/SuggestionsProvider'; import SuggestionsSlider from '../../Suggestions/SuggestionsSlider/SuggestionsSlider'; +import DescriptionSourceBadge from '../DescriptionSourceBadge/DescriptionSourceBadge'; import ExpandableCard from '../ExpandableCard/ExpandableCard'; import { CommentIconButton, @@ -62,7 +63,7 @@ const DescriptionV1 = ({ entityFullyQualifiedName, }: DescriptionProps) => { const navigate = useNavigate(); - const { isVersionView } = useGenericContext(); + const { isVersionView, changeSummary } = useGenericContext(); const { suggestions, selectedUserSuggestions } = useSuggestionsContext(); const [isEditDescription, setIsEditDescription] = useState(false); const { fqn } = useFqn(); @@ -219,22 +220,36 @@ const DescriptionV1 = ({ } }, [description, suggestionData, isDescriptionExpanded]); + const shouldShowDescriptionMetadata = useMemo( + () => changeSummary?.['description']?.changeSource != null, + [changeSummary] + ); + const header = useMemo(() => { return (
0, - })}> -
- + className={classNames( + 'description-v1-header d-flex justify-between flex-wrap', + { + 'm-t-sm': suggestions?.length > 0, + } + )}> +
+ {t('label.description')} + {showActions && actionButtons}
{showSuggestions && suggestions?.length > 0 && }
); - }, [showActions, actionButtons, suggestions, showSuggestions]); + }, [showActions, actionButtons, suggestions, showSuggestions, changeSummary]); const content = ( @@ -247,9 +262,17 @@ const DescriptionV1 = ({ className={classNames('schema-description d-flex', className)} direction="vertical" size={16}> - {!wrapInCard ? header : null} + {wrapInCard ? null : header}
{descriptionContent} + {!suggestionData && shouldShowDescriptionMetadata && ( +
+ +
+ )} ; + totalEntries: number; + isLoading: boolean; + error: Error | null; + refetch: () => void; +} + +export const useChangeSummary = ( + entityType: string, + entityId: string, + params?: ChangeSummaryParams +): UseChangeSummaryResult => { + const [changeSummary, setChangeSummary] = useState< + Record + >({}); + const [totalEntries, setTotalEntries] = useState(0); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [refetchCount, setRefetchCount] = useState(0); + + const refetch = useCallback(() => { + setRefetchCount((c) => c + 1); + }, []); + + useEffect(() => { + let cancelled = false; + + const fetchData = async () => { + if (!entityType || !entityId) { + return; + } + + setIsLoading(true); + setError(null); + + try { + const response: ChangeSummaryResponse = await getChangeSummary( + entityType, + entityId, + params + ); + if (!cancelled) { + setChangeSummary(response.changeSummary ?? {}); + setTotalEntries(response.totalEntries ?? 0); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err : new Error(String(err))); + setChangeSummary({}); + setTotalEntries(0); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + fetchData(); + + return () => { + cancelled = true; + }; + }, [ + entityType, + entityId, + params?.fieldPrefix, + params?.limit, + params?.offset, + refetchCount, + ]); + + return { + changeSummary, + totalEntries, + isLoading, + error, + refetch, + }; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json index 863fa6ff06c..2cc2be88b77 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ar-sa.json @@ -8,6 +8,7 @@ "accept": "قبول", "accept-all": "قبول الكل", "accept-suggestion": "قبول الاقتراح", + "accepted-by": "قبله", "access": "الوصول", "access-block-time": "وقت حظر الوصول", "access-control": "التحكم في الوصول", @@ -92,6 +93,8 @@ "agent-activity": "Agent Activity", "agent-plural": "الوكلاء", "aggregate": "تجميع", + "ai": "AI", + "ai-generated-description": "وصف مُولَّد بالذكاء الاصطناعي", "ai-queries": "استعلامات الذكاء الاصطناعي", "airflow-config-plural": "تكوينات Airflow", "alert": "تنبيه", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "نوع المصادقة", "authentication-uri": "URI المصادقة", + "authored-by": "بقلم", "authority": "السلطة", "authorize-app": "ترخيص {{app}}", "auto-classification": "تصنيف تلقائي", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "وسم PII تلقائي", "auto-tier": "مستوى تلقائي", "automated": "مؤتمت", + "automated-description": "وصف تلقائي", "automatically-generate": "توليد تلقائي", "availability-time": "وقت التوافر", "average-daily-active-users-on-the-platform": "متوسط المستخدمين النشطين يوميًا على المنصة", @@ -589,6 +594,7 @@ "derived-from": "مشتق من", "derives": "يشتق", "description": "الوصف", + "description-inherited-from-parent-entity": "الوصف موروث من الكيان الأصل", "description-kpi": "مؤشر أداء الوصف", "description-lowercase": "الوصف", "description-plural": "أوصاف", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json index 38d70df4d09..8c839a676da 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/de-de.json @@ -8,6 +8,7 @@ "accept": "Akzeptieren", "accept-all": "Alle akzeptieren", "accept-suggestion": "Vorschlag akzeptieren", + "accepted-by": "Akzeptiert von", "access": "Zugriff", "access-block-time": "Zugangssperrzeit", "access-control": "Zugriffskontrolle", @@ -92,6 +93,8 @@ "agent-activity": "Agentenaktivität", "agent-plural": "Agenten", "aggregate": "Aggregiert", + "ai": "AI", + "ai-generated-description": "KI-generierte Beschreibung", "ai-queries": "KI-Abfragen", "airflow-config-plural": "Airflow-Konfigurationen", "alert": "Warnung", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "Authentifizierungstyp", "authentication-uri": "Authentifizierungs-URI", + "authored-by": "Verfasst von", "authority": "Behörde", "authorize-app": "{{app}} autorisieren", "auto-classification": "Automatische Klassifizierung", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "Auto PII-Tag", "auto-tier": "Automatische Ebene", "automated": "Automatisiert", + "automated-description": "Automatisierte Beschreibung", "automatically-generate": "Automatisch generieren", "availability-time": "Verfügbarkeitszeit", "average-daily-active-users-on-the-platform": "Durchschnittliche aktive Nutzer auf der Plattoform", @@ -589,6 +594,7 @@ "derived-from": "Abgeleitet von", "derives": "Leitet ab", "description": "Beschreibung", + "description-inherited-from-parent-entity": "Beschreibung vom übergeordneten Element geerbt", "description-kpi": "Beschreibung KPI", "description-lowercase": "beschreibung", "description-plural": "Beschreibungen", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json index 39dd8fd337f..0dcae91a965 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/en-us.json @@ -8,6 +8,7 @@ "accept": "Accept", "accept-all": "Accept All", "accept-suggestion": "Accept Suggestion", + "accepted-by": "Accepted by", "access": "Access", "access-block-time": "Access block time", "access-control": "Access Control", @@ -92,6 +93,8 @@ "agent-activity": "Agent Activity", "agent-plural": "Agents", "aggregate": "Aggregate", + "ai": "AI", + "ai-generated-description": "AI-generated Description", "ai-queries": "AI Queries", "airflow-config-plural": "airflow configs", "alert": "Alert", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "Authentication Type", "authentication-uri": "Authentication URI", + "authored-by": "Authored by", "authority": "Authority", "authorize-app": "Authorize {{app}}", "auto-classification": "Auto Classification", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "Auto Tag PII", "auto-tier": "Auto Tier", "automated": "Automated", + "automated-description": "Automated Description", "automatically-generate": "Automatically Generate", "availability-time": "Availability Time", "average-daily-active-users-on-the-platform": "Average Daily Active Users on the Platform", @@ -589,6 +594,7 @@ "derived-from": "Derived From", "derives": "Derives", "description": "Description", + "description-inherited-from-parent-entity": "Description inherited from parent entity", "description-kpi": "Description KPI", "description-lowercase": "description", "description-plural": "Descriptions", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json index fc0171e276c..bdef633e1b6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/es-es.json @@ -8,6 +8,7 @@ "accept": "Aceptar", "accept-all": "Aceptar todo", "accept-suggestion": "Aceptar sugerencia", + "accepted-by": "Aceptado por", "access": "Acceso", "access-block-time": "Tiempo de Bloqueo de Acceso", "access-control": "Control de Acceso", @@ -92,6 +93,8 @@ "agent-activity": "Actividad del agente", "agent-plural": "Agentes", "aggregate": "Agregar", + "ai": "AI", + "ai-generated-description": "Descripción generada por IA", "ai-queries": "Consultas IA", "airflow-config-plural": "Configuraciones de airflow", "alert": "Alerta", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "Tipo de autenticación", "authentication-uri": "URI de autenticación", + "authored-by": "Creado por", "authority": "Autoridad", "authorize-app": "Autorizar {{app}}", "auto-classification": "Clasificación automática", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "Etiqueta de información personal identificable automática", "auto-tier": "Nivel Automático", "automated": "Automatizado", + "automated-description": "Descripción automatizada", "automatically-generate": "Generar automáticamente", "availability-time": "Tiempo de Disponibilidad", "average-daily-active-users-on-the-platform": "Media de Usuario Activos Diariamente en la Plataforma", @@ -589,6 +594,7 @@ "derived-from": "Derivado De", "derives": "Deriva", "description": "Descripción", + "description-inherited-from-parent-entity": "Descripción heredada de la entidad principal", "description-kpi": "Descripción KPI", "description-lowercase": "descripción", "description-plural": "Descripciones", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json index 70e3a40a7cc..0f35d558b32 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/fr-fr.json @@ -8,6 +8,7 @@ "accept": "Accepter", "accept-all": "Tout Accepter", "accept-suggestion": "Accepter la Suggestion", + "accepted-by": "Accepté par", "access": "Accès", "access-block-time": "Durée du blocage d'accès", "access-control": "Contrôle d'Accès", @@ -92,6 +93,8 @@ "agent-activity": "Activité des agents", "agent-plural": "Agentes", "aggregate": "Agrégat", + "ai": "AI", + "ai-generated-description": "Description générée par IA", "ai-queries": "Requêtes IA", "airflow-config-plural": "Configurations Airflow", "alert": "Alerte", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "Type d'authentification", "authentication-uri": "URI d'Authentification", + "authored-by": "Rédigé par", "authority": "Autorité", "authorize-app": "Autoriser {{app}}", "auto-classification": "Classification Automatique", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "Balise Auto PII", "auto-tier": "Niveau Automatique", "automated": "Automatique", + "automated-description": "Description automatisée", "automatically-generate": "Générer Automatiquement", "availability-time": "Temps de Disponibilité", "average-daily-active-users-on-the-platform": "Moyenne des Utilisateurs Actifs Quotidiens sur la Plateforme", @@ -589,6 +594,7 @@ "derived-from": "Dérivé de", "derives": "Dérive", "description": "Description", + "description-inherited-from-parent-entity": "Description héritée de l'entité parente", "description-kpi": "Description KPI", "description-lowercase": "description", "description-plural": "Descriptions", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json index d8f3a84f59c..7e43cb43a77 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/gl-es.json @@ -8,6 +8,7 @@ "accept": "Aceptar", "accept-all": "Aceptar todo", "accept-suggestion": "Aceptar suxestión", + "accepted-by": "Aceptado por", "access": "Acceso", "access-block-time": "Tempo de bloqueo de acceso", "access-control": "Control de Acceso", @@ -92,6 +93,8 @@ "agent-activity": "Agent Activity", "agent-plural": "Agentes", "aggregate": "Agrupar", + "ai": "AI", + "ai-generated-description": "Descrición xerada por IA", "ai-queries": "AI Queries", "airflow-config-plural": "configuracións de Airflow", "alert": "Alerta", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "Tipo de autenticación", "authentication-uri": "URI de autenticación", + "authored-by": "Creado por", "authority": "Autoridade", "authorize-app": "Autorizar {{app}}", "auto-classification": "auto clasificación", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "Etiquetado automático de PII", "auto-tier": "Nivel Automático", "automated": "Automático", + "automated-description": "Descrición automatizada", "automatically-generate": "Xerar automaticamente", "availability-time": "Tempo de dispoñibilidade", "average-daily-active-users-on-the-platform": "Media Usuarios Activos Diarios na Plataforma", @@ -589,6 +594,7 @@ "derived-from": "Derivado De", "derives": "Deriva", "description": "Descrición", + "description-inherited-from-parent-entity": "Descrición herdada da entidade nai", "description-kpi": "Descrición KPI", "description-lowercase": "descrición", "description-plural": "Descricións", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json index 2c8daf80799..1a52cd57899 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/he-he.json @@ -8,6 +8,7 @@ "accept": "קבל", "accept-all": "אשר הכל", "accept-suggestion": "קבל הצעה", + "accepted-by": "אושר על ידי", "access": "גישה", "access-block-time": "זמן חסימת גישה", "access-control": "בקרת גישה", @@ -92,6 +93,8 @@ "agent-activity": "פעילות סוכן", "agent-plural": "סוכנים", "aggregate": "כלול", + "ai": "AI", + "ai-generated-description": "תיאור שנוצר על ידי AI", "ai-queries": "שאילתות AI", "airflow-config-plural": "תצורות airflow", "alert": "התראה", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "סוג אימות", "authentication-uri": "URI אימות", + "authored-by": "נכתב על ידי", "authority": "רשות", "authorize-app": "אמת את {{app}}", "auto-classification": "סיווג אוטומטי", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "תיוג PII אוטומטי", "auto-tier": "דרגה אוטומטית", "automated": "אוטומטי", + "automated-description": "תיאור אוטומטי", "automatically-generate": "צור באופן אוטומטי", "availability-time": "זמן זמינות", "average-daily-active-users-on-the-platform": "ממוצע משתמשים יומיים פעילים בפלטפורמה", @@ -589,6 +594,7 @@ "derived-from": "נגזר מ", "derives": "גוזר", "description": "תיאור", + "description-inherited-from-parent-entity": "תיאור שעבר בירושה מהישות הורה", "description-kpi": "תיאור KPI", "description-lowercase": "תיאור", "description-plural": "תיאורים", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json index 46e9a68c9d7..582e240e13f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ja-jp.json @@ -8,6 +8,7 @@ "accept": "承認", "accept-all": "すべて承認", "accept-suggestion": "提案を受け入れる", + "accepted-by": "承認者", "access": "アクセス", "access-block-time": "アクセスブロック時間", "access-control": "アクセス制御", @@ -92,6 +93,8 @@ "agent-activity": "エージェントのアクティビティ", "agent-plural": "エージェント", "aggregate": "集約", + "ai": "AI", + "ai-generated-description": "AI生成の説明", "ai-queries": "AIクエリ", "airflow-config-plural": "Airflowの設定", "alert": "アラート", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "認証タイプ", "authentication-uri": "認証URI", + "authored-by": "作成者", "authority": "認証元", "authorize-app": "{{app}} を認可", "auto-classification": "自動分類", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "自動PIIタグ", "auto-tier": "自動階層", "automated": "自動", + "automated-description": "自動化された説明", "automatically-generate": "自動生成", "availability-time": "利用可能時間", "average-daily-active-users-on-the-platform": "プラットフォームの1日平均アクティブユーザー数", @@ -589,6 +594,7 @@ "derived-from": "派生元", "derives": "派生", "description": "説明", + "description-inherited-from-parent-entity": "親エンティティから継承された説明", "description-kpi": "説明のKPI", "description-lowercase": "説明", "description-plural": "説明", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json index bafb79a46be..20ff5c7f28d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ko-kr.json @@ -8,6 +8,7 @@ "accept": "수락", "accept-all": "모두 수락", "accept-suggestion": "제안 수락", + "accepted-by": "수락한 사람", "access": "접근", "access-block-time": "접근 차단 시간", "access-control": "접근 제어", @@ -92,6 +93,8 @@ "agent-activity": "에이전트 활동", "agent-plural": "에이전트", "aggregate": "집계", + "ai": "AI", + "ai-generated-description": "AI 생성 설명", "ai-queries": "AI 쿼리들", "airflow-config-plural": "에어플로우 설정", "alert": "알림", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "인증 유형", "authentication-uri": "인증 URI", + "authored-by": "작성자", "authority": "권한", "authorize-app": "{{app}} 인증", "auto-classification": "자동 분류", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "PII 자동 태그", "auto-tier": "자동 계층", "automated": "자동", + "automated-description": "자동화된 설명", "automatically-generate": "자동 생성", "availability-time": "사용 가능 시간", "average-daily-active-users-on-the-platform": "플랫폼의 일일 평균 활성 사용자 수", @@ -589,6 +594,7 @@ "derived-from": "파생 출처", "derives": "파생", "description": "설명", + "description-inherited-from-parent-entity": "상위 엔티티에서 상속된 설명", "description-kpi": "설명 KPI", "description-lowercase": "설명", "description-plural": "설명들", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json index e1858454eef..e96ab30b676 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/mr-in.json @@ -8,6 +8,7 @@ "accept": "स्वीकार करा", "accept-all": "सर्व स्वीकार करा", "accept-suggestion": "सुचवलेले स्वीकार करा", + "accepted-by": "यांनी स्वीकारले", "access": "प्रवेश", "access-block-time": "प्रवेश ब्लॉक वेळ", "access-control": "प्रवेश नियंत्रण", @@ -92,6 +93,8 @@ "agent-activity": "Agent Activity", "agent-plural": "एजंट्स", "aggregate": "एकूण", + "ai": "AI", + "ai-generated-description": "AI-निर्मित वर्णन", "ai-queries": "AI Queries", "airflow-config-plural": "एअरफ्लो संरचना", "alert": "सूचना", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "प्रमाणीकरण प्रकार", "authentication-uri": "प्रमाणीकरण URI", + "authored-by": "यांनी लिहिले", "authority": "प्राधिकरण", "authorize-app": "{{app}} अधिकृत करा", "auto-classification": "Auto Classification", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "ऑटो टॅग PII", "auto-tier": "स्वयंचलित स्तर", "automated": "स्वयंचलित", + "automated-description": "स्वयंचलित वर्णन", "automatically-generate": "स्वयंचलितपणे व्युत्पन्न करा", "availability-time": "उपलब्धता वेळ", "average-daily-active-users-on-the-platform": "प्लॅटफॉर्मवरील सरासरी दैनिक सक्रिय वापरकर्ते", @@ -589,6 +594,7 @@ "derived-from": "यावरून व्युत्पन्न", "derives": "व्युत्पन्न करते", "description": "वर्णन", + "description-inherited-from-parent-entity": "मूळ घटकाकडून वारसाने मिळालेले वर्णन", "description-kpi": "वर्णन KPI", "description-lowercase": "वर्णन", "description-plural": "वर्णने", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json index 5d064a8d304..42b65f6e9e4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/nl-nl.json @@ -8,6 +8,7 @@ "accept": "Accepteren", "accept-all": "Alles Accepteren", "accept-suggestion": "Suggestie Accepteren", + "accepted-by": "Geaccepteerd door", "access": "Toegang", "access-block-time": "Blokkeertijd Toegang", "access-control": "Toegangscontrole", @@ -92,6 +93,8 @@ "agent-activity": "Agentactiviteit", "agent-plural": "Agenten", "aggregate": "Agregaat", + "ai": "AI", + "ai-generated-description": "AI-gegenereerde beschrijving", "ai-queries": "AI-querys", "airflow-config-plural": "Airflowconfiguraties", "alert": "Waarschuwing", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "Authenticatietype", "authentication-uri": "Authenticatie-URI", + "authored-by": "Geschreven door", "authority": "Autoriteit", "authorize-app": "Applicatie autoriseren {{app}}", "auto-classification": "Automatische classificatie", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "Automatisch taggen van PII", "auto-tier": "Automatische Laag", "automated": "Automatisch", + "automated-description": "Geautomatiseerde beschrijving", "automatically-generate": "Automatisch genereren", "availability-time": "Beschikbaarheidstijd", "average-daily-active-users-on-the-platform": "Gemiddeld Aantal Dagelijks Actieve Gebruikers op het Platform", @@ -589,6 +594,7 @@ "derived-from": "Afgeleid van", "derives": "Leidt af", "description": "Beschrijving", + "description-inherited-from-parent-entity": "Beschrijving overgenomen van bovenliggende entiteit", "description-kpi": "Description KPI", "description-lowercase": "beschrijving", "description-plural": "Descriptions", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json index 3ca06634bd5..649c632d9d6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pr-pr.json @@ -8,6 +8,7 @@ "accept": "پذیرفتن", "accept-all": "پذیرفتن همه", "accept-suggestion": "پذیرفتن پیشنهاد", + "accepted-by": "پذیرفته شده توسط", "access": "دسترسی", "access-block-time": "زمان مسدود کردن دسترسی", "access-control": "کنترل دسترسی", @@ -92,6 +93,8 @@ "agent-activity": "Agent Activity", "agent-plural": "Agentes", "aggregate": "تجمیع", + "ai": "AI", + "ai-generated-description": "توضیحات تولید شده توسط هوش مصنوعی", "ai-queries": "AI Queries", "airflow-config-plural": "پیکربندی‌های ایر‌فلو", "alert": "هشدار", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "نوع احراز هویت", "authentication-uri": "آدرس URI احراز هویت", + "authored-by": "نوشته شده توسط", "authority": "مرجع", "authorize-app": "مجوز دادن به {{app}}", "auto-classification": "طبقه‌بندی خودکار", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "برچسب PII خودکار", "auto-tier": "سطح خودکار", "automated": "خودکار", + "automated-description": "توضیحات خودکار", "automatically-generate": "تولید خودکار", "availability-time": "زمان در دسترس بودن", "average-daily-active-users-on-the-platform": "Average Daily Active Users on the Platform", @@ -589,6 +594,7 @@ "derived-from": "مشتق شده از", "derives": "مشتق می‌کند", "description": "توضیحات", + "description-inherited-from-parent-entity": "توضیحات به ارث رسیده از موجودیت والد", "description-kpi": "توضیحات KPI", "description-lowercase": "توضیحات", "description-plural": "توضیحات", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json index 19747cc35f3..74b1d5848d7 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-br.json @@ -8,6 +8,7 @@ "accept": "Aceitar", "accept-all": "Aceitar todos", "accept-suggestion": "Aceitar Sugestão", + "accepted-by": "Aceito por", "access": "Acesso", "access-block-time": "Tempo de bloqueio de acesso", "access-control": "Controle de Acesso", @@ -92,6 +93,8 @@ "agent-activity": "Atividade do agente", "agent-plural": "Agentes", "aggregate": "Agregado", + "ai": "AI", + "ai-generated-description": "Descrição gerada por IA", "ai-queries": "Consultas de IA", "airflow-config-plural": "configs do airflow", "alert": "Alerta", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "Tipo de autenticação", "authentication-uri": "URI de Autenticação", + "authored-by": "Criado por", "authority": "Autoridade", "authorize-app": "Autorizar {{app}}", "auto-classification": "Classificação Automática", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "Tag automática de PII", "auto-tier": "Nível Automático", "automated": "Automático", + "automated-description": "Descrição automatizada", "automatically-generate": "Gerar Automaticamente", "availability-time": "Tempo de Disponibilidade", "average-daily-active-users-on-the-platform": "Média de usuários ativos diários na plataforma", @@ -589,6 +594,7 @@ "derived-from": "Derivado De", "derives": "Deriva", "description": "Descrição", + "description-inherited-from-parent-entity": "Descrição herdada da entidade pai", "description-kpi": "Descrição KPI", "description-lowercase": "descrição", "description-plural": "Descrições", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json index 8508f50159a..f190420dcb8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/pt-pt.json @@ -8,6 +8,7 @@ "accept": "Aceitar", "accept-all": "Aceitar Tudo", "accept-suggestion": "Aceitar Sugestão", + "accepted-by": "Aceite por", "access": "Acesso", "access-block-time": "Tempo de bloqueio de acesso", "access-control": "Controle de Acesso", @@ -92,6 +93,8 @@ "agent-activity": "Atividade do Agente", "agent-plural": "Agentes", "aggregate": "Agregado", + "ai": "AI", + "ai-generated-description": "Descrição gerada por IA", "ai-queries": "Consultas IA", "airflow-config-plural": "configs do airflow", "alert": "Alerta", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "Tipo de autenticação", "authentication-uri": "URI de Autenticação", + "authored-by": "Criado por", "authority": "Autoridade", "authorize-app": "Autorizar {{app}}", "auto-classification": "Classificação Automática", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "Etiqueta Automática PII", "auto-tier": "Nível Automático", "automated": "Automático", + "automated-description": "Descrição automatizada", "automatically-generate": "Gerar Automaticamente", "availability-time": "Tempo de disponibilidade", "average-daily-active-users-on-the-platform": "Média de Utilizadores Ativos Diários na Plataforma", @@ -589,6 +594,7 @@ "derived-from": "Derivado De", "derives": "Deriva", "description": "Descrição", + "description-inherited-from-parent-entity": "Descrição herdada da entidade pai", "description-kpi": "Descrição KPI", "description-lowercase": "descrição", "description-plural": "Descrições", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json index e9485e335cf..cde7e4b4221 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/ru-ru.json @@ -8,6 +8,7 @@ "accept": "Принять", "accept-all": "Принять все", "accept-suggestion": "Согласовать предложение", + "accepted-by": "Принято пользователем", "access": "Доступ", "access-block-time": "Блокировка доступа по времени", "access-control": "Контроль доступа", @@ -92,6 +93,8 @@ "agent-activity": "Активность агента", "agent-plural": "Агенты", "aggregate": "Агрегатор", + "ai": "AI", + "ai-generated-description": "Описание, сгенерированное ИИ", "ai-queries": "AI-запросы", "airflow-config-plural": "Конфиги Airflow", "alert": "Оповещение", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "Тип аутентификации", "authentication-uri": "URI аутентификации", + "authored-by": "Автор", "authority": "Власть", "authorize-app": "Авторизация {{app}}", "auto-classification": "Автоклассификация", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "Автотег PII", "auto-tier": "Автоматический уровень", "automated": "Автоматический", + "automated-description": "Автоматизированное описание", "automatically-generate": "Автоматически генерировать", "availability-time": "Время доступности", "average-daily-active-users-on-the-platform": "Среднее количество активных пользователей в день на платформе", @@ -589,6 +594,7 @@ "derived-from": "Получено из", "derives": "Порождает", "description": "Описание", + "description-inherited-from-parent-entity": "Описание унаследовано от родительской сущности", "description-kpi": "Описание KPI", "description-lowercase": "описание", "description-plural": "Описания", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json index 926e8f4a17c..c9afa318d6d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/th-th.json @@ -8,6 +8,7 @@ "accept": "ยอมรับ", "accept-all": "ยอมรับทั้งหมด", "accept-suggestion": "ยอมรับข้อเสนอแนะ", + "accepted-by": "ยอมรับโดย", "access": "การเข้าถึง", "access-block-time": "เวลาบล็อกการเข้าถึง", "access-control": "การควบคุมการเข้าถึง", @@ -92,6 +93,8 @@ "agent-activity": "Agent Activity", "agent-plural": "เอเจนต์", "aggregate": "รวม", + "ai": "AI", + "ai-generated-description": "คำอธิบายที่สร้างโดย AI", "ai-queries": "AI Queries", "airflow-config-plural": "การกำหนดค่าของ airflow", "alert": "การแจ้งเตือน", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "ประเภทการยืนยันตัวตน", "authentication-uri": "URI การรับรอง", + "authored-by": "เขียนโดย", "authority": "อำนาจ", "authorize-app": "อนุญาต {{app}}", "auto-classification": "การจำแนกประเภทอัตโนมัติ", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "แท็ก PII อัตโนมัติ", "auto-tier": "เทียร์อัตโนมัติ", "automated": "อัตโนมัติ", + "automated-description": "คำอธิบายอัตโนมัติ", "automatically-generate": "สร้างโดยอัตโนมัติ", "availability-time": "เวลาใช้งาน", "average-daily-active-users-on-the-platform": "ผู้ใช้งานเฉลี่ยต่อวันบนแพลตฟอร์ม", @@ -589,6 +594,7 @@ "derived-from": "ได้มาจาก", "derives": "อนุพันธ์", "description": "คำอธิบาย", + "description-inherited-from-parent-entity": "คำอธิบายที่สืบทอดมาจากเอนทิตีแม่", "description-kpi": "คำอธิบาย KPI", "description-lowercase": "คำอธิบาย", "description-plural": "คำอธิบายหลายรายการ", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json index 2826b0c528d..cc49ff2cb20 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/tr-tr.json @@ -8,6 +8,7 @@ "accept": "Kabul Et", "accept-all": "Tümünü Kabul Et", "accept-suggestion": "Öneriyi Kabul Et", + "accepted-by": "Kabul eden", "access": "Erişim", "access-block-time": "Erişim engelleme süresi", "access-control": "Erişim Kontrolü", @@ -92,6 +93,8 @@ "agent-activity": "Agent Activity", "agent-plural": "Agent'lar", "aggregate": "Topla", + "ai": "AI", + "ai-generated-description": "Yapay Zeka Tarafından Oluşturulan Açıklama", "ai-queries": "Yapay Zeka Sorguları", "airflow-config-plural": "airflow yapılandırmaları", "alert": "Uyarı", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "Kimlik Doğrulama Türü", "authentication-uri": "Kimlik Doğrulama URI'si", + "authored-by": "Yazan", "authority": "Yetki", "authorize-app": "{{app}} Yetkilendir", "auto-classification": "Otomatik Sınıflandırma", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "KVT'yi Otomatik Etiketle", "auto-tier": "Otomatik Katman", "automated": "Otomatik", + "automated-description": "Otomatik Açıklama", "automatically-generate": "Otomatik Oluştur", "availability-time": "Kullanılabilirlik Süresi", "average-daily-active-users-on-the-platform": "Platformdaki Ortalama Günlük Aktif Kullanıcı Sayısı", @@ -589,6 +594,7 @@ "derived-from": "Türetildiği Kaynak", "derives": "Türetir", "description": "Açıklama", + "description-inherited-from-parent-entity": "Üst Varlıktan Devralınan Açıklama", "description-kpi": "Açıklama KPI'ı", "description-lowercase": "açıklama", "description-plural": "Açıklamalar", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json index ccb81aadfba..4f3d3181023 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-cn.json @@ -8,6 +8,7 @@ "accept": "接受", "accept-all": "接受所有", "accept-suggestion": "接受建议", + "accepted-by": "已由…接受", "access": "访问", "access-block-time": "访问阻止时间", "access-control": "访问控制", @@ -92,6 +93,8 @@ "agent-activity": "代理活动", "agent-plural": "代理", "aggregate": "聚合", + "ai": "AI", + "ai-generated-description": "AI生成的描述", "ai-queries": "AI查询", "airflow-config-plural": "Airflow 配置", "alert": "提醒", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "认证类型", "authentication-uri": "鉴权 URI", + "authored-by": "作者", "authority": "授权", "authorize-app": "授权{{app}}", "auto-classification": "自动分类", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "自动标记 PII", "auto-tier": "自动层级", "automated": "自动", + "automated-description": "自动化描述", "automatically-generate": "自动生成", "availability-time": "可用时间", "average-daily-active-users-on-the-platform": "平台日均活跃用户数", @@ -589,6 +594,7 @@ "derived-from": "派生自", "derives": "派生", "description": "描述", + "description-inherited-from-parent-entity": "从父实体继承的描述", "description-kpi": "Description KPI", "description-lowercase": "描述", "description-plural": "描述", diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json index 2953a1338f2..3d25bb9b015 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/zh-tw.json @@ -8,6 +8,7 @@ "accept": "接受", "accept-all": "全部接受", "accept-suggestion": "接受建議", + "accepted-by": "已由…接受", "access": "存取", "access-block-time": "存取封鎖時間", "access-control": "存取控制", @@ -92,6 +93,8 @@ "agent-activity": "Agent Activity", "agent-plural": "代理程式", "aggregate": "彙總", + "ai": "AI", + "ai-generated-description": "AI生成的描述", "ai-queries": "AI 查詢", "airflow-config-plural": "airflow 組態", "alert": "警示", @@ -181,6 +184,7 @@ "auth0": "Auth0", "authentication-type": "驗證類型", "authentication-uri": "驗證 URI", + "authored-by": "作者", "authority": "授權單位", "authorize-app": "授權 {{app}}", "auto-classification": "自動分類", @@ -189,6 +193,7 @@ "auto-tag-pii-uppercase": "自動標記 PII", "auto-tier": "自動層級", "automated": "自動化", + "automated-description": "自動化描述", "automatically-generate": "自動產生", "availability-time": "可用時間", "average-daily-active-users-on-the-platform": "平台上每日平均活躍使用者", @@ -589,6 +594,7 @@ "derived-from": "衍生自", "derives": "衍生", "description": "描述", + "description-inherited-from-parent-entity": "從父實體繼承的描述", "description-kpi": "描述 KPI", "description-lowercase": "描述", "description-plural": "描述", diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/changeSummaryAPI.test.ts b/openmetadata-ui/src/main/resources/ui/src/rest/changeSummaryAPI.test.ts new file mode 100644 index 00000000000..1131c601cb6 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/changeSummaryAPI.test.ts @@ -0,0 +1,124 @@ +/* + * 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 axiosClient from '.'; +import { ChangeSource } from '../generated/type/changeSummaryMap'; +import { getChangeSummary, getChangeSummaryByFqn } from './changeSummaryAPI'; + +jest.mock('.'); + +const mockedGet = axiosClient.get as jest.MockedFunction< + typeof axiosClient.get +>; + +describe('changeSummaryAPI', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getChangeSummary', () => { + it('should call the correct URL with entity type and ID', async () => { + const mockResponse = { + data: { + changeSummary: { + description: { + changedBy: 'admin', + changeSource: ChangeSource.Suggested, + changedAt: 1700000000000, + }, + }, + totalEntries: 1, + }, + }; + mockedGet.mockResolvedValueOnce(mockResponse as never); + + const result = await getChangeSummary('table', 'test-id-123'); + + expect(mockedGet).toHaveBeenCalledWith( + '/changeSummary/table/test-id-123', + { params: undefined } + ); + expect(result.changeSummary).toHaveProperty('description'); + expect(result.totalEntries).toBe(1); + }); + + it('should pass fieldPrefix parameter', async () => { + const mockResponse = { + data: { + changeSummary: {}, + totalEntries: 0, + }, + }; + mockedGet.mockResolvedValueOnce(mockResponse as never); + + await getChangeSummary('table', 'test-id', { + fieldPrefix: 'columns.', + }); + + expect(mockedGet).toHaveBeenCalledWith('/changeSummary/table/test-id', { + params: { fieldPrefix: 'columns.' }, + }); + }); + + it('should pass pagination parameters', async () => { + const mockResponse = { + data: { + changeSummary: {}, + totalEntries: 0, + offset: 10, + limit: 50, + }, + }; + mockedGet.mockResolvedValueOnce(mockResponse as never); + + await getChangeSummary('table', 'test-id', { + limit: 50, + offset: 10, + }); + + expect(mockedGet).toHaveBeenCalledWith('/changeSummary/table/test-id', { + params: { limit: 50, offset: 10 }, + }); + }); + }); + + describe('getChangeSummaryByFqn', () => { + it('should call the correct URL with entity type and FQN', async () => { + const mockResponse = { + data: { + changeSummary: { + description: { + changedBy: 'admin', + changeSource: ChangeSource.Automated, + changedAt: 1700000000000, + }, + }, + totalEntries: 1, + }, + }; + mockedGet.mockResolvedValueOnce(mockResponse as never); + + const result = await getChangeSummaryByFqn( + 'table', + 'service.database.schema.my_table' + ); + + expect(mockedGet).toHaveBeenCalledWith( + '/changeSummary/table/name/service.database.schema.my_table', + { params: undefined } + ); + expect(result.changeSummary).toHaveProperty('description'); + expect(result.totalEntries).toBe(1); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/rest/changeSummaryAPI.ts b/openmetadata-ui/src/main/resources/ui/src/rest/changeSummaryAPI.ts new file mode 100644 index 00000000000..cc69cf9c3a5 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/rest/changeSummaryAPI.ts @@ -0,0 +1,62 @@ +/* + * 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 axiosClient from '.'; +import { ChangeSource } from '../generated/type/changeSummaryMap'; + +export interface ChangeSummaryEntry { + changedAt?: number; + changedBy?: string; + changeSource?: ChangeSource; +} + +export interface ChangeSummaryResponse { + changeSummary: Record; + totalEntries: number; + offset?: number; + limit?: number; +} + +export interface ChangeSummaryParams { + fieldPrefix?: string; + limit?: number; + offset?: number; +} + +const BASE_URL = '/changeSummary'; + +export const getChangeSummary = async ( + entityType: string, + entityId: string, + params?: ChangeSummaryParams +): Promise => { + const response = await axiosClient.get( + `${BASE_URL}/${entityType}/${entityId}`, + { params } + ); + + return response.data; +}; + +export const getChangeSummaryByFqn = async ( + entityType: string, + fqn: string, + params?: ChangeSummaryParams +): Promise => { + const response = await axiosClient.get( + `${BASE_URL}/${entityType}/name/${fqn}`, + { params } + ); + + return response.data; +};