mirror of
https://github.com/open-metadata/OpenMetadata
synced 2026-05-24 09:39:11 +00:00
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) <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
Co-authored-by: Pere Miquel Brull <peremiquelbrull@gmail.com>
Co-authored-by: Rohit0301 <rj03012002@gmail.com>
Co-authored-by: Rohit Jain <60229265+Rohit0301@users.noreply.github.com>
This commit is contained in:
parent
b2b49db75e
commit
897f9cfd1b
66 changed files with 2479 additions and 96 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -557,6 +557,7 @@ public class APIEndpointResourceIT extends BaseEntityIT<APIEndpoint, CreateAPIEn
|
|||
ListParams paramsTagsOnly = new ListParams();
|
||||
paramsTagsOnly.setFields("tags");
|
||||
paramsTagsOnly.setLimit(50);
|
||||
paramsTagsOnly.addQueryParam("apiCollection", collection.getFullyQualifiedName());
|
||||
ListResponse<APIEndpoint> listWithTagsOnly = client.apiEndpoints().list(paramsTagsOnly);
|
||||
assertNotNull(listWithTagsOnly.getData());
|
||||
|
||||
|
|
@ -594,6 +595,7 @@ public class APIEndpointResourceIT extends BaseEntityIT<APIEndpoint, CreateAPIEn
|
|||
ListParams paramsWithSchemas = new ListParams();
|
||||
paramsWithSchemas.setFields("requestSchema,responseSchema,tags");
|
||||
paramsWithSchemas.setLimit(50);
|
||||
paramsWithSchemas.addQueryParam("apiCollection", collection.getFullyQualifiedName());
|
||||
ListResponse<APIEndpoint> listWithSchemas = client.apiEndpoints().list(paramsWithSchemas);
|
||||
assertNotNull(listWithSchemas.getData());
|
||||
|
||||
|
|
|
|||
|
|
@ -6240,4 +6240,168 @@ public abstract class BaseEntityIT<T extends EntityInterface, K> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
* <p>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<String, Object> summary1 = getChangeSummary("table", table.getFullyQualifiedName());
|
||||
Map<String, Map<String, Object>> 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<String, Object> summary2 = getChangeSummary("table", table.getFullyQualifiedName());
|
||||
Map<String, Map<String, Object>> 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<String, Object> summary1 = getChangeSummary("table", table.getFullyQualifiedName());
|
||||
Map<String, Map<String, Object>> entries1 = extractChangeSummary(summary1);
|
||||
Map<String, Object> 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<String, Object> summary2 = getChangeSummary("table", table.getFullyQualifiedName());
|
||||
Map<String, Map<String, Object>> entries2 = extractChangeSummary(summary2);
|
||||
Map<String, Object> 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<String, Object> summary1 = getChangeSummary("table", table.getFullyQualifiedName());
|
||||
Map<String, Map<String, Object>> 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<String, Object> summary2 = getChangeSummary("table", table.getFullyQualifiedName());
|
||||
Map<String, Map<String, Object>> 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<String, Object> summary = getChangeSummary("table", table.getFullyQualifiedName());
|
||||
Map<String, Map<String, Object>> entries = extractChangeSummary(summary);
|
||||
|
||||
Map<String, Object> nameEntry = findEntryByPrefix(entries, "columns.name.description");
|
||||
Map<String, Object> 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<Column> 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<String, Object> 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<Map<String, Object>>() {});
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Map<String, Object>> extractChangeSummary(Map<String, Object> response) {
|
||||
return (Map<String, Map<String, Object>>) response.get("changeSummary");
|
||||
}
|
||||
|
||||
private Map<String, Object> findEntryByPrefix(
|
||||
Map<String, Map<String, Object>> entries, String prefix) {
|
||||
return entries.entrySet().stream()
|
||||
.filter(e -> e.getKey().startsWith(prefix) || e.getKey().equals(prefix))
|
||||
.map(Map.Entry::getValue)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
|
|
@ -101,7 +101,7 @@ public class LLMModelResourceIT extends BaseEntityIT<LLMModel, CreateLLMModel> {
|
|||
|
||||
@Override
|
||||
protected String getEntityType() {
|
||||
return "llmmodel";
|
||||
return "llmModel";
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ public class MlModelServiceResourceIT extends BaseServiceIT<MlModelService, Crea
|
|||
|
||||
@Override
|
||||
protected String getEntityType() {
|
||||
return "mlModelService";
|
||||
return "mlmodelService";
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
|||
|
|
@ -531,17 +531,20 @@ public class TopicResourceIT extends BaseEntityIT<Topic, CreateTopic> {
|
|||
|
||||
// List topics
|
||||
ListParams params = new ListParams();
|
||||
params.setFields("service");
|
||||
params.setLimit(100);
|
||||
params.setService(service.getFullyQualifiedName());
|
||||
ListResponse<Topic> 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
|
||||
|
|
|
|||
|
|
@ -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<APIEndpoint> {
|
||||
private static final Set<String> 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> {
|
|||
APIEndpoint.class,
|
||||
Entity.getCollectionDAO().apiEndpointDAO(),
|
||||
"",
|
||||
"");
|
||||
"",
|
||||
CHANGE_SUMMARY_FIELDS);
|
||||
supportsSearch = true;
|
||||
|
||||
// Register bulk field fetchers for efficient database operations
|
||||
|
|
|
|||
|
|
@ -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<T extends EntityInterface> {
|
|||
}
|
||||
|
||||
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<T> 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<T extends EntityInterface> {
|
|||
.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<String, Object>) map)
|
||||
.map(map -> (String) map.get("name"))
|
||||
|
|
@ -152,4 +118,73 @@ public class ChangeSummarizer<T extends EntityInterface> {
|
|||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<List<String>> 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 (<relation>)")
|
||||
|
|
@ -2466,6 +2486,13 @@ public interface CollectionDAO {
|
|||
}
|
||||
}
|
||||
|
||||
class RelationTypeCountMapper implements RowMapper<List<String>> {
|
||||
@Override
|
||||
public List<String> map(ResultSet rs, StatementContext ctx) throws SQLException {
|
||||
return Arrays.asList(rs.getString("relationType"), rs.getString("cnt"));
|
||||
}
|
||||
}
|
||||
|
||||
class RelationshipObjectMapper implements RowMapper<EntityRelationshipObject> {
|
||||
@Override
|
||||
public EntityRelationshipObject map(ResultSet rs, StatementContext ctx) throws SQLException {
|
||||
|
|
|
|||
|
|
@ -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<Container> {
|
||||
private static final String CONTAINER_UPDATE_FIELDS = "dataModel";
|
||||
private static final String CONTAINER_PATCH_FIELDS = "dataModel";
|
||||
private static final Set<String> CHANGE_SUMMARY_FIELDS = Set.of("dataModel.columns.description");
|
||||
|
||||
public ContainerRepository() {
|
||||
super(
|
||||
|
|
@ -58,7 +60,8 @@ public class ContainerRepository extends EntityRepository<Container> {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -54,6 +54,8 @@ import org.openmetadata.service.util.FullyQualifiedName;
|
|||
|
||||
@Slf4j
|
||||
public class DashboardDataModelRepository extends EntityRepository<DashboardDataModel> {
|
||||
private static final Set<String> CHANGE_SUMMARY_FIELDS = Set.of("columns.description");
|
||||
|
||||
public DashboardDataModelRepository() {
|
||||
super(
|
||||
DashboardDataModelResource.COLLECTION_PATH,
|
||||
|
|
@ -61,7 +63,8 @@ public class DashboardDataModelRepository extends EntityRepository<DashboardData
|
|||
DashboardDataModel.class,
|
||||
Entity.getCollectionDAO().dashboardDataModelDAO(),
|
||||
"",
|
||||
"");
|
||||
"",
|
||||
CHANGE_SUMMARY_FIELDS);
|
||||
supportsSearch = true;
|
||||
|
||||
// Register bulk field fetchers for efficient database operations
|
||||
|
|
|
|||
|
|
@ -552,11 +552,25 @@ public abstract class EntityRepository<T extends EntityInterface> {
|
|||
}
|
||||
}
|
||||
|
||||
changeSummarizer = new ChangeSummarizer<>(entityClass, changeSummaryFields);
|
||||
changeSummarizer =
|
||||
new ChangeSummarizer<>(entityClass, getEffectiveChangeSummaryFields(changeSummaryFields));
|
||||
|
||||
Entity.registerEntity(entityClass, entityType, this);
|
||||
}
|
||||
|
||||
private Set<String> getEffectiveChangeSummaryFields(Set<String> configuredFields) {
|
||||
Set<String> 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<T extends EntityInterface> {
|
|||
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<T> addFollower(String updatedBy, UUID entityId, UUID userId) {
|
||||
T entity = find(entityId, NON_DELETED);
|
||||
|
|
|
|||
|
|
@ -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<File> {
|
|||
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<String> CHANGE_SUMMARY_FIELDS = Set.of("columns.description");
|
||||
|
||||
public FileRepository() {
|
||||
super(
|
||||
|
|
@ -76,7 +78,8 @@ public class FileRepository extends EntityRepository<File> {
|
|||
File.class,
|
||||
Entity.getCollectionDAO().fileDAO(),
|
||||
PATCH_FIELDS,
|
||||
UPDATE_FIELDS);
|
||||
UPDATE_FIELDS,
|
||||
CHANGE_SUMMARY_FIELDS);
|
||||
supportsSearch = true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -236,17 +236,15 @@ public class GlossaryTermRepository extends EntityRepository<GlossaryTerm> {
|
|||
}
|
||||
|
||||
public Map<String, Integer> getRelationTypeUsageCounts() {
|
||||
Map<String, Integer> usageCounts = new HashMap<>();
|
||||
List<EntityRelationshipRecord> records =
|
||||
List<List<String>> 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<String, Integer> usageCounts = new HashMap<>();
|
||||
for (List<String> row : rows) {
|
||||
usageCounts.put(row.get(0), Integer.parseInt(row.get(1)));
|
||||
}
|
||||
|
||||
return usageCounts;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MlModel> {
|
||||
private static final String MODEL_UPDATE_FIELDS = "dashboard";
|
||||
private static final String MODEL_PATCH_FIELDS = "dashboard";
|
||||
private static final Set<String> CHANGE_SUMMARY_FIELDS = Set.of("mlFeatures.description");
|
||||
|
||||
public MlModelRepository() {
|
||||
super(
|
||||
|
|
@ -69,7 +71,8 @@ public class MlModelRepository extends EntityRepository<MlModel> {
|
|||
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<MlModel> {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ public class PipelineRepository extends EntityRepository<Pipeline> {
|
|||
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<String> 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> {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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<SearchIndex> {
|
||||
private static final Set<String> CHANGE_SUMMARY_FIELDS = Set.of("fields.description");
|
||||
|
||||
public SearchIndexRepository() {
|
||||
super(
|
||||
|
|
@ -72,7 +74,8 @@ public class SearchIndexRepository extends EntityRepository<SearchIndex> {
|
|||
SearchIndex.class,
|
||||
Entity.getCollectionDAO().searchIndexDAO(),
|
||||
"",
|
||||
"");
|
||||
"",
|
||||
CHANGE_SUMMARY_FIELDS);
|
||||
supportsSearch = true;
|
||||
|
||||
// Register bulk field fetchers for efficient database operations
|
||||
|
|
|
|||
|
|
@ -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<Suggestion> 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<Suggestion> rejectSuggestion(
|
||||
UriInfo uriInfo, Suggestion suggestion, String user) {
|
||||
suggestion.setStatus(SuggestionStatus.Rejected);
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ import org.openmetadata.service.util.EntityUtil.RelationIncludes;
|
|||
import org.openmetadata.service.util.FullyQualifiedName;
|
||||
|
||||
public class TopicRepository extends EntityRepository<Topic> {
|
||||
private static final Set<String> CHANGE_SUMMARY_FIELDS =
|
||||
Set.of("messageSchema.schemaFields.description");
|
||||
|
||||
public TopicRepository() {
|
||||
super(
|
||||
|
|
@ -75,7 +77,8 @@ public class TopicRepository extends EntityRepository<Topic> {
|
|||
Topic.class,
|
||||
Entity.getCollectionDAO().topicDAO(),
|
||||
"",
|
||||
"");
|
||||
"",
|
||||
CHANGE_SUMMARY_FIELDS);
|
||||
supportsSearch = true;
|
||||
|
||||
// Register bulk field fetchers for efficient database operations
|
||||
|
|
|
|||
|
|
@ -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<String, ChangeSummary> 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<String, ChangeSummary> filtered;
|
||||
if (fieldPrefix != null && !fieldPrefix.isEmpty()) {
|
||||
filtered = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, ChangeSummary> entry : changeSummary.entrySet()) {
|
||||
if (entry.getKey().startsWith(fieldPrefix)) {
|
||||
filtered.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
filtered = changeSummary;
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
Map<String, ChangeSummary> paginated = new LinkedHashMap<>();
|
||||
int count = 0;
|
||||
int added = 0;
|
||||
for (Map.Entry<String, ChangeSummary> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Container> containerChangeSummarizer =
|
||||
new ChangeSummarizer<>(Container.class, Set.of("dataModel.columns.description"));
|
||||
String fieldName = "dataModel.columns.column1.description";
|
||||
List<FieldChange> changes = List.of(new FieldChange().withName(fieldName));
|
||||
|
||||
Map<String, ChangeSummary> 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";
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
@ -496,14 +496,20 @@ export class TableClass extends EntityClass {
|
|||
async patch({
|
||||
apiContext,
|
||||
patchData,
|
||||
queryParams,
|
||||
}: {
|
||||
apiContext: APIRequestContext;
|
||||
patchData: Operation[];
|
||||
queryParams?: Record<string, string>;
|
||||
}) {
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
<svg viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.30524 0.657545L5.65804 0L6.01082 0.657545C6.07371 0.774736 6.17072 0.87061 6.28933 0.932753L6.95475 1.28136L6.28933 1.62998C6.17072 1.69212 6.07371 1.78798 6.01082 1.90519L5.65804 2.56273L5.30524 1.90519C5.24235 1.78798 5.14535 1.69212 5.02674 1.62998L4.36131 1.28136L5.02674 0.932753C5.14535 0.87061 5.24235 0.774736 5.30524 0.657545ZM1.14143 13.8666L3.68795 12.909L2.11054 11.3503L1.14143 13.8666ZM10.6237 2.6237L2.49234 10.6588L4.38775 12.5317L12.5191 4.49668L10.6237 2.6237ZM11.0981 2.15487L11.809 1.45235C12.2021 1.06397 12.8393 1.06397 13.2324 1.45235L13.7045 1.91883C14.0975 2.30722 14.0975 2.93693 13.7045 3.32534L12.9935 4.02784L11.0981 2.15487ZM11.8646 9.77972L12.508 10.9789C12.5708 11.0961 12.6679 11.192 12.7865 11.2541L14 11.8899L12.7865 12.5256C12.6679 12.5878 12.5708 12.6836 12.508 12.8009L11.8646 14L11.2212 12.8009C11.1583 12.6837 11.0613 12.5878 10.9427 12.5256L9.72912 11.8899L10.9427 11.2541C11.0613 11.192 11.1583 11.0961 11.2212 10.9789L11.8646 9.77972ZM1.16316 4.68662L1.63218 3.81243L2.1012 4.68662C2.16408 4.80383 2.26111 4.89971 2.37972 4.96183L3.26438 5.42531L2.37972 5.88878C2.26111 5.95093 2.16408 6.0468 2.1012 6.16401L1.63218 7.03818L1.16316 6.16401C1.10023 6.0468 1.00328 5.95093 0.884683 5.88878L9.53674e-07 5.42531L0.884683 4.96183C1.00328 4.89971 1.10023 4.80383 1.16316 4.68662Z" fill="url(#paint0_linear_2359_2721)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_2359_2721" x1="14" y1="0" x2="0" y2="14" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#43CBFF"/>
|
||||
<stop offset="1" stop-color="#9708CC"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
|
|
@ -0,0 +1,5 @@
|
|||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.85033 2.056V3.02569H6.03309L5.95638 3.03137C5.88036 3.0431 5.80722 3.07168 5.74332 3.11565C5.67954 3.15956 5.62704 3.21756 5.58896 3.28421L5.55581 3.35428L5.12683 4.46223L5.06433 4.62416L3.43648 5.54745L3.29065 5.63078L1.95163 5.42529C1.85181 5.41232 1.75043 5.43024 1.65996 5.47453C1.56859 5.5193 1.49218 5.59006 1.44121 5.67813L1.44026 5.67718L1.04348 6.37415L1.04064 6.37983C0.988201 6.46911 0.963343 6.57199 0.970561 6.67528C0.977891 6.77881 1.01668 6.8782 1.08136 6.95937L1.93553 8.01807V10.0342L1.10408 11.0938L1.10124 11.0966C1.03671 11.1777 0.997837 11.2764 0.990448 11.3798C0.98495 11.4574 0.997744 11.5354 1.02643 11.6071L1.06052 11.6762L1.06336 11.6819L1.46109 12.3779C1.5121 12.466 1.58863 12.5368 1.67985 12.5815C1.77023 12.6258 1.8716 12.6427 1.97151 12.6298L3.31243 12.4252L3.45921 12.5095L5.06433 13.4337L5.12683 13.5938L5.55581 14.7008C5.593 14.7972 5.65894 14.8807 5.74426 14.9394C5.82937 14.998 5.92965 15.0299 6.03309 15.0303H6.86832C6.97184 15.03 7.07293 14.998 7.1581 14.9394L7.21776 14.8911C7.27389 14.8385 7.3177 14.774 7.3456 14.7017L7.34654 14.7008L7.77552 13.5938L7.83708 13.4337L7.98575 13.3485L9.44315 12.5095L9.58993 12.4252L10.9299 12.6298C11.03 12.6428 11.132 12.6258 11.2225 12.5815C11.3137 12.5368 11.3894 12.4658 11.4403 12.3779L11.838 11.6819L11.8418 11.6762C11.8942 11.5869 11.9182 11.4837 11.911 11.3807C11.9036 11.277 11.8648 11.1777 11.8002 11.0966L11.054 10.1705L10.946 10.037V9.14306H11.9157V9.6942L12.5568 10.4896L12.5587 10.4925C12.7456 10.7271 12.8566 11.0126 12.8778 11.3116C12.8989 11.6089 12.8288 11.9047 12.679 12.162L12.6799 12.1629L12.2812 12.8618L12.2803 12.8637C12.133 13.1181 11.9128 13.3234 11.6487 13.4527C11.3847 13.582 11.0884 13.6294 10.7973 13.59L10.7888 13.589L9.77932 13.4347L8.61738 14.1023L8.24996 15.0502C8.14241 15.329 7.9541 15.5693 7.70829 15.7387C7.4624 15.9077 7.17042 15.9989 6.87211 16H6.03025C5.76909 15.9992 5.51341 15.9291 5.28877 15.7983L5.19407 15.7387C4.97886 15.5905 4.80666 15.3885 4.69501 15.1534L4.65145 15.0512L4.28403 14.1023L3.12209 13.4347L2.11261 13.589L2.10504 13.59C1.81378 13.6295 1.51686 13.5821 1.25276 13.4527C0.98863 13.3233 0.769297 13.118 0.622075 12.8637L0.620181 12.8618L0.221504 12.1629L0.222451 12.162C0.0725904 11.9047 0.00254935 11.6086 0.0235864 11.3116C0.0447709 11.0126 0.155968 10.727 0.342717 10.4925L0.965826 9.69798V8.35993L0.324724 7.56542L0.322831 7.56353C0.136053 7.32907 0.0249114 7.04341 0.00369996 6.74441C-0.0174714 6.44537 0.0525804 6.14682 0.204459 5.88835L0.600294 5.19423L0.602188 5.19234C0.749467 4.93786 0.968841 4.7327 1.23287 4.60333C1.46385 4.49016 1.71979 4.43914 1.9753 4.45465L2.08515 4.46602L2.09273 4.46696L3.1041 4.62037L4.28403 3.95087L4.65145 3.00486L4.69501 2.90258C4.80663 2.66758 4.9787 2.46559 5.19407 2.31736C5.44014 2.14803 5.73156 2.0569 6.03025 2.056H6.85033ZM3.87115 9.02753C3.87136 7.60835 5.02198 6.45843 6.44124 6.45843C6.68695 6.45845 6.9253 6.4929 7.15147 6.55786L7.01794 7.02376L6.88347 7.48967C6.74356 7.44952 6.59537 7.42814 6.44124 7.42812C5.55754 7.42812 4.84106 8.1439 4.84085 9.02753C4.84085 9.91135 5.55742 10.6279 6.44124 10.6279C7.32491 10.6277 8.04162 9.91125 8.04162 9.02753C8.04161 8.96792 8.0375 8.90908 8.03121 8.8514L8.51322 8.79932L8.99523 8.74629C9.00533 8.83878 9.01131 8.93262 9.01133 9.02753C9.01133 10.4468 7.86048 11.5974 6.44124 11.5976C5.02184 11.5976 3.87115 10.4469 3.87115 9.02753Z" fill="#535862"/>
|
||||
<path d="M12.121 0C12.58 0 12.9524 0.372394 12.9524 0.831433V1.75188C14.695 2.13247 15.9996 3.68409 15.9998 5.54067V6.09559C15.9996 7.01335 15.2556 7.75729 14.3379 7.75751H9.90413C8.98636 7.75729 8.24241 7.01335 8.24219 6.09559V5.54067C8.24238 3.68409 9.54705 2.13247 11.2896 1.75188V0.831433C11.2896 0.372394 11.662 9.3712e-08 12.121 0ZM11.4969 2.69884C10.1902 2.98413 9.21208 4.14938 9.21189 5.54067V6.09559C9.21211 6.47781 9.52191 6.7876 9.90413 6.78782H14.3379C14.7201 6.7876 15.0299 6.4778 15.0301 6.09559V5.54067C15.0299 4.14938 14.0518 2.98413 12.7451 2.69884L12.121 2.56153L11.4969 2.69884Z" fill="#535862"/>
|
||||
<path d="M10.7362 4.15582C11.1951 4.15597 11.5667 4.52831 11.5667 4.98726C11.5666 5.44608 11.1951 5.81759 10.7362 5.81774C10.2773 5.81774 9.90493 5.44617 9.90479 4.98726C9.90479 4.52822 10.2772 4.15582 10.7362 4.15582ZM13.5061 4.15582C13.9652 4.15582 14.3376 4.52822 14.3376 4.98726C14.3374 5.44617 13.9651 5.81774 13.5061 5.81774C13.0474 5.81752 12.6758 5.44603 12.6756 4.98726C12.6756 4.52836 13.0473 4.15604 13.5061 4.15582ZM10.7362 4.84805C10.6597 4.84805 10.597 4.91075 10.597 4.98726L10.6084 5.04123C10.6295 5.09079 10.679 5.12551 10.7362 5.12551C10.7935 5.1254 10.843 5.09082 10.8641 5.04123L10.8745 4.98726L10.8641 4.93328C10.8501 4.90007 10.8233 4.87352 10.7902 4.85942L10.7362 4.84805ZM13.5061 4.84805C13.4298 4.84827 13.3679 4.91087 13.3679 4.98726L13.3792 5.04123C13.4003 5.09063 13.4491 5.12535 13.5061 5.12551C13.5634 5.12551 13.6129 5.09077 13.634 5.04123L13.6453 4.98726L13.634 4.93328C13.613 4.8835 13.5636 4.84805 13.5061 4.84805Z" fill="#535862"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -1 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="none"><path fill="#00A166" d="M11.813 6A5.812 5.812 0 1 1 .188 6a5.812 5.812 0 0 1 11.624 0ZM5.327 9.078 9.64 4.765a.375.375 0 0 0 0-.53l-.53-.53a.375.375 0 0 0-.53 0L5.062 7.222 3.42 5.58a.375.375 0 0 0-.53 0l-.53.53a.375.375 0 0 0 0 .53l2.437 2.438a.375.375 0 0 0 .53 0Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12" fill="none"><path fill="currentColor" d="M11.813 6A5.812 5.812 0 1 1 .188 6a5.812 5.812 0 0 1 11.624 0ZM5.327 9.078 9.64 4.765a.375.375 0 0 0 0-.53l-.53-.53a.375.375 0 0 0-.53 0L5.062 7.222 3.42 5.58a.375.375 0 0 0-.53 0l-.53.53a.375.375 0 0 0 0 .53l2.437 2.438a.375.375 0 0 0 .53 0Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 347 B After Width: | Height: | Size: 352 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.71436 15.9286C6.30604 15.9286 6.78557 15.449 6.78564 14.8573V11.4286C6.78564 10.8369 6.30609 10.3573 5.71436 10.3573H4.50049V8.1366C4.85847 8.37127 5.27986 8.4998 5.71436 8.49988H9.71436C10.1877 8.49999 10.6423 8.68861 10.9771 9.02332C11.3118 9.35816 11.5005 9.81254 11.5005 10.286V10.3573H10.2856C9.69398 10.3574 9.21436 10.8369 9.21436 11.4286V14.8573C9.21443 15.4489 9.69403 15.9285 10.2856 15.9286H13.7144C14.306 15.9286 14.7856 15.449 14.7856 14.8573V11.4286C14.7856 10.8368 14.306 10.3573 13.7144 10.3573H12.5005V10.286C12.5005 9.54751 12.2071 8.83865 11.6851 8.31628C11.1627 7.79395 10.453 7.49999 9.71436 7.49988H5.71436C5.3926 7.49977 5.08355 7.37185 4.85596 7.14441C4.62839 6.91677 4.50056 6.60789 4.50049 6.28601V5.64246H5.71436C6.30604 5.64246 6.78557 5.16283 6.78564 4.57117V1.14246C6.78542 0.550989 6.30597 0.071167 5.71436 0.071167H2.28564C1.69409 0.0712424 1.21458 0.551036 1.21436 1.14246V4.57117C1.21443 5.16279 1.69402 5.64238 2.28564 5.64246H3.50049V10.3573H2.28564C1.69398 10.3574 1.21436 10.8369 1.21436 11.4286V14.8573C1.21443 15.4489 1.69403 15.9285 2.28564 15.9286H5.71436ZM2.28564 4.64246C2.24631 4.64238 2.21443 4.6105 2.21436 4.57117V1.14246C2.21458 1.10327 2.24643 1.07124 2.28564 1.07117H5.71436C5.75364 1.07117 5.78542 1.10322 5.78564 1.14246V4.57117C5.78557 4.61055 5.75376 4.64246 5.71436 4.64246H2.28564ZM2.28564 14.9286C2.24631 14.9285 2.21443 14.8966 2.21436 14.8573V11.4286C2.21436 11.3892 2.24626 11.3574 2.28564 11.3573H5.71436C5.7538 11.3573 5.78564 11.3891 5.78564 11.4286V14.8573C5.78557 14.8967 5.75376 14.9286 5.71436 14.9286H2.28564ZM10.2856 14.9286C10.2463 14.9285 10.2144 14.8966 10.2144 14.8573V11.4286C10.2144 11.3892 10.2463 11.3574 10.2856 11.3573H13.7144C13.7538 11.3573 13.7856 11.3892 13.7856 11.4286V14.8573C13.7856 14.8967 13.7537 14.9286 13.7144 14.9286H10.2856Z" fill="#535862"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -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<T extends Omit<EntityReference, 'type'>> {
|
||||
|
|
@ -56,4 +57,5 @@ export interface GenericContextType<T extends Omit<EntityReference, 'type'>> {
|
|||
openColumnDetailPanel: (column: ColumnOrTask) => void;
|
||||
closeColumnDetailPanel: () => void;
|
||||
setDisplayedColumns: (columns: ColumnOrTask[]) => void;
|
||||
changeSummary?: Record<string, ChangeSummaryEntry>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = <T extends Omit<EntityReference, 'type'>>({
|
|||
|
||||
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 = <T extends Omit<EntityReference, 'type'>>({
|
|||
openColumnDetailPanel,
|
||||
closeColumnDetailPanel,
|
||||
setDisplayedColumns,
|
||||
changeSummary,
|
||||
}),
|
||||
[
|
||||
data,
|
||||
|
|
@ -438,6 +449,7 @@ export const GenericProvider = <T extends Omit<EntityReference, 'type'>>({
|
|||
openColumnDetailPanel,
|
||||
closeColumnDetailPanel,
|
||||
setDisplayedColumns,
|
||||
changeSummary,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = ({
|
|||
/>
|
||||
)}
|
||||
<DescriptionSection
|
||||
changeSummaryEntry={descriptionChangeSummaryEntry}
|
||||
description={dataAsset.description}
|
||||
entityFqn={dataAsset.fullyQualifiedName}
|
||||
entityType={entityType}
|
||||
|
|
@ -579,6 +588,7 @@ export const DataAssetSummaryPanelV1 = ({
|
|||
<>
|
||||
<span className="d-none" data-testid="KnowledgePageSummary" />
|
||||
<DescriptionSection
|
||||
changeSummaryEntry={descriptionChangeSummaryEntry}
|
||||
description={dataAsset.description}
|
||||
entityFqn={dataAsset.fullyQualifiedName}
|
||||
entityType={entityType}
|
||||
|
|
@ -626,6 +636,7 @@ export const DataAssetSummaryPanelV1 = ({
|
|||
return (
|
||||
<>
|
||||
<DescriptionSection
|
||||
changeSummaryEntry={descriptionChangeSummaryEntry}
|
||||
description={dataAsset.description}
|
||||
entityFqn={dataAsset.fullyQualifiedName}
|
||||
entityType={entityType}
|
||||
|
|
@ -712,6 +723,7 @@ export const DataAssetSummaryPanelV1 = ({
|
|||
return (
|
||||
<>
|
||||
<DescriptionSection
|
||||
changeSummaryEntry={descriptionChangeSummaryEntry}
|
||||
description={dataAsset.description}
|
||||
entityFqn={dataAsset.fullyQualifiedName}
|
||||
entityType={entityType}
|
||||
|
|
@ -764,6 +776,7 @@ export const DataAssetSummaryPanelV1 = ({
|
|||
componentType,
|
||||
statusCounts,
|
||||
entityPermissions,
|
||||
changeSummary,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -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 = <T extends ColumnOrTask = Column>({
|
|||
onColumnsUpdate,
|
||||
}: ColumnDetailPanelProps<T>) => {
|
||||
const { t } = useTranslation();
|
||||
const { permissions } = useGenericContext();
|
||||
const { permissions, changeSummary } = useGenericContext();
|
||||
|
||||
const previousFqnRef = useRef<string | undefined>();
|
||||
const fetchedColumnFqnRef = useRef<string | undefined>();
|
||||
|
|
@ -690,6 +691,14 @@ export const ColumnDetailPanel = <T extends ColumnOrTask = Column>({
|
|||
</div>
|
||||
) : (
|
||||
<DescriptionSection
|
||||
changeSummaryEntry={
|
||||
changeSummary?.[
|
||||
`columns.${EntityLink.getTableColumnNameFromColumnFqn(
|
||||
activeColumn?.fullyQualifiedName ?? '',
|
||||
false
|
||||
)}.description`
|
||||
]
|
||||
}
|
||||
description={activeColumn?.description}
|
||||
entityFqn={activeColumn?.fullyQualifiedName}
|
||||
entityType={entityType}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { EntityType } from '../../../enums/entity.enum';
|
|||
import EntityTasks from '../../../pages/TasksPage/EntityTasks/EntityTasks.component';
|
||||
import EntityLink from '../../../utils/EntityLink';
|
||||
import { getEntityFeedLink } from '../../../utils/EntityUtils';
|
||||
import DescriptionSourceBadge from '../../common/DescriptionSourceBadge/DescriptionSourceBadge';
|
||||
import { EditIconButton } from '../../common/IconButtons/EditIconButton';
|
||||
import RichTextEditorPreviewerNew from '../../common/RichTextEditor/RichTextEditorPreviewNew';
|
||||
import { useGenericContext } from '../../Customization/GenericProvider/GenericProvider';
|
||||
|
|
@ -37,7 +38,16 @@ const TableDescription = ({
|
|||
}: TableDescriptionProps) => {
|
||||
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}
|
||||
<DescriptionSourceBadge
|
||||
changeSummaryEntry={changeSummary?.[changeSummaryKey]}
|
||||
/>
|
||||
|
||||
{!suggestionData && !isReadOnly ? (
|
||||
<div className="d-flex items-baseline gap-4">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DescriptionSectionProps> = ({
|
|||
hasPermission = false,
|
||||
entityFqn,
|
||||
entityType,
|
||||
changeSummaryEntry,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
|
@ -133,13 +135,31 @@ const DescriptionSection: React.FC<DescriptionSectionProps> = ({
|
|||
|
||||
const canShowEditButton =
|
||||
showEditButton && hasPermission && onDescriptionUpdate;
|
||||
const shouldShowMetadata = changeSummaryEntry?.changeSource != null;
|
||||
|
||||
const headerBadge = (
|
||||
<DescriptionSourceBadge
|
||||
changeSummaryEntry={changeSummaryEntry}
|
||||
showAcceptedBy={false}
|
||||
showTimestamp={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const metadataRow = (
|
||||
<DescriptionSourceBadge changeSummaryEntry={changeSummaryEntry} />
|
||||
);
|
||||
|
||||
if (!description?.trim()) {
|
||||
return (
|
||||
<EntityAttachmentProvider entityFqn={entityFqn} entityType={entityType}>
|
||||
<div className="description-section">
|
||||
<div className="description-header">
|
||||
<span className="description-title">{t('label.description')}</span>
|
||||
<div className="description-title-row">
|
||||
<span className="description-title">
|
||||
{t('label.description')}
|
||||
</span>
|
||||
{headerBadge}
|
||||
</div>
|
||||
{canShowEditButton && (
|
||||
<EditIconButton
|
||||
newLook
|
||||
|
|
@ -160,6 +180,9 @@ const DescriptionSection: React.FC<DescriptionSectionProps> = ({
|
|||
entity: t('label.description-lowercase'),
|
||||
})}
|
||||
</span>
|
||||
{shouldShowMetadata ? (
|
||||
<div className="description-metadata">{metadataRow}</div>
|
||||
) : null}
|
||||
<ModalWithMarkdownEditor
|
||||
header={t('label.edit-entity', {
|
||||
entity: t('label.description'),
|
||||
|
|
@ -182,7 +205,10 @@ const DescriptionSection: React.FC<DescriptionSectionProps> = ({
|
|||
<EntityAttachmentProvider entityFqn={entityFqn} entityType={entityType}>
|
||||
<div className="description-section">
|
||||
<div className="description-header">
|
||||
<span className="description-title">{t('label.description')}</span>
|
||||
<div className="description-title-row">
|
||||
<span className="description-title">{t('label.description')}</span>
|
||||
{headerBadge}
|
||||
</div>
|
||||
{canShowEditButton && (
|
||||
<EditIconButton
|
||||
newLook
|
||||
|
|
@ -218,6 +244,9 @@ const DescriptionSection: React.FC<DescriptionSectionProps> = ({
|
|||
{isExpanded ? t('label.show-less') : t('label.show-more')}
|
||||
</button>
|
||||
)}
|
||||
{shouldShowMetadata ? (
|
||||
<div className="description-metadata">{metadataRow}</div>
|
||||
) : null}
|
||||
<ModalWithMarkdownEditor
|
||||
header={t('label.edit-entity', {
|
||||
entity: t('label.description'),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* 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 { ChangeSummaryEntry } from '../../../rest/changeSummaryAPI';
|
||||
|
||||
export interface DescriptionSourceBadgeProps {
|
||||
changeSummaryEntry?: ChangeSummaryEntry;
|
||||
showAcceptedBy?: boolean;
|
||||
showBadge?: boolean;
|
||||
showTimestamp?: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
/*
|
||||
* Copyright 2024 Collate.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ChangeSource } from '../../../generated/type/changeSummaryMap';
|
||||
import DescriptionSourceBadge from './DescriptionSourceBadge';
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
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: () => <div data-testid="ai-suggestion-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock('../../../assets/svg/ic-automated.svg', () => ({
|
||||
ReactComponent: () => <div data-testid="automated-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock('../../../assets/svg/ic-propagated.svg', () => ({
|
||||
ReactComponent: () => <div data-testid="propagated-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock('../../../assets/svg/ic-check-circle.svg', () => ({
|
||||
ReactComponent: () => <div data-testid="check-circle-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock('../PopOverCard/UserPopOverCard', () => ({
|
||||
__esModule: true,
|
||||
default: ({ displayName }: { displayName: string }) => (
|
||||
<span data-testid="user-popover">{displayName}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('antd', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
describe('DescriptionSourceBadge', () => {
|
||||
it('should render nothing when changeSummaryEntry is undefined', () => {
|
||||
const { container } = render(
|
||||
<DescriptionSourceBadge changeSummaryEntry={undefined} />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render nothing when changeSource is undefined', () => {
|
||||
const { container } = render(
|
||||
<DescriptionSourceBadge changeSummaryEntry={{ changedBy: 'admin' }} />
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should render authored-by metadata for Manual changeSource', () => {
|
||||
render(
|
||||
<DescriptionSourceBadge
|
||||
changeSummaryEntry={{
|
||||
changeSource: ChangeSource.Manual,
|
||||
changedBy: 'admin',
|
||||
changedAt: 1700000000000,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<DescriptionSourceBadge
|
||||
changeSummaryEntry={{
|
||||
changeSource: ChangeSource.Suggested,
|
||||
changedBy: 'admin',
|
||||
changedAt: 1700000000000,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<DescriptionSourceBadge
|
||||
changeSummaryEntry={{
|
||||
changeSource: ChangeSource.Automated,
|
||||
changedBy: 'bot',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<DescriptionSourceBadge
|
||||
changeSummaryEntry={{
|
||||
changeSource: ChangeSource.Propagated,
|
||||
changedBy: 'lineage',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<DescriptionSourceBadge
|
||||
changeSummaryEntry={{
|
||||
changeSource: ChangeSource.Suggested,
|
||||
changedBy: 'John Doe',
|
||||
changedAt: 1700000000000,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<DescriptionSourceBadge
|
||||
changeSummaryEntry={{
|
||||
changeSource: ChangeSource.Automated,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('source-actor')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render badge only when metadata is disabled', () => {
|
||||
render(
|
||||
<DescriptionSourceBadge
|
||||
changeSummaryEntry={{
|
||||
changeSource: ChangeSource.Suggested,
|
||||
changedBy: 'admin',
|
||||
changedAt: 1700000000000,
|
||||
}}
|
||||
showAcceptedBy={false}
|
||||
showTimestamp={false}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<DescriptionSourceBadge
|
||||
changeSummaryEntry={{
|
||||
changeSource: ChangeSource.Ingested,
|
||||
changedBy: 'ingestion',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('should not render badge for Derived changeSource', () => {
|
||||
const { container } = render(
|
||||
<DescriptionSourceBadge
|
||||
changeSummaryEntry={{
|
||||
changeSource: ChangeSource.Derived,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Record<ChangeSource, BadgeConfig>> = {
|
||||
[ChangeSource.Suggested]: {
|
||||
labelKey: 'label.ai',
|
||||
tooltipKey: 'label.ai-generated-description',
|
||||
icon: <AISuggestionIcon height={14} width={14} />,
|
||||
className: 'badge-suggested',
|
||||
testId: 'ai-suggested-badge',
|
||||
iconOnly: true,
|
||||
},
|
||||
[ChangeSource.Automated]: {
|
||||
labelKey: 'label.automated',
|
||||
tooltipKey: 'label.automated-description',
|
||||
icon: <AutomatedIcon height={16} width={16} />,
|
||||
className: 'badge-automated',
|
||||
testId: 'automated-badge',
|
||||
iconOnly: true,
|
||||
},
|
||||
[ChangeSource.Propagated]: {
|
||||
labelKey: 'label.propagated',
|
||||
tooltipKey: 'label.description-inherited-from-parent-entity',
|
||||
icon: <PropagatedIcon height={16} width={16} />,
|
||||
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 ? (
|
||||
<Tooltip title={t(config.tooltipKey)}>
|
||||
<output
|
||||
aria-live="polite"
|
||||
className="description-source-icon"
|
||||
data-testid={config.testId}>
|
||||
{config.icon}
|
||||
</output>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title={tooltipContent}>
|
||||
<div
|
||||
className={classNames(
|
||||
'description-source-badge',
|
||||
config.className
|
||||
)}>
|
||||
{config.icon}
|
||||
<span>{t(config.labelKey)}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}, [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 ? (
|
||||
<span
|
||||
className={classNames('description-source-text', {
|
||||
'description-source-text-success': Boolean(config),
|
||||
})}
|
||||
data-testid="source-actor">
|
||||
{config ? (
|
||||
<CheckCircleIcon className="text-primary" height={12} width={12} />
|
||||
) : null}
|
||||
<span className="d-flex items-center gap-1">
|
||||
<span className="text-grey-500">{actorLabel}</span>
|
||||
|
||||
<UserPopOverCard
|
||||
showUserName
|
||||
className="text-grey-900 actor-username"
|
||||
displayName={changeSummaryEntry.changedBy}
|
||||
profileWidth={16}
|
||||
showUserProfile={false}
|
||||
userName={changeSummaryEntry.changedBy || '-'}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const timestampInfo =
|
||||
showTimestamp && relativeTime ? (
|
||||
<span className="description-source-time" data-testid="source-timestamp">
|
||||
{relativeTime}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
if (!showBadge && !actorInfo && !timestampInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="description-source-container"
|
||||
data-testid="description-source-container">
|
||||
{renderTooltipContent}
|
||||
{(actorInfo || timestampInfo) && (
|
||||
<div className="description-source-metadata">
|
||||
{actorInfo}
|
||||
{actorInfo && timestampInfo ? (
|
||||
<span className="description-source-separator">•</span>
|
||||
) : null}
|
||||
{timestampInfo}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DescriptionSourceBadge;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<Domain>();
|
||||
const { isVersionView, changeSummary } = useGenericContext<Domain>();
|
||||
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 (
|
||||
<div
|
||||
className={classNames('d-flex justify-between flex-wrap', {
|
||||
'm-t-sm': suggestions?.length > 0,
|
||||
})}>
|
||||
<div className="d-flex items-center gap-2">
|
||||
<Text className={classNames('text-sm font-medium')}>
|
||||
className={classNames(
|
||||
'description-v1-header d-flex justify-between flex-wrap',
|
||||
{
|
||||
'm-t-sm': suggestions?.length > 0,
|
||||
}
|
||||
)}>
|
||||
<div className="description-v1-title-row d-flex items-center gap-2">
|
||||
<Text
|
||||
className={classNames('description-v1-title text-sm font-medium')}>
|
||||
{t('label.description')}
|
||||
</Text>
|
||||
<DescriptionSourceBadge
|
||||
changeSummaryEntry={changeSummary?.['description']}
|
||||
showAcceptedBy={false}
|
||||
showTimestamp={false}
|
||||
/>
|
||||
{showActions && actionButtons}
|
||||
</div>
|
||||
{showSuggestions && suggestions?.length > 0 && <SuggestionsSlider />}
|
||||
</div>
|
||||
);
|
||||
}, [showActions, actionButtons, suggestions, showSuggestions]);
|
||||
}, [showActions, actionButtons, suggestions, showSuggestions, changeSummary]);
|
||||
|
||||
const content = (
|
||||
<EntityAttachmentProvider entityFqn={entityFqn} entityType={entityType}>
|
||||
|
|
@ -247,9 +262,17 @@ const DescriptionV1 = ({
|
|||
className={classNames('schema-description d-flex', className)}
|
||||
direction="vertical"
|
||||
size={16}>
|
||||
{!wrapInCard ? header : null}
|
||||
{wrapInCard ? null : header}
|
||||
<div>
|
||||
{descriptionContent}
|
||||
{!suggestionData && shouldShowDescriptionMetadata && (
|
||||
<div className="description-v1-metadata">
|
||||
<DescriptionSourceBadge
|
||||
changeSummaryEntry={changeSummary?.['description']}
|
||||
showBadge={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ModalWithMarkdownEditor
|
||||
header={t('label.edit-description-for', { entityName })}
|
||||
placeholder={t('label.enter-entity', {
|
||||
|
|
|
|||
|
|
@ -20,3 +20,15 @@
|
|||
height: calc(100% - 20px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.description-v1-title-row {
|
||||
flex-wrap: wrap;
|
||||
|
||||
.ant-space-item {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.description-v1-metadata {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* 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 { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ChangeSummaryEntry,
|
||||
ChangeSummaryParams,
|
||||
ChangeSummaryResponse,
|
||||
getChangeSummary,
|
||||
} from '../rest/changeSummaryAPI';
|
||||
|
||||
interface UseChangeSummaryResult {
|
||||
changeSummary: Record<string, ChangeSummaryEntry>;
|
||||
totalEntries: number;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
export const useChangeSummary = (
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
params?: ChangeSummaryParams
|
||||
): UseChangeSummaryResult => {
|
||||
const [changeSummary, setChangeSummary] = useState<
|
||||
Record<string, ChangeSummaryEntry>
|
||||
>({});
|
||||
const [totalEntries, setTotalEntries] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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": "أوصاف",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "תיאורים",
|
||||
|
|
|
|||
|
|
@ -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": "説明",
|
||||
|
|
|
|||
|
|
@ -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": "설명들",
|
||||
|
|
|
|||
|
|
@ -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": "वर्णने",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "توضیحات",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Описания",
|
||||
|
|
|
|||
|
|
@ -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": "คำอธิบายหลายรายการ",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "描述",
|
||||
|
|
|
|||
|
|
@ -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": "描述",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, ChangeSummaryEntry>;
|
||||
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<ChangeSummaryResponse> => {
|
||||
const response = await axiosClient.get<ChangeSummaryResponse>(
|
||||
`${BASE_URL}/${entityType}/${entityId}`,
|
||||
{ params }
|
||||
);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getChangeSummaryByFqn = async (
|
||||
entityType: string,
|
||||
fqn: string,
|
||||
params?: ChangeSummaryParams
|
||||
): Promise<ChangeSummaryResponse> => {
|
||||
const response = await axiosClient.get<ChangeSummaryResponse>(
|
||||
`${BASE_URL}/${entityType}/name/${fqn}`,
|
||||
{ params }
|
||||
);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
Loading…
Reference in a new issue