Feat(ui): add and edit certifications from asset page (#21758)

* Feat(ui): add and edit certifications from asset page (#21344)

* added styling to certification

* added tests

* changed icons for certificatie popup

---------

Co-authored-by: Karan Hotchandani <33024356+karanh37@users.noreply.github.com>

* #19529 Improvise import to include certification for Assets (Database, DatabaseSchema, DatabaseService), Introduce EDIT_CERTIFICATION policy and Fix Elastic Search Indexes on Certification (#21564)

* #19529 - Adding certification field in json schema for databaseService.json, databaseServiceCsvDocumentation.json for bulk import facility, Implemented bulk import for non-recursive databaseService import async

* #19529 -Bulk async import - databaseSchema entity

* #19529 -Bulk async import - database entity

* #19529 - Initial Implementation of Search Indexes for Certificate during bulk Import

* #19529 - Edit Certification Policy Implementation, Attaching it to DataStewards and DataConsumers, also adding it to Migration

* #19529 - Updated ElasticSearch Index mapping name

* #19529 - Updated the CSV indices for recursive import

* #19529 - Single Test working as expected but not as a test suite.

* #19529 - Fixed DatabaseSchemaResourceTest

* #19529 - Fixed DatabaseResource Test for exportImportCSV

* #19529 - Fixed DatabaseServiceResource Test for exportImportCSV

* #19529 - Updated and Improvised ElasticSearch Certification functionality

* #19529 - Added postgres migration as well along with mysql migration

* #19529 - Removed migration scripts from v160 and placed it at v180, Included mappings for database_service in zh and jp,
Applied mvn spotless:apply

* #19529 - Adding certification field in json schema for databaseService.json, databaseServiceCsvDocumentation.json for bulk import facility, Implemented bulk import for non-recursive databaseService import async

* #19529 -Bulk async import - databaseSchema entity

* #19529 -Bulk async import - database entity

* #19529 - Initial Implementation of Search Indexes for Certificate during bulk Import

* #19529 - Edit Certification Policy Implementation, Attaching it to DataStewards and DataConsumers, also adding it to Migration

* #19529 - Updated ElasticSearch Index mapping name

* #19529 - Updated the CSV indices for recursive import

* #19529 - Single Test working as expected but not as a test suite.

* #19529 - Fixed DatabaseSchemaResourceTest

* #19529 - Fixed DatabaseResource Test for exportImportCSV

* #19529 - Fixed DatabaseServiceResource Test for exportImportCSV

* #19529 - Updated and Improvised ElasticSearch Certification functionality

* #19529 - Added postgres migration as well along with mysql migration

* #19529 - Removed migration scripts from v160 and placed it at v180, Included mappings for database_service in zh and jp,
Applied mvn spotless:apply

* Applied mvn spotless:apply

* Reused the Existing UPDATE_CERTIFICATION_SCRIPT for ElasticSearch Indexing

* Added field certification in the static String FIELDS

* fix playwright test around bulk action

* #19529 - Persisting Null or empty in the bulk import for certification

* #19529 - Persisting Null or empty in the bulk import for certification - Moved the if block to the top

* mvn spotless:apply

* Reverted an unimportant file

* mvn spotless:apply

* #19529 - Persisting the field Certification in clearFIeldsInternal

* typescript files for edit_certification

* Revert "typescript files for edit_certification"

This reverts commit f5e5514a98008cbd0b62d7cb21fefe61659e97cb.

* typescript files for edit_certification

* mvn:spotless:apply

* Removed correction

* needed typescript file for edit_certification

* Removed Unnecessary Comments

* Improved Test Cases - Added DATA_ASSET_SEARCH alias instead of GLOBAL_ALIAS

* Fixed csv values in order

---------

Co-authored-by: Ashish Gupta <ashish@getcollate.io>
Co-authored-by: System Administrator <root@192.168.1.4>
Co-authored-by: sonika-shah <58761340+sonika-shah@users.noreply.github.com>
Co-authored-by: Karan Hotchandani <33024356+karanh37@users.noreply.github.com>

* update certification component

* Fixed Certification Indexes

* Fixed Missing Certification Label for DBSchema

* add certification changes

* fix test errors

* Remove Certification field and mapping from database service asset

* Removed certification from schema and fixed test

* fix tests

---------

Co-authored-by: Dhruv Parmar <83108871+dhruvjsx@users.noreply.github.com>
Co-authored-by: Ram Narayan Balaji <81347100+yan-3005@users.noreply.github.com>
Co-authored-by: Ashish Gupta <ashish@getcollate.io>
Co-authored-by: System Administrator <root@192.168.1.4>
Co-authored-by: sonika-shah <58761340+sonika-shah@users.noreply.github.com>
Co-authored-by: Ram Narayan Balaji <ramnarayanb3005@gmail.com>
This commit is contained in:
Karan Hotchandani 2025-06-17 11:28:07 +05:30 committed by GitHub
parent 961a5357dd
commit 2689676b9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 1569 additions and 241 deletions

View File

@ -75,6 +75,7 @@ import org.openmetadata.schema.entity.data.DatabaseSchema;
import org.openmetadata.schema.entity.data.StoredProcedure;
import org.openmetadata.schema.entity.data.Table;
import org.openmetadata.schema.type.ApiStatus;
import org.openmetadata.schema.type.AssetCertification;
import org.openmetadata.schema.type.ChangeEvent;
import org.openmetadata.schema.type.Column;
import org.openmetadata.schema.type.ColumnDataType;
@ -378,6 +379,19 @@ public abstract class EntityCsv<T extends EntityInterface> {
return tagLabels;
}
protected AssetCertification getCertificationLabels(String certificationTag) {
if (nullOrEmpty(certificationTag)) {
return null;
}
TagLabel certificationLabel =
new TagLabel().withTagFQN(certificationTag).withSource(TagLabel.TagSource.CLASSIFICATION);
return new AssetCertification()
.withTagLabel(certificationLabel)
.withAppliedDate(System.currentTimeMillis())
.withExpiryDate(System.currentTimeMillis());
}
public Map<String, Object> getExtension(CSVPrinter printer, CSVRecord csvRecord, int fieldNumber)
throws IOException {
String extensionString = csvRecord.get(fieldNumber);
@ -1077,6 +1091,8 @@ public abstract class EntityCsv<T extends EntityInterface> {
Pair.of(4, TagSource.CLASSIFICATION),
Pair.of(5, TagSource.GLOSSARY),
Pair.of(6, TagSource.CLASSIFICATION)));
AssetCertification certification = getCertificationLabels(csvRecord.get(7));
schema
.withId(UUID.randomUUID())
.withName(csvRecord.get(0))
@ -1085,10 +1101,11 @@ public abstract class EntityCsv<T extends EntityInterface> {
.withDescription(csvRecord.get(2))
.withOwners(getOwners(printer, csvRecord, 3))
.withTags(tagLabels)
.withRetentionPeriod(csvRecord.get(7))
.withSourceUrl(csvRecord.get(8))
.withDomain(getEntityReference(printer, csvRecord, 9, Entity.DOMAIN))
.withExtension(getExtension(printer, csvRecord, 10))
.withCertification(certification)
.withRetentionPeriod(csvRecord.get(8))
.withSourceUrl(csvRecord.get(9))
.withDomain(getEntityReference(printer, csvRecord, 10, Entity.DOMAIN))
.withExtension(getExtension(printer, csvRecord, 11))
.withUpdatedAt(System.currentTimeMillis())
.withUpdatedBy(importedBy);
if (processRecord) {
@ -1155,6 +1172,7 @@ public abstract class EntityCsv<T extends EntityInterface> {
Pair.of(4, TagSource.CLASSIFICATION),
Pair.of(5, TagSource.GLOSSARY),
Pair.of(6, TagSource.CLASSIFICATION)));
AssetCertification certification = getCertificationLabels(csvRecord.get(7));
// Populate table attributes
table
@ -1162,10 +1180,11 @@ public abstract class EntityCsv<T extends EntityInterface> {
.withDescription(csvRecord.get(2))
.withOwners(getOwners(printer, csvRecord, 3))
.withTags(tagLabels)
.withRetentionPeriod(csvRecord.get(7))
.withSourceUrl(csvRecord.get(8))
.withDomain(getEntityReference(printer, csvRecord, 9, Entity.DOMAIN))
.withExtension(getExtension(printer, csvRecord, 10))
.withCertification(certification)
.withRetentionPeriod(csvRecord.get(8))
.withSourceUrl(csvRecord.get(9))
.withDomain(getEntityReference(printer, csvRecord, 10, Entity.DOMAIN))
.withExtension(getExtension(printer, csvRecord, 11))
.withUpdatedAt(System.currentTimeMillis())
.withUpdatedBy(importedBy);
if (processRecord) {
@ -1228,8 +1247,8 @@ public abstract class EntityCsv<T extends EntityInterface> {
Pair.of(4, TagSource.CLASSIFICATION),
Pair.of(5, TagSource.GLOSSARY),
Pair.of(6, TagSource.CLASSIFICATION)));
String languageStr = csvRecord.get(18);
AssetCertification certification = getCertificationLabels(csvRecord.get(7));
String languageStr = csvRecord.get(19);
StoredProcedureLanguage language = null;
if (languageStr != null && !languageStr.isEmpty()) {
@ -1241,16 +1260,17 @@ public abstract class EntityCsv<T extends EntityInterface> {
}
StoredProcedureCode storedProcedureCode =
new StoredProcedureCode().withCode(csvRecord.get(17)).withLanguage(language);
new StoredProcedureCode().withCode(csvRecord.get(18)).withLanguage(language);
sp.withDisplayName(csvRecord.get(1))
.withDescription(csvRecord.get(2))
.withOwners(getOwners(printer, csvRecord, 3))
.withTags(tagLabels)
.withSourceUrl(csvRecord.get(8))
.withDomain(getEntityReference(printer, csvRecord, 9, Entity.DOMAIN))
.withCertification(certification)
.withSourceUrl(csvRecord.get(9))
.withDomain(getEntityReference(printer, csvRecord, 10, Entity.DOMAIN))
.withStoredProcedureCode(storedProcedureCode)
.withExtension(getExtension(printer, csvRecord, 10));
.withExtension(getExtension(printer, csvRecord, 11));
if (processRecord) {
// Only create the stored procedure if the schema actually exists
@ -1318,7 +1338,7 @@ public abstract class EntityCsv<T extends EntityInterface> {
private void updateColumnsFromCsvRecursive(Table table, CSVRecord csvRecord, CSVPrinter printer)
throws IOException {
String columnFqn = csvRecord.get(0);
String columnFullyQualifiedName = csvRecord.get(12);
String columnFullyQualifiedName = csvRecord.get(13);
Column column = null;
boolean columnExists = false;
try {
@ -1338,8 +1358,8 @@ public abstract class EntityCsv<T extends EntityInterface> {
column.withDisplayName(csvRecord.get(1));
column.withDescription(csvRecord.get(2));
column.withDataTypeDisplay(csvRecord.get(13));
String dataTypeStr = csvRecord.get(14);
column.withDataTypeDisplay(csvRecord.get(14));
String dataTypeStr = csvRecord.get(15);
if (nullOrEmpty(dataTypeStr)) {
throw new IllegalArgumentException(
"Column dataType is mandatory for column: " + csvRecord.get(0));
@ -1353,11 +1373,11 @@ public abstract class EntityCsv<T extends EntityInterface> {
}
if (column.getDataType() == ColumnDataType.ARRAY) {
if (nullOrEmpty(csvRecord.get(15))) {
if (nullOrEmpty(csvRecord.get(16))) {
throw new IllegalArgumentException(
"Array data type is mandatory for ARRAY columns: " + csvRecord.get(0));
}
column.withArrayDataType(ColumnDataType.fromValue(csvRecord.get(15)));
column.withArrayDataType(ColumnDataType.fromValue(csvRecord.get(16)));
}
if (column.getDataType() == ColumnDataType.STRUCT && column.getChildren() == null) {
@ -1365,7 +1385,7 @@ public abstract class EntityCsv<T extends EntityInterface> {
}
column.withDataLength(
parseDataLength(csvRecord.get(16), column.getDataType(), column.getName()));
parseDataLength(csvRecord.get(17), column.getDataType(), column.getName()));
List<TagLabel> tagLabels =
getTagLabels(

View File

@ -50,6 +50,7 @@ public class ResourceRegistry {
mapFieldOperation(MetadataOperation.EDIT_TEAMS, "teams");
mapFieldOperation(MetadataOperation.EDIT_DESCRIPTION, Entity.FIELD_DESCRIPTION);
mapFieldOperation(MetadataOperation.EDIT_DISPLAY_NAME, Entity.FIELD_DISPLAY_NAME);
mapFieldOperation(MetadataOperation.EDIT_CERTIFICATION, Entity.FIELD_CERTIFICATION);
// Set up "all" resource descriptor that includes operations for all entities
List<MetadataOperation> allOperations = Arrays.asList(MetadataOperation.values());
@ -98,6 +99,9 @@ public class ResourceRegistry {
if (entityFields.contains("reviewers")) {
operations.add(MetadataOperation.EDIT_REVIEWERS);
}
if (entityFields.contains(Entity.FIELD_CERTIFICATION)) {
operations.add(MetadataOperation.EDIT_CERTIFICATION);
}
return new ArrayList<>(operations);
}

View File

@ -44,6 +44,7 @@ import org.openmetadata.schema.entity.data.DatabaseSchema;
import org.openmetadata.schema.entity.data.StoredProcedure;
import org.openmetadata.schema.entity.data.Table;
import org.openmetadata.schema.entity.services.DatabaseService;
import org.openmetadata.schema.type.AssetCertification;
import org.openmetadata.schema.type.DatabaseProfilerConfig;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.Include;
@ -351,6 +352,11 @@ public class DatabaseRepository extends EntityRepository<Database> {
addTagLabels(recordList, entity.getTags());
addGlossaryTerms(recordList, entity.getTags());
addTagTiers(recordList, entity.getTags());
addField(
recordList,
entity.getCertification() != null && entity.getCertification().getTagLabel() != null
? entity.getCertification().getTagLabel().getTagFQN()
: "");
Object retentionPeriod = EntityUtil.getEntityField(entity, "retentionPeriod");
Object sourceUrl = EntityUtil.getEntityField(entity, "sourceUrl");
addField(recordList, retentionPeriod == null ? "" : retentionPeriod.toString());
@ -411,9 +417,9 @@ public class DatabaseRepository extends EntityRepository<Database> {
}
// Get entityType and fullyQualifiedName if provided
String entityType = csvRecord.size() > 11 ? csvRecord.get(11) : DATABASE_SCHEMA;
String entityType = csvRecord.size() > 12 ? csvRecord.get(12) : DATABASE_SCHEMA;
String entityFQN =
csvRecord.size() > 12 ? StringEscapeUtils.unescapeCsv(csvRecord.get(12)) : null;
csvRecord.size() > 13 ? StringEscapeUtils.unescapeCsv(csvRecord.get(13)) : null;
if (DATABASE_SCHEMA.equals(entityType)) {
createSchemaEntity(printer, csvRecord, entityFQN);
@ -443,7 +449,8 @@ public class DatabaseRepository extends EntityRepository<Database> {
.withService(database.getService());
}
// Headers: name, displayName, description, owner, tags, glossaryTerms, tiers retentionPeriod,
// Headers: name, displayName, description, owner, tags, glossaryTerms, tiers, certification,
// retentionPeriod,
// sourceUrl, domain
// Field 1,2,3,6,7 - database schema name, displayName, description
List<TagLabel> tagLabels =
@ -454,6 +461,9 @@ public class DatabaseRepository extends EntityRepository<Database> {
Pair.of(4, TagLabel.TagSource.CLASSIFICATION),
Pair.of(5, TagLabel.TagSource.GLOSSARY),
Pair.of(6, TagLabel.TagSource.CLASSIFICATION)));
AssetCertification certification = getCertificationLabels(csvRecord.get(7));
schema
.withName(csvRecord.get(0))
.withFullyQualifiedName(schemaFqn)
@ -461,10 +471,11 @@ public class DatabaseRepository extends EntityRepository<Database> {
.withDescription(csvRecord.get(2))
.withOwners(getOwners(printer, csvRecord, 3))
.withTags(tagLabels)
.withRetentionPeriod(csvRecord.get(7))
.withSourceUrl(csvRecord.get(8))
.withDomain(getEntityReference(printer, csvRecord, 9, Entity.DOMAIN))
.withExtension(getExtension(printer, csvRecord, 10));
.withCertification(certification)
.withRetentionPeriod(csvRecord.get(8))
.withSourceUrl(csvRecord.get(9))
.withDomain(getEntityReference(printer, csvRecord, 10, Entity.DOMAIN))
.withExtension(getExtension(printer, csvRecord, 11));
if (processRecord) {
createEntity(printer, csvRecord, schema, DATABASE_SCHEMA);
}

View File

@ -44,6 +44,7 @@ import org.openmetadata.schema.entity.data.Database;
import org.openmetadata.schema.entity.data.DatabaseSchema;
import org.openmetadata.schema.entity.data.StoredProcedure;
import org.openmetadata.schema.entity.data.Table;
import org.openmetadata.schema.type.AssetCertification;
import org.openmetadata.schema.type.DatabaseSchemaProfilerConfig;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.Include;
@ -368,6 +369,11 @@ public class DatabaseSchemaRepository extends EntityRepository<DatabaseSchema> {
addTagLabels(recordList, entity.getTags());
addGlossaryTerms(recordList, entity.getTags());
addTagTiers(recordList, entity.getTags());
addField(
recordList,
entity.getCertification() != null && entity.getCertification().getTagLabel() != null
? entity.getCertification().getTagLabel().getTagFQN()
: "");
Object retentionPeriod = EntityUtil.getEntityField(entity, "retentionPeriod");
Object sourceUrl = EntityUtil.getEntityField(entity, "sourceUrl");
addField(recordList, retentionPeriod == null ? "" : retentionPeriod.toString());
@ -433,7 +439,7 @@ public class DatabaseSchemaRepository extends EntityRepository<DatabaseSchema> {
.withDatabaseSchema(schema.getEntityReference());
}
// Headers: name, displayName, description, owners, tags, glossaryTerms, tiers
// Headers: name, displayName, description, owners, tags, glossaryTerms, tiers, certification,
// retentionPeriod,
// sourceUrl, domain
// Field 1,2,3,6,7 - database schema name, displayName, description
@ -445,6 +451,9 @@ public class DatabaseSchemaRepository extends EntityRepository<DatabaseSchema> {
Pair.of(4, TagLabel.TagSource.CLASSIFICATION),
Pair.of(5, TagLabel.TagSource.GLOSSARY),
Pair.of(6, TagLabel.TagSource.CLASSIFICATION)));
AssetCertification certification = getCertificationLabels(csvRecord.get(7));
table
.withName(csvRecord.get(0))
.withFullyQualifiedName(tableFqn)
@ -452,11 +461,12 @@ public class DatabaseSchemaRepository extends EntityRepository<DatabaseSchema> {
.withDescription(csvRecord.get(2))
.withOwners(getOwners(printer, csvRecord, 3))
.withTags(tagLabels)
.withRetentionPeriod(csvRecord.get(7))
.withSourceUrl(csvRecord.get(8))
.withCertification(certification)
.withRetentionPeriod(csvRecord.get(8))
.withSourceUrl(csvRecord.get(9))
.withColumns(nullOrEmpty(table.getColumns()) ? new ArrayList<>() : table.getColumns())
.withDomain(getEntityReference(printer, csvRecord, 9, Entity.DOMAIN))
.withExtension(getExtension(printer, csvRecord, 10));
.withDomain(getEntityReference(printer, csvRecord, 10, Entity.DOMAIN))
.withExtension(getExtension(printer, csvRecord, 11));
if (processRecord) {
createEntity(printer, csvRecord, table, TABLE);
@ -468,8 +478,8 @@ public class DatabaseSchemaRepository extends EntityRepository<DatabaseSchema> {
CSVRecord csvRecord = getNextRecord(printer, csvRecords);
// Get entityType and fullyQualifiedName if provided
String entityType = csvRecord.size() > 11 ? csvRecord.get(11) : TABLE;
String entityFQN = csvRecord.size() > 12 ? csvRecord.get(12) : null;
String entityType = csvRecord.size() > 12 ? csvRecord.get(12) : TABLE;
String entityFQN = csvRecord.size() > 13 ? csvRecord.get(13) : null;
if (TABLE.equals(entityType)) {
createTableEntity(printer, csvRecord, entityFQN);

View File

@ -44,6 +44,7 @@ import org.openmetadata.schema.entity.data.Database;
import org.openmetadata.schema.entity.data.DatabaseSchema;
import org.openmetadata.schema.entity.services.DatabaseService;
import org.openmetadata.schema.entity.services.ServiceType;
import org.openmetadata.schema.type.AssetCertification;
import org.openmetadata.schema.type.Include;
import org.openmetadata.schema.type.TagLabel;
import org.openmetadata.schema.type.csv.CsvDocumentation;
@ -165,6 +166,11 @@ public class DatabaseServiceRepository
addTagLabels(recordList, entity.getTags());
addGlossaryTerms(recordList, entity.getTags());
addTagTiers(recordList, entity.getTags());
addField(
recordList,
entity.getCertification() != null && entity.getCertification().getTagLabel() != null
? entity.getCertification().getTagLabel().getTagFQN()
: "");
if (recursive) {
Object retentionPeriod = EntityUtil.getEntityField(entity, "retentionPeriod");
@ -226,8 +232,8 @@ public class DatabaseServiceRepository
database = new Database().withService(service.getEntityReference());
}
// Headers: name, displayName, description, owners, tags, glossaryTerms, tiers, domain
// Field 1,2,3,6,7 - database service name, displayName, description
// Headers: name, displayName, description, owners, tags, glossaryTerms, tiers, certification,
// domain, extension
List<TagLabel> tagLabels =
getTagLabels(
printer,
@ -236,6 +242,9 @@ public class DatabaseServiceRepository
Pair.of(4, TagLabel.TagSource.CLASSIFICATION),
Pair.of(5, TagLabel.TagSource.GLOSSARY),
Pair.of(6, TagLabel.TagSource.CLASSIFICATION)));
AssetCertification certification = getCertificationLabels(csvRecord.get(7));
database
.withName(csvRecord.get(0))
.withFullyQualifiedName(databaseFqn)
@ -243,8 +252,9 @@ public class DatabaseServiceRepository
.withDescription(csvRecord.get(2))
.withOwners(getOwners(printer, csvRecord, 3))
.withTags(tagLabels)
.withDomain(getEntityReference(printer, csvRecord, 7, Entity.DOMAIN))
.withExtension(getExtension(printer, csvRecord, 8));
.withCertification(certification)
.withDomain(getEntityReference(printer, csvRecord, 8, Entity.DOMAIN))
.withExtension(getExtension(printer, csvRecord, 9));
if (processRecord) {
createEntity(printer, csvRecord, database, DATABASE);
@ -256,8 +266,8 @@ public class DatabaseServiceRepository
CSVRecord csvRecord = getNextRecord(printer, csvRecords);
// Get entityType and fullyQualifiedName if provided
String entityType = csvRecord.size() > 11 ? csvRecord.get(11) : DATABASE;
String entityFQN = csvRecord.size() > 12 ? csvRecord.get(12) : null;
String entityType = csvRecord.size() > 12 ? csvRecord.get(12) : DATABASE;
String entityFQN = csvRecord.size() > 13 ? csvRecord.get(13) : null;
if (DATABASE.equals(entityType)) {
createDatabaseEntity(printer, csvRecord, entityFQN);
@ -298,15 +308,17 @@ public class DatabaseServiceRepository
Pair.of(4, TagLabel.TagSource.CLASSIFICATION),
Pair.of(5, TagLabel.TagSource.GLOSSARY),
Pair.of(6, TagLabel.TagSource.CLASSIFICATION)));
AssetCertification certification = getCertificationLabels(csvRecord.get(7));
database
.withName(csvRecord.get(0))
.withDisplayName(csvRecord.get(1))
.withDescription(csvRecord.get(2))
.withOwners(getOwners(printer, csvRecord, 3))
.withTags(tagLabels)
.withSourceUrl(csvRecord.get(8))
.withDomain(getEntityReference(printer, csvRecord, 9, Entity.DOMAIN))
.withExtension(getExtension(printer, csvRecord, 10));
.withCertification(certification)
.withSourceUrl(csvRecord.get(9))
.withDomain(getEntityReference(printer, csvRecord, 10, Entity.DOMAIN))
.withExtension(getExtension(printer, csvRecord, 11));
if (processRecord) {
createEntity(printer, csvRecord, database, DATABASE);
@ -364,6 +376,7 @@ public class DatabaseServiceRepository
Pair.of(4, TagLabel.TagSource.CLASSIFICATION),
Pair.of(5, TagLabel.TagSource.GLOSSARY),
Pair.of(6, TagLabel.TagSource.CLASSIFICATION)));
AssetCertification certification = getCertificationLabels(csvRecord.get(7));
schema
.withId(UUID.randomUUID())
.withName(csvRecord.get(0))
@ -372,10 +385,11 @@ public class DatabaseServiceRepository
.withDescription(csvRecord.get(2))
.withOwners(getOwners(printer, csvRecord, 3))
.withTags(tagLabels)
.withRetentionPeriod(csvRecord.get(7))
.withSourceUrl(csvRecord.get(8))
.withDomain(getEntityReference(printer, csvRecord, 9, Entity.DOMAIN))
.withExtension(getExtension(printer, csvRecord, 10))
.withCertification(certification)
.withRetentionPeriod(csvRecord.get(8))
.withSourceUrl(csvRecord.get(9))
.withDomain(getEntityReference(printer, csvRecord, 10, Entity.DOMAIN))
.withExtension(getExtension(printer, csvRecord, 11))
.withUpdatedAt(System.currentTimeMillis())
.withUpdatedBy(importedBy);
if (processRecord) {

View File

@ -1057,7 +1057,10 @@ public abstract class EntityRepository<T extends EntityInterface> {
public final T setFieldsInternal(T entity, Fields fields) {
entity.setOwners(fields.contains(FIELD_OWNERS) ? getOwners(entity) : entity.getOwners());
entity.setTags(fields.contains(FIELD_TAGS) ? getTags(entity) : entity.getTags());
entity.setCertification(fields.contains(FIELD_TAGS) ? getCertification(entity) : null);
entity.setCertification(
fields.contains(FIELD_TAGS) || fields.contains(FIELD_CERTIFICATION)
? getCertification(entity)
: null);
entity.setExtension(
fields.contains(FIELD_EXTENSION) ? getExtension(entity) : entity.getExtension());
// Always return domains of entity
@ -3698,8 +3701,21 @@ public abstract class EntityRepository<T extends EntityInterface> {
AssetCertification origCertification = original.getCertification();
AssetCertification updatedCertification = updated.getCertification();
if (Objects.equals(origCertification, updatedCertification) || updatedCertification == null)
LOG.debug(
"Updating certification - Original: {}, Updated: {}",
origCertification,
updatedCertification);
if (updatedCertification == null) {
LOG.debug("Setting certification to null");
recordChange(FIELD_CERTIFICATION, origCertification, updatedCertification, true);
return;
}
if (Objects.equals(origCertification, updatedCertification)) {
LOG.debug("Certification unchanged");
return;
}
SystemRepository systemRepository = Entity.getSystemRepository();
AssetCertificationSettings assetCertificationSettings =

View File

@ -863,6 +863,7 @@ public class TableRepository extends EntityRepository<Table> {
// More fields that should be empty for columns
addField(recordList, ""); // tiers (empty for columns)
addField(recordList, ""); // certification (empty for columns)
addField(recordList, ""); // retentionPeriod (empty for columns)
addField(recordList, ""); // sourceUrl (empty for columns)
addField(recordList, ""); // domain (empty for columns)

View File

@ -0,0 +1,20 @@
package org.openmetadata.service.migration.mysql.v180;
import static org.openmetadata.service.migration.utils.v180.MigrationUtil.addCertificationOperationsToPolicy;
import lombok.SneakyThrows;
import org.openmetadata.service.migration.api.MigrationProcessImpl;
import org.openmetadata.service.migration.utils.MigrationFile;
public class Migration extends MigrationProcessImpl {
public Migration(MigrationFile migrationFile) {
super(migrationFile);
}
@Override
@SneakyThrows
public void runDataMigration() {
addCertificationOperationsToPolicy(collectionDAO);
}
}

View File

@ -0,0 +1,20 @@
package org.openmetadata.service.migration.postgres.v180;
import static org.openmetadata.service.migration.utils.v180.MigrationUtil.addCertificationOperationsToPolicy;
import lombok.SneakyThrows;
import org.openmetadata.service.migration.api.MigrationProcessImpl;
import org.openmetadata.service.migration.utils.MigrationFile;
public class Migration extends MigrationProcessImpl {
public Migration(MigrationFile migrationFile) {
super(migrationFile);
}
@Override
@SneakyThrows
public void runDataMigration() {
addCertificationOperationsToPolicy(collectionDAO);
}
}

View File

@ -0,0 +1,27 @@
package org.openmetadata.service.migration.utils.v180;
import static org.openmetadata.service.migration.utils.v160.MigrationUtil.addOperationsToPolicyRule;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.service.jdbi3.CollectionDAO;
@Slf4j
public class MigrationUtil {
public static void addCertificationOperationsToPolicy(CollectionDAO collectionDAO) {
addOperationsToPolicyRule(
"DataConsumerPolicy",
"DataConsumerPolicy-EditRule",
List.of(MetadataOperation.EDIT_CERTIFICATION),
collectionDAO);
addOperationsToPolicyRule(
"DataStewardPolicy",
"DataStewardPolicy-EditRule",
List.of(MetadataOperation.EDIT_CERTIFICATION),
collectionDAO);
}
}

View File

@ -79,7 +79,7 @@ public class DatabaseResource extends EntityResource<Database, DatabaseRepositor
public static final String COLLECTION_PATH = "v1/databases/";
private final DatabaseMapper mapper = new DatabaseMapper();
static final String FIELDS =
"owners,databaseSchemas,usageSummary,location,tags,extension,domain,sourceHash,followers";
"owners,databaseSchemas,usageSummary,location,tags,certification,extension,domain,sourceHash,followers";
@Override
public Database addHref(UriInfo uriInfo, Database db) {

View File

@ -78,7 +78,7 @@ public class DatabaseSchemaResource
private final DatabaseSchemaMapper mapper = new DatabaseSchemaMapper();
public static final String COLLECTION_PATH = "v1/databaseSchemas/";
static final String FIELDS =
"owners,tables,usageSummary,tags,extension,domain,sourceHash,followers";
"owners,tables,usageSummary,tags,certification,extension,domain,sourceHash,followers";
@Override
public DatabaseSchema addHref(UriInfo uriInfo, DatabaseSchema schema) {

View File

@ -15,6 +15,7 @@ import static org.openmetadata.service.Entity.RAW_COST_ANALYSIS_REPORT_DATA;
import static org.openmetadata.service.Entity.WEB_ANALYTIC_ENTITY_VIEW_REPORT_DATA;
import static org.openmetadata.service.Entity.WEB_ANALYTIC_USER_ACTIVITY_REPORT_DATA;
import static org.openmetadata.service.search.SearchClient.ADD_OWNERS_SCRIPT;
import static org.openmetadata.service.search.SearchClient.DATA_ASSET_SEARCH_ALIAS;
import static org.openmetadata.service.search.SearchClient.DEFAULT_UPDATE_SCRIPT;
import static org.openmetadata.service.search.SearchClient.GLOBAL_SEARCH_ALIAS;
import static org.openmetadata.service.search.SearchClient.PROPAGATE_ENTITY_REFERENCE_FIELD_SCRIPT;
@ -70,6 +71,7 @@ import java.util.Set;
import java.util.TreeSet;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
@ -91,6 +93,7 @@ import org.openmetadata.schema.service.configuration.elasticsearch.ElasticSearch
import org.openmetadata.schema.service.configuration.elasticsearch.NaturalLanguageSearchConfiguration;
import org.openmetadata.schema.tests.DataQualityReport;
import org.openmetadata.schema.tests.TestSuite;
import org.openmetadata.schema.type.AssetCertification;
import org.openmetadata.schema.type.ChangeDescription;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.FieldChange;
@ -106,6 +109,7 @@ import org.openmetadata.service.search.nlq.NLQService;
import org.openmetadata.service.search.nlq.NLQServiceFactory;
import org.openmetadata.service.search.opensearch.OpenSearchClient;
import org.openmetadata.service.security.policyevaluator.SubjectContext;
import org.openmetadata.service.util.EntityUtil;
import org.openmetadata.service.util.FullyQualifiedName;
import org.openmetadata.service.util.JsonUtils;
import org.openmetadata.service.workflows.searchIndex.ReindexingUtil;
@ -124,7 +128,7 @@ public class SearchRepository {
private final List<String> inheritableFields =
List.of(
FIELD_OWNERS,
Entity.FIELD_DOMAIN,
FIELD_DOMAIN,
Entity.FIELD_DISABLED,
Entity.FIELD_TEST_SUITES,
FIELD_DISPLAY_NAME);
@ -528,22 +532,81 @@ public class SearchRepository {
}
}
private static final String CERTIFICATION = "Certification";
private static final String CERTIFICATION_FIELD = "certification";
private static final String CERTIFICATION_TAG_FQN_FIELD = "certification.tagLabel.tagFQN";
public void propagateCertificationTags(
String entityType, EntityInterface entity, ChangeDescription changeDescription) {
if (changeDescription != null && entityType.equalsIgnoreCase(Entity.TAG)) {
Tag tagEntity = (Tag) entity;
if (tagEntity.getClassification().getFullyQualifiedName().equals("Certification")) {
Map<String, Object> paramMap = new HashMap<>();
paramMap.put("name", entity.getName());
paramMap.put("description", entity.getDescription());
paramMap.put("tagFQN", entity.getFullyQualifiedName());
paramMap.put("style", entity.getStyle());
searchClient.updateChildren(
GLOBAL_SEARCH_ALIAS,
new ImmutablePair<>("certification.tagLabel.tagFQN", entity.getFullyQualifiedName()),
new ImmutablePair<>(UPDATE_CERTIFICATION_SCRIPT, paramMap));
}
if (changeDescription == null) {
return;
}
if (Entity.TAG.equalsIgnoreCase(entityType)) {
handleTagEntityUpdate((Tag) entity);
} else {
handleEntityCertificationUpdate(entity, changeDescription);
}
}
private void handleTagEntityUpdate(Tag tagEntity) {
if (CERTIFICATION.equals(tagEntity.getClassification().getFullyQualifiedName())) {
updateCertificationInSearch(tagEntity);
}
}
private void updateCertificationInSearch(Tag tagEntity) {
Map<String, Object> paramMap = new HashMap<>();
paramMap.put("name", tagEntity.getName());
paramMap.put("description", tagEntity.getDescription());
paramMap.put("tagFQN", tagEntity.getFullyQualifiedName());
paramMap.put("style", tagEntity.getStyle());
searchClient.updateChildren(
DATA_ASSET_SEARCH_ALIAS,
new ImmutablePair<>(CERTIFICATION_TAG_FQN_FIELD, tagEntity.getFullyQualifiedName()),
new ImmutablePair<>(UPDATE_CERTIFICATION_SCRIPT, paramMap));
}
private void handleEntityCertificationUpdate(EntityInterface entity, ChangeDescription change) {
if (!isCertificationUpdated(change)) {
return;
}
AssetCertification certification = getCertificationFromEntity(entity);
updateEntityCertificationInSearch(entity, certification);
}
private boolean isCertificationUpdated(ChangeDescription change) {
return Stream.concat(
Stream.concat(change.getFieldsUpdated().stream(), change.getFieldsAdded().stream()),
change.getFieldsDeleted().stream())
.anyMatch(fieldChange -> CERTIFICATION_FIELD.equals(fieldChange.getName()));
}
private AssetCertification getCertificationFromEntity(EntityInterface entity) {
return (AssetCertification) EntityUtil.getEntityField(entity, CERTIFICATION_FIELD);
}
private void updateEntityCertificationInSearch(
EntityInterface entity, AssetCertification certification) {
IndexMapping indexMapping = entityIndexMap.get(entity.getEntityReference().getType());
String indexName = indexMapping.getIndexName(clusterAlias);
Map<String, Object> paramMap = new HashMap<>();
if (certification != null && certification.getTagLabel() != null) {
paramMap.put("name", certification.getTagLabel().getName());
paramMap.put("description", certification.getTagLabel().getDescription());
paramMap.put("tagFQN", certification.getTagLabel().getTagFQN());
paramMap.put("style", certification.getTagLabel().getStyle());
} else {
paramMap.put("name", null);
paramMap.put("description", null);
paramMap.put("tagFQN", null);
paramMap.put("style", null);
}
searchClient.updateEntity(
indexName, entity.getId().toString(), paramMap, UPDATE_CERTIFICATION_SCRIPT);
}
public void propagateToRelatedEntities(
@ -622,7 +685,7 @@ public class SearchRepository {
searchClient.updateByFqnPrefix(GLOBAL_SEARCH_ALIAS, oldFQN, newFQN, TAGS_FQN);
}
if (field.getName().equalsIgnoreCase(Entity.FIELD_DISPLAY_NAME)) {
if (field.getName().equalsIgnoreCase(FIELD_DISPLAY_NAME)) {
Map<String, Object> updates = new HashMap<>();
updates.put("displayName", field.getNewValue().toString());
paramMap.put("tagFQN", oldFQN);
@ -692,7 +755,7 @@ public class SearchRepository {
if (field.getName().equals(Entity.FIELD_TEST_SUITES)) {
scriptTxt.append(PROPAGATE_TEST_SUITES_SCRIPT);
fieldData.put(Entity.FIELD_TEST_SUITES, field.getNewValue());
} else if (field.getName().equals(Entity.FIELD_DISPLAY_NAME)) {
} else if (field.getName().equals(FIELD_DISPLAY_NAME)) {
String fieldPath =
getFieldPath(entity.getEntityReference().getType(), field.getName());
fieldData.put(field.getName(), field.getNewValue().toString());
@ -732,7 +795,7 @@ public class SearchRepository {
}
scriptTxt.append(" ");
} catch (UnhandledServerException e) {
if (field.getName().equals(Entity.FIELD_DISPLAY_NAME)) {
if (field.getName().equals(FIELD_DISPLAY_NAME)) {
String fieldPath =
getFieldPath(entity.getEntityReference().getType(), field.getName());
fieldData.put(field.getName(), field.getNewValue().toString());

View File

@ -137,6 +137,8 @@ public class JsonPatchUtils {
String tagFQN = tag.getTagFQN();
if (isTierClassification(tagFQN)) {
return MetadataOperation.EDIT_TIER;
} else if (isCertificationClassification(tagFQN)) {
return MetadataOperation.EDIT_CERTIFICATION;
} else if ("Classification".equalsIgnoreCase(source)) {
return MetadataOperation.EDIT_TAGS;
} else if ("Glossary".equalsIgnoreCase(source)) {
@ -150,6 +152,10 @@ public class JsonPatchUtils {
return tagFQN != null && tagFQN.startsWith("Tier.");
}
private static boolean isCertificationClassification(String tagFQN) {
return tagFQN != null && tagFQN.startsWith("Certification.");
}
public static MetadataOperation getMetadataOperation(Object jsonPatchObject) {
String path;

View File

@ -61,6 +61,16 @@
"`Tier.Tier2`"
]
},
{
"name": "certification",
"required": false,
"description": "Certification tag from the Certification classification. Only one certification level can be applied.",
"examples": [
"Certification.Gold",
"Certification.Silver",
"Certification.Bronze"
]
},
{
"name": "retentionPeriod",
"required": false,

View File

@ -61,6 +61,16 @@
"`Tier.Tier2`"
]
},
{
"name": "certification",
"required": false,
"description": "Certification tag from the Certification classification. Only one certification level can be applied.",
"examples": [
"Certification.Gold",
"Certification.Silver",
"Certification.Bronze"
]
},
{
"name": "retentionPeriod",
"required": false,

View File

@ -61,6 +61,16 @@
"`Tier.Tier2`"
]
},
{
"name": "certification",
"required": false,
"description": "Certification tag from the Certification classification. Only one certification level can be applied.",
"examples": [
"Certification.Gold",
"Certification.Silver",
"Certification.Bronze"
]
},
{
"name": "domain",
"required": false,

View File

@ -62,6 +62,16 @@
"`Tier.Tier2`"
]
},
{
"name": "certification",
"required": false,
"description": "Certification tag from the Certification classification. Only one certification level can be applied.",
"examples": [
"Certification.Gold",
"Certification.Silver",
"Certification.Bronze"
]
},
{
"name": "retentionPeriod",
"required": false,

View File

@ -11,7 +11,7 @@
"name": "DataConsumerPolicy-EditRule",
"description" : "Allow some of the edit operations on a resource for everyone.",
"resources" : ["all"],
"operations": ["ViewAll", "EditDescription", "EditTags", "EditGlossaryTerms", "EditTier"],
"operations": ["ViewAll", "EditDescription", "EditTags", "EditGlossaryTerms", "EditTier", "EditCertification"],
"effect": "allow"
}
]

View File

@ -10,7 +10,7 @@
{
"name": "DataStewardPolicy-EditRule",
"resources" : ["all"],
"operations": ["ViewAll", "EditDescription", "EditDisplayName","EditLineage","EditOwners", "EditTags", "EditTier", "EditGlossaryTerms"],
"operations": ["ViewAll", "EditDescription", "EditDisplayName", "EditLineage", "EditOwners", "EditTags", "EditTier", "EditGlossaryTerms", "EditCertification"],
"effect": "allow"
}
]

View File

@ -51,6 +51,7 @@ import org.openmetadata.csv.EntityCsv;
import org.openmetadata.schema.api.data.CreateDatabase;
import org.openmetadata.schema.api.data.CreateDatabaseSchema;
import org.openmetadata.schema.api.data.CreateTable;
import org.openmetadata.schema.entity.classification.Tag;
import org.openmetadata.schema.entity.data.Database;
import org.openmetadata.schema.entity.data.DatabaseSchema;
import org.openmetadata.schema.entity.data.Table;
@ -60,6 +61,7 @@ import org.openmetadata.schema.type.csv.CsvImportResult;
import org.openmetadata.service.Entity;
import org.openmetadata.service.resources.EntityResourceTest;
import org.openmetadata.service.resources.databases.DatabaseResource.DatabaseList;
import org.openmetadata.service.resources.tags.TagResourceTest;
import org.openmetadata.service.util.FullyQualifiedName;
import org.openmetadata.service.util.JsonUtils;
import org.openmetadata.service.util.ResultList;
@ -129,7 +131,7 @@ public class DatabaseResourceTest extends EntityResourceTest<Database, CreateDat
// Update databaseSchema with invalid tags field
String resultsHeader =
recordToString(EntityCsv.getResultHeaders(getDatabaseCsvHeaders(database, false)));
String record = "s1,dsp1,dsc1,,Tag.invalidTag,,,,,,";
String record = "s1,dsp1,dsc1,,Tag.invalidTag,,,,,,,";
String csv = createCsv(getDatabaseCsvHeaders(database, false), listOf(record), null);
CsvImportResult result = importCsv(databaseName, csv, false);
assertSummary(result, ApiStatus.PARTIAL_SUCCESS, 2, 1, 1);
@ -140,7 +142,7 @@ public class DatabaseResourceTest extends EntityResourceTest<Database, CreateDat
assertRows(result, expectedRows);
// invalid tag it will give error.
record = "non-existing,dsp1,dsc1,,Tag.invalidTag,,,,,,";
record = "non-existing,dsp1,dsc1,,Tag.invalidTag,,,,,,,";
csv = createCsv(getDatabaseSchemaCsvHeaders(dbSchema, false), listOf(record), null);
result = importCsv(databaseName, csv, false);
assertSummary(result, ApiStatus.PARTIAL_SUCCESS, 2, 1, 1);
@ -152,7 +154,7 @@ public class DatabaseResourceTest extends EntityResourceTest<Database, CreateDat
// databaseSchema will be created if it does not exist
String schemaFqn = FullyQualifiedName.add(database.getFullyQualifiedName(), "non-existing");
record = "non-existing,dsp1,dsc1,,,,,,,,";
record = "non-existing,dsp1,dsc1,,,,,,,,,";
csv = createCsv(getDatabaseSchemaCsvHeaders(dbSchema, false), listOf(record), null);
result = importCsv(databaseName, csv, false);
assertSummary(result, ApiStatus.SUCCESS, 2, 2, 0);
@ -171,13 +173,22 @@ public class DatabaseResourceTest extends EntityResourceTest<Database, CreateDat
schemaTest.createRequest("s1").withDatabase(database.getFullyQualifiedName());
schemaTest.createEntity(createSchema, ADMIN_AUTH_HEADERS);
// Headers: name, displayName, description, owner, tags, glossaryTerms, tiers, retentionPeriod,
// Create certification
TagResourceTest tagResourceTest = new TagResourceTest();
Tag certificationTag =
tagResourceTest.createEntity(
tagResourceTest.createRequest("Certification"), ADMIN_AUTH_HEADERS);
// Headers: name, displayName, description, owner, tags, glossaryTerms, tiers, certification,
// retentionPeriod,
// sourceUrl, domain
// Update terms with change in description
String record =
String.format(
"s1,dsp1,new-dsc1,user:%s,,,Tier.Tier1,P23DT23H,http://test.com,%s,",
user1, escapeCsv(DOMAIN.getFullyQualifiedName()));
"s1,dsp1,new-dsc1,user:%s,,,Tier.Tier1,%s,P23DT23H,http://test.com,%s,",
user1,
certificationTag.getFullyQualifiedName(),
escapeCsv(DOMAIN.getFullyQualifiedName()));
// Update created entity with changes
importCsvAndValidate(
@ -186,7 +197,7 @@ public class DatabaseResourceTest extends EntityResourceTest<Database, CreateDat
null,
listOf(record));
String clearRecord = "s1,dsp1,new-dsc2,,,,,P23DT23H,http://test.com,,";
String clearRecord = "s1,dsp1,new-dsc2,,,,,,P23DT23H,http://test.com,,";
importCsvAndValidate(
database.getFullyQualifiedName(),
getDatabaseCsvHeaders(database, false),

View File

@ -49,6 +49,7 @@ import org.openmetadata.csv.EntityCsv;
import org.openmetadata.schema.api.data.CreateDatabaseSchema;
import org.openmetadata.schema.api.data.CreateTable;
import org.openmetadata.schema.api.data.RestoreEntity;
import org.openmetadata.schema.entity.classification.Tag;
import org.openmetadata.schema.entity.data.DatabaseSchema;
import org.openmetadata.schema.entity.data.Table;
import org.openmetadata.schema.type.ApiStatus;
@ -57,6 +58,7 @@ import org.openmetadata.schema.type.csv.CsvImportResult;
import org.openmetadata.service.Entity;
import org.openmetadata.service.resources.EntityResourceTest;
import org.openmetadata.service.resources.databases.DatabaseSchemaResource.DatabaseSchemaList;
import org.openmetadata.service.resources.tags.TagResourceTest;
import org.openmetadata.service.util.FullyQualifiedName;
import org.openmetadata.service.util.TestUtils;
@ -121,11 +123,12 @@ public class DatabaseSchemaResourceTest
tableTest.createRequest("s1").withDatabaseSchema(schema.getFullyQualifiedName());
tableTest.createEntity(createTable, ADMIN_AUTH_HEADERS);
// Headers: name, displayName, description, owner, tags, retentionPeriod, sourceUrl, domain
// Headers: name, displayName, description, owner, tags, glossaryTerms, tiers, certification,
// retentionPeriod, sourceUrl, domain, extension
// Create table with invalid tags field
String resultsHeader =
recordToString(EntityCsv.getResultHeaders(getDatabaseSchemaCsvHeaders(schema, false)));
String record = "s1,dsp1,dsc1,,Tag.invalidTag,,,,,,";
String record = "s1,dsp1,dsc1,,Tag.invalidTag,,,,,,,";
String csv = createCsv(getDatabaseSchemaCsvHeaders(schema, false), listOf(record), null);
CsvImportResult result = importCsv(schemaName, csv, false);
assertSummary(result, ApiStatus.PARTIAL_SUCCESS, 2, 1, 1);
@ -136,7 +139,7 @@ public class DatabaseSchemaResourceTest
assertRows(result, expectedRows);
// Tag will cause failure
record = "non-existing,dsp1,dsc1,,Tag.invalidTag,,,,,,";
record = "non-existing,dsp1,dsc1,,Tag.invalidTag,,,,,,,";
csv = createCsv(getDatabaseSchemaCsvHeaders(schema, false), listOf(record), null);
result = importCsv(schemaName, csv, false);
assertSummary(result, ApiStatus.PARTIAL_SUCCESS, 2, 1, 1);
@ -147,7 +150,7 @@ public class DatabaseSchemaResourceTest
assertRows(result, expectedRows);
// non-existing table will cause
record = "non-existing,dsp1,dsc1,,,,,,,,";
record = "non-existing,dsp1,dsc1,,,,,,,,,";
String tableFqn = FullyQualifiedName.add(schema.getFullyQualifiedName(), "non-existing");
csv = createCsv(getDatabaseSchemaCsvHeaders(schema, false), listOf(record), null);
result = importCsv(schemaName, csv, false);
@ -167,13 +170,20 @@ public class DatabaseSchemaResourceTest
tableTest.createRequest("s1").withDatabaseSchema(schema.getFullyQualifiedName());
tableTest.createEntity(createTable, ADMIN_AUTH_HEADERS);
// Create certification
TagResourceTest tagResourceTest = new TagResourceTest();
Tag certificationTag =
tagResourceTest.createEntity(
tagResourceTest.createRequest("Certification"), ADMIN_AUTH_HEADERS);
// Headers: name, displayName, description, owner, tags, retentionPeriod, sourceUrl, domain
// Update terms with change in description
List<String> updateRecords =
listOf(
String.format(
"s1,dsp1,new-dsc1,user:%s,,,Tier.Tier1,P23DT23H,http://test.com,%s,",
user1, escapeCsv(DOMAIN.getFullyQualifiedName())));
"s1,dsp1,new-dsc1,user:%s,,,Tier.Tier1,%s,P23DT23H,http://test.com,%s,",
user1,
certificationTag.getFullyQualifiedName(),
escapeCsv(DOMAIN.getFullyQualifiedName())));
// Update created entity with changes
importCsvAndValidate(
@ -182,7 +192,7 @@ public class DatabaseSchemaResourceTest
null,
updateRecords);
List<String> clearRecords = listOf("s1,dsp1,new-dsc2,,,,,P23DT23H,http://test.com,,");
List<String> clearRecords = listOf("s1,dsp1,new-dsc2,,,,,,P23DT23H,http://test.com,,");
importCsvAndValidate(
schema.getFullyQualifiedName(),
@ -244,7 +254,8 @@ public class DatabaseSchemaResourceTest
// Validate updated table
Table updated =
tableTest.getEntityByName(table.getFullyQualifiedName(), "description", ADMIN_AUTH_HEADERS);
tableTest.getEntityByName(
table.getFullyQualifiedName(), "description,certification", ADMIN_AUTH_HEADERS);
assertEquals("Updated Table Description", updated.getDescription());
// Validate updated column

View File

@ -24,8 +24,11 @@ import static org.openmetadata.common.utils.CommonUtil.listOf;
import static org.openmetadata.common.utils.CommonUtil.listOrEmpty;
import static org.openmetadata.csv.CsvUtil.recordToString;
import static org.openmetadata.csv.EntityCsv.entityNotFound;
import static org.openmetadata.csv.EntityCsvTest.*;
import static org.openmetadata.csv.EntityCsvTest.assertRows;
import static org.openmetadata.csv.EntityCsvTest.assertSummary;
import static org.openmetadata.csv.EntityCsvTest.createCsv;
import static org.openmetadata.csv.EntityCsvTest.getFailedRecord;
import static org.openmetadata.csv.EntityCsvTest.getSuccessRecord;
import static org.openmetadata.service.exception.CatalogExceptionMessage.invalidEnumValue;
import static org.openmetadata.service.util.EntityUtil.fieldAdded;
import static org.openmetadata.service.util.EntityUtil.fieldUpdated;
@ -86,6 +89,7 @@ import org.openmetadata.service.resources.databases.TableResourceTest;
import org.openmetadata.service.resources.services.database.DatabaseServiceResource;
import org.openmetadata.service.resources.services.database.DatabaseServiceResource.DatabaseServiceList;
import org.openmetadata.service.resources.services.ingestionpipelines.IngestionPipelineResourceTest;
import org.openmetadata.service.resources.tags.TagResourceTest;
import org.openmetadata.service.secrets.masker.PasswordEntityMasker;
import org.openmetadata.service.util.FullyQualifiedName;
import org.openmetadata.service.util.JsonUtils;
@ -359,7 +363,7 @@ public class DatabaseServiceResourceTest
// Update database with invalid tags field
String resultsHeader =
recordToString(EntityCsv.getResultHeaders(getDatabaseServiceCsvHeaders(service, false)));
String record = "d1,dsp1,dsc1,,Tag.invalidTag,,,,";
String record = "d1,dsp1,dsc1,,Tag.invalidTag,,,,,";
String csv = createCsv(getDatabaseServiceCsvHeaders(service, false), listOf(record), null);
CsvImportResult result = importCsv(serviceName, csv, false);
assertSummary(result, ApiStatus.PARTIAL_SUCCESS, 2, 1, 1);
@ -370,7 +374,7 @@ public class DatabaseServiceResourceTest
assertRows(result, expectedRows);
// invalid tag it will give error.
record = "non-existing,dsp1,dsc1,,Tag.invalidTag,,,,";
record = "non-existing,dsp1,dsc1,,Tag.invalidTag,,,,,";
csv = createCsv(getDatabaseServiceCsvHeaders(service, false), listOf(record), null);
result = importCsv(serviceName, csv, false);
assertSummary(result, ApiStatus.PARTIAL_SUCCESS, 2, 1, 1);
@ -382,7 +386,7 @@ public class DatabaseServiceResourceTest
// database will be created if it does not exist
String databaseFqn = FullyQualifiedName.add(serviceName, "non-existing");
record = "non-existing,dsp1,dsc1,,,,,,";
record = "non-existing,dsp1,dsc1,,,,,,,";
csv = createCsv(getDatabaseServiceCsvHeaders(service, false), listOf(record), null);
result = importCsv(serviceName, csv, false);
assertSummary(result, ApiStatus.SUCCESS, 2, 2, 0);
@ -401,11 +405,15 @@ public class DatabaseServiceResourceTest
databaseTest.createRequest("d1").withService(service.getFullyQualifiedName());
databaseTest.createEntity(createDatabase, ADMIN_AUTH_HEADERS);
// Headers: name, displayName, description, owner, tags, glossaryTerms, tiers, domain, extension
// Create certification
TagResourceTest tagResourceTest = new TagResourceTest();
// Headers: name, displayName, description, owner, tags, glossaryTerms, tiers,
// certification,domain, extension
// Update terms with change in description
String record =
String.format(
"d1,dsp1,new-dsc1,user:%s,,,Tier.Tier1,%s,",
"d1,dsp1,new-dsc1,user:%s,,,Tier.Tier1,,%s,",
user1, escapeCsv(DOMAIN.getFullyQualifiedName()));
// Update created entity with changes
@ -415,7 +423,7 @@ public class DatabaseServiceResourceTest
null,
listOf(record));
String clearRecord = "d1,dsp1,new-dsc2,,,,,,";
String clearRecord = "d1,dsp1,new-dsc2,,,,,,,";
importCsvAndValidate(
service.getFullyQualifiedName(),

View File

@ -1,6 +1,7 @@
package org.openmetadata.service.util;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.when;
import static org.openmetadata.common.utils.CommonUtil.listOf;
@ -13,6 +14,7 @@ import java.io.IOException;
import java.io.StringReader;
import java.util.Set;
import java.util.UUID;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -276,4 +278,93 @@ class JsonPatchUtilsTest {
"MetadataOperations should contain EDIT_TAGS");
assertEquals(1, operations.size(), "There should be exactly one MetadataOperation");
}
@Test
void testAddCertificationTag() throws JsonPatchException, IOException {
// Create a patch to add a new Certification tag
long currentTime = System.currentTimeMillis();
String patchString = getPatchString(currentTime, " \"op\": \"add\",\n");
JsonPatch patch;
try (JsonReader jsonReader = Json.createReader(new StringReader(patchString))) {
JsonArray patchArray = jsonReader.readArray();
patch = Json.createPatch(patchArray);
}
// Determine MetadataOperations
Set<MetadataOperation> operations =
JsonPatchUtils.getMetadataOperations(resourceContextMock, patch);
// Assertions
assertTrue(operations.contains(MetadataOperation.EDIT_CERTIFICATION));
assertEquals(1, operations.size());
}
@Test
void testReplaceCertificationTag() throws JsonPatchException, IOException {
// Create a patch to replace the Certification tag
long currentTime = System.currentTimeMillis();
String patchString = getPatchString(currentTime, " \"op\": \"replace\",\n");
JsonPatch patch;
try (JsonReader jsonReader = Json.createReader(new StringReader(patchString))) {
JsonArray patchArray = jsonReader.readArray();
patch = Json.createPatch(patchArray);
}
// Determine MetadataOperations
Set<MetadataOperation> operations =
JsonPatchUtils.getMetadataOperations(resourceContextMock, patch);
// Assertions
assertTrue(operations.contains(MetadataOperation.EDIT_CERTIFICATION));
assertEquals(1, operations.size());
}
@Test
void testRemoveCertificationTag() throws JsonPatchException, IOException {
// Create a patch to remove the Certification tag
String patchString =
"""
[
{
"op": "remove",
"path": "/certification"
}
]""";
JsonPatch patch;
try (JsonReader jsonReader = Json.createReader(new StringReader(patchString))) {
JsonArray patchArray = jsonReader.readArray();
patch = Json.createPatch(patchArray);
}
// Determine MetadataOperations
Set<MetadataOperation> operations =
JsonPatchUtils.getMetadataOperations(resourceContextMock, patch);
// Assertions
assertTrue(operations.contains(MetadataOperation.EDIT_CERTIFICATION));
assertEquals(1, operations.size());
}
private static @NotNull String getPatchString(long currentTime, String operationString) {
long oneYearInMillis = 365L * 24 * 60 * 60 * 1000;
long expiryTime = currentTime + oneYearInMillis;
return "[\n"
+ " {\n"
+ operationString
+ " \"path\": \"/certification\",\n"
+ " \"value\": {\n"
+ " \"tagLabel\": {\n"
+ " \"tagFQN\": \"Certification.Gold\",\n"
+ " \"labelType\": \"Manual\",\n"
+ " \"state\": \"Confirmed\"\n"
+ " },\n"
+ " \"appliedDate\": "
+ currentTime
+ ",\n"
+ " \"expiryDate\": "
+ expiryTime
+ "\n"
+ " }\n"
+ " }\n"
+ "]";
}
}

View File

@ -42,6 +42,7 @@
"EditGlossaryTerms",
"EditTeams",
"EditTier",
"EditCertification",
"EditTests",
"EditUsage",
"EditUsers",

View File

@ -32,6 +32,11 @@ export const FollowSupportedServices = [
EntityTypeEndpoint.Database,
];
export const CertificationSupportedServices = [
EntityTypeEndpoint.DatabaseSchema,
EntityTypeEndpoint.Database,
];
export const VISIT_SERVICE_PAGE_DETAILS = {
[SERVICE_TYPE.Database]: {
settingsMenuId: GlobalSettingOptions.DATABASES,

View File

@ -209,7 +209,7 @@ test.describe('Bulk Import Export', () => {
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('ArrowDown', { delay: 100 });
await pressKeyXTimes(page, 12, 'ArrowLeft');
await pressKeyXTimes(page, 13, 'ArrowLeft');
await fillRowDetails(
{
@ -238,7 +238,7 @@ test.describe('Bulk Import Export', () => {
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('ArrowDown', { delay: 100 });
await pressKeyXTimes(page, 12, 'ArrowLeft');
await pressKeyXTimes(page, 13, 'ArrowLeft');
// Fill table and columns details
await fillRowDetails(
@ -268,7 +268,7 @@ test.describe('Bulk Import Export', () => {
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('ArrowDown', { delay: 100 });
await pressKeyXTimes(page, 12, 'ArrowLeft');
await pressKeyXTimes(page, 13, 'ArrowLeft');
await fillRecursiveColumnDetails(
{
@ -287,7 +287,7 @@ test.describe('Bulk Import Export', () => {
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('ArrowDown', { delay: 100 });
await pressKeyXTimes(page, 16, 'ArrowLeft');
await pressKeyXTimes(page, 19, 'ArrowLeft');
await fillRowDetails(
{
@ -320,7 +320,7 @@ test.describe('Bulk Import Export', () => {
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('ArrowDown', { delay: 100 });
await pressKeyXTimes(page, 18, 'ArrowLeft');
await pressKeyXTimes(page, 19, 'ArrowLeft');
await fillRowDetails(
{
@ -478,7 +478,7 @@ test.describe('Bulk Import Export', () => {
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('ArrowDown', { delay: 100 });
await pressKeyXTimes(page, 12, 'ArrowLeft');
await pressKeyXTimes(page, 13, 'ArrowLeft');
// Fill table and columns details
await fillRowDetails(
@ -508,7 +508,7 @@ test.describe('Bulk Import Export', () => {
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('ArrowDown', { delay: 100 });
await pressKeyXTimes(page, 12, 'ArrowLeft');
await pressKeyXTimes(page, 13, 'ArrowLeft');
await fillRecursiveColumnDetails(
{
@ -527,7 +527,7 @@ test.describe('Bulk Import Export', () => {
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('ArrowDown', { delay: 100 });
await pressKeyXTimes(page, 16, 'ArrowLeft');
await pressKeyXTimes(page, 17, 'ArrowLeft');
await fillRowDetails(
{
@ -694,7 +694,7 @@ test.describe('Bulk Import Export', () => {
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('ArrowDown', { delay: 100 });
await pressKeyXTimes(page, 12, 'ArrowLeft');
await pressKeyXTimes(page, 13, 'ArrowLeft');
// Fill table columns details
await fillRecursiveColumnDetails(
@ -714,7 +714,7 @@ test.describe('Bulk Import Export', () => {
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('ArrowDown', { delay: 100 });
await pressKeyXTimes(page, 16, 'ArrowLeft');
await pressKeyXTimes(page, 17, 'ArrowLeft');
await fillRowDetails(
{
@ -744,7 +744,7 @@ test.describe('Bulk Import Export', () => {
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('ArrowDown', { delay: 100 });
await pressKeyXTimes(page, 12, 'ArrowLeft');
await pressKeyXTimes(page, 13, 'ArrowLeft');
// fill second table columns details
await fillRecursiveColumnDetails(
@ -859,7 +859,7 @@ test.describe('Bulk Import Export', () => {
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('ArrowDown', { delay: 100 });
await pressKeyXTimes(page, 8, 'ArrowLeft');
await pressKeyXTimes(page, 9, 'ArrowLeft');
await fillColumnDetails(columnDetails2, page);

View File

@ -216,6 +216,15 @@ entities.forEach((EntityClass) => {
);
});
test('Certification Add Remove', async ({ page }) => {
await entity.certification(
page,
EntityDataClass.certificationTag1,
EntityDataClass.certificationTag2,
entity
);
});
if (['Dashboard', 'Dashboard Data Model'].includes(entityName)) {
test(`${entityName} page should show the project name`, async ({
page,

View File

@ -12,7 +12,10 @@
*/
import { Page, test as base } from '@playwright/test';
import { CustomPropertySupportedEntityList } from '../../constant/customProperty';
import { FollowSupportedServices } from '../../constant/service';
import {
CertificationSupportedServices,
FollowSupportedServices,
} from '../../constant/service';
import { ApiCollectionClass } from '../../support/entity/ApiCollectionClass';
import { DatabaseClass } from '../../support/entity/DatabaseClass';
import { DatabaseSchemaClass } from '../../support/entity/DatabaseSchemaClass';
@ -115,6 +118,16 @@ entities.forEach((EntityClass) => {
await entity.tier(page, 'Tier1', 'Tier5');
});
if (CertificationSupportedServices.includes(entity.endpoint)) {
test('Certification Add Remove', async ({ page }) => {
await entity.certification(
page,
EntityDataClass.certificationTag1,
EntityDataClass.certificationTag2
);
});
}
test('Update description', async ({ page }) => {
await entity.descriptionUpdate(page);
});

View File

@ -29,6 +29,7 @@ import {
import {
addMultiOwner,
addOwner,
assignCertification,
assignGlossaryTerm,
assignGlossaryTermToChildren,
assignTag,
@ -41,6 +42,7 @@ import {
downVote,
followEntity,
hardDeleteEntity,
removeCertification,
removeDisplayNameForEntityChildren,
removeGlossaryTerm,
removeGlossaryTermFromChildren,
@ -62,6 +64,7 @@ import {
import { DataProduct } from '../domain/DataProduct';
import { Domain } from '../domain/Domain';
import { GlossaryTerm } from '../glossary/GlossaryTerm';
import { TagClass } from '../tag/TagClass';
import { EntityTypeEndpoint, ENTITY_PATH } from './Entity.interface';
export class EntityClass {
@ -227,6 +230,35 @@ export class EntityClass {
await removeTier(page, this.endpoint);
}
async certification(
page: Page,
certification1: TagClass,
certification2: TagClass,
entity?: EntityClass
) {
await assignCertification(page, certification1, this.endpoint);
if (entity) {
await checkExploreSearchFilter(
page,
'Certification',
'certification.tagLabel.tagFQN',
certification1.responseData.fullyQualifiedName,
entity
);
}
await assignCertification(page, certification2, this.endpoint);
if (entity) {
await checkExploreSearchFilter(
page,
'Certification',
'certification.tagLabel.tagFQN',
certification2.responseData.fullyQualifiedName,
entity
);
}
await removeCertification(page, this.endpoint);
}
async descriptionUpdate(page: Page) {
const description =
// eslint-disable-next-line max-len

View File

@ -56,6 +56,12 @@ export class EntityDataClass {
static readonly team2 = new TeamClass();
static readonly tierTag1 = new TagClass({ classification: 'Tier' });
static readonly tierTag2 = new TagClass({ classification: 'Tier' });
static readonly certificationTag1 = new TagClass({
classification: 'Certification',
});
static readonly certificationTag2 = new TagClass({
classification: 'Certification',
});
static readonly classification1 = new ClassificationClass({
provider: 'system',
mutuallyExclusive: true,
@ -120,6 +126,8 @@ export class EntityDataClass {
this.team2.create(apiContext),
this.tierTag1.create(apiContext),
this.tierTag2.create(apiContext),
this.certificationTag1.create(apiContext),
this.certificationTag2.create(apiContext),
this.classification1.create(apiContext),
]
: [];
@ -234,6 +242,8 @@ export class EntityDataClass {
this.team2.delete(apiContext),
this.tierTag1.delete(apiContext),
this.tierTag2.delete(apiContext),
this.certificationTag1.delete(apiContext),
this.certificationTag2.delete(apiContext),
this.classification1.delete(apiContext),
this.tag1.delete(apiContext),
this.dataProduct1.delete(apiContext),

View File

@ -22,6 +22,7 @@ import { ES_RESERVED_CHARACTERS } from '../constant/entity';
import { SidebarItem } from '../constant/sidebar';
import { EntityTypeEndpoint } from '../support/entity/Entity.interface';
import { EntityClass } from '../support/entity/EntityClass';
import { TagClass } from '../support/tag/TagClass';
import {
clickOutside,
descriptionBox,
@ -345,6 +346,39 @@ export const removeTier = async (page: Page, endpoint: string) => {
await expect(page.getByTestId('Tier')).toContainText('No Tier');
};
export const assignCertification = async (
page: Page,
certification: TagClass,
endpoint: string
) => {
await page.getByTestId('edit-certification').click();
await page.waitForSelector('[data-testid="loader"]', { state: 'detached' });
await page
.getByTestId(`radio-btn-${certification.responseData.fullyQualifiedName}`)
.click();
const patchRequest = page.waitForResponse(`/api/v1/${endpoint}/*`);
await page.getByTestId('update-certification').click();
await patchRequest;
await clickOutside(page);
await expect(page.getByTestId('certification-label')).toContainText(
certification.responseData.displayName
);
};
export const removeCertification = async (page: Page, endpoint: string) => {
await page.getByTestId('edit-certification').click();
await page.waitForSelector('[data-testid="loader"]', { state: 'detached' });
const patchRequest = page.waitForResponse(`/api/v1/${endpoint}/*`);
await page.getByTestId('clear-certification').click();
await patchRequest;
await clickOutside(page);
await expect(page.getByTestId('certification-label')).toContainText(
'No Certification'
);
};
export const updateDescription = async (
page: Page,
description: string,
@ -1567,6 +1601,22 @@ export const generateEntityChildren = (entityName: string, count = 25) => {
});
};
export const checkItemNotExistsInQuickFilter = async (
page: Page,
filterLabel: string,
filterValue: string
) => {
await sidebarClick(page, SidebarItem.EXPLORE);
await page.waitForLoadState('networkidle');
await page.click(`[data-testid="search-dropdown-${filterLabel}"]`);
const testId = filterValue.toLowerCase();
// testId should not be present
await expect(page.getByTestId(testId)).toBeHidden();
await clickOutside(page);
};
export const checkExploreSearchFilter = async (
page: Page,
filterLabel: string,

View File

@ -427,6 +427,7 @@ export const createDatabaseRowDetails = () => {
entityType: 'Database',
retentionPeriod: '1 year',
sourceUrl: 'www.xyz.com',
certification: 'Certification.Gold',
};
};
@ -442,6 +443,7 @@ export const createDatabaseSchemaRowDetails = () => {
retentionPeriod: '1 year',
sourceUrl: 'www.xy,z.com',
entityType: 'Database Schema',
certification: 'Certification.Gold',
};
};
@ -457,6 +459,7 @@ export const createTableRowDetails = () => {
retentionPeriod: '1 year',
sourceUrl: 'www.xy,z.com',
entityType: 'Table',
certification: 'Certification.Gold',
};
};
@ -488,6 +491,7 @@ export const createStoredProcedureRowDetails = () => {
entityType: 'Stored Procedure',
retentionPeriod: '1 year',
sourceUrl: 'www.xyz.com',
certification: 'Certification.Gold',
};
};
@ -558,6 +562,7 @@ export const fillRowDetails = async (
parent: string;
};
tier: string;
certification: string;
retentionPeriod?: string;
sourceUrl?: string;
domains: {
@ -622,6 +627,16 @@ export const fillRowDetails = async (
await page.click(`[data-testid="radio-btn-${row.tier}"]`);
await page
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('ArrowRight', { delay: 100 });
await page
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('Enter', { delay: 100 });
await page.click(`[data-testid="radio-btn-${row.certification}"]`);
await page.getByTestId('update-certification').click();
await page
.locator('.InovuaReactDataGrid__cell--cell-active')
.press('ArrowRight');
@ -887,7 +902,7 @@ export const fillRecursiveColumnDetails = async (
.press('ArrowRight', { delay: 100 });
await fillGlossaryTermDetails(page, row.glossary);
await pressKeyXTimes(page, 6, 'ArrowRight');
await pressKeyXTimes(page, 7, 'ArrowRight');
await fillEntityTypeDetails(page, row.entityType);

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -37,7 +37,10 @@ import {
import { getEntityName } from '../../../utils/EntityUtils';
import { getEntityDetailsPath } from '../../../utils/RouterUtils';
import { getTagsWithoutTier, getTierTags } from '../../../utils/TableUtils';
import { updateTierTag } from '../../../utils/TagsUtils';
import {
updateCertificationTag,
updateTierTag,
} from '../../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils';
import { withActivityFeed } from '../../AppRouter/withActivityFeed';
import { AlignRightIconButton } from '../../common/IconButtons/EditIconButton';
@ -234,6 +237,20 @@ const APIEndpointDetails: React.FC<APIEndpointDetailsProps> = ({
const toggleTabExpanded = () => {
setIsTabExpanded(!isTabExpanded);
};
const onCertificationUpdate = useCallback(
async (newCertification?: Tag) => {
if (apiEndpointDetails) {
const certificationTag = updateCertificationTag(newCertification);
const updatedApiEndpointDetails: APIEndpoint = {
...apiEndpointDetails,
certification: certificationTag,
};
await onApiEndpointUpdate(updatedApiEndpointDetails, 'certification');
}
},
[apiEndpointDetails, onApiEndpointUpdate]
);
const isExpandViewSupported = useMemo(
() => checkIfExpandViewSupported(tabs[0], activeTab, PageType.APIEndpoint),
@ -260,6 +277,7 @@ const APIEndpointDetails: React.FC<APIEndpointDetailsProps> = ({
entityType={EntityType.API_ENDPOINT}
openTaskCount={feedCount.openTaskCount}
permissions={apiEndpointPermissions}
onCertificationUpdate={onCertificationUpdate}
onDisplayNameUpdate={handleUpdateDisplayName}
onFollowClick={followApiEndpoint}
onOwnerUpdate={onOwnerUpdate}

View File

@ -0,0 +1,213 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CheckOutlined, CloseOutlined } from '@ant-design/icons';
import { Button, Card, Popover, Radio, Space, Spin, Typography } from 'antd';
import { AxiosError } from 'axios';
import { t } from 'i18next';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ReactComponent as CertificationIcon } from '../../assets/svg/ic-certification.svg';
import { Tag } from '../../generated/entity/classification/tag';
import { getTags } from '../../rest/tagAPI';
import { getEntityName } from '../../utils/EntityUtils';
import { stringToHTML } from '../../utils/StringsUtils';
import { getTagImageSrc } from '../../utils/TagsUtils';
import { showErrorToast } from '../../utils/ToastUtils';
import Loader from '../common/Loader/Loader';
import { CertificationProps } from './Certification.interface';
import './certification.less';
const Certification = ({
currentCertificate = '',
children,
onCertificationUpdate,
popoverProps,
onClose,
}: CertificationProps) => {
const popoverRef = useRef<any>(null);
const [isLoadingCertificationData, setIsLoadingCertificationData] =
useState<boolean>(false);
const [certifications, setCertifications] = useState<Array<Tag>>([]);
const [selectedCertification, setSelectedCertification] = useState<string>(
currentCertificate ?? ''
);
const certificationCardData = useMemo(() => {
return (
<Radio.Group
className="h-max-100 overflow-y-auto overflow-x-hidden"
value={selectedCertification}>
{certifications.map((certificate) => {
const tagSrc = getTagImageSrc(certificate.style?.iconURL ?? '');
const title = getEntityName(certificate);
const { id, fullyQualifiedName, description } = certificate;
return (
<div
className="certification-card-item cursor-pointer"
key={id}
style={{ cursor: 'pointer' }}
onClick={() => {
setSelectedCertification(fullyQualifiedName ?? '');
}}>
<Radio
className="certification-radio-top-right"
data-testid={`radio-btn-${fullyQualifiedName}`}
value={fullyQualifiedName}
/>
<div className="certification-card-content">
{tagSrc ? (
<img alt={title} src={tagSrc} />
) : (
<div className="certification-icon">
<CertificationIcon height={28} width={28} />
</div>
)}
<div>
<Typography.Paragraph className="m-b-0 font-regular text-xs text-grey-body">
{title}
</Typography.Paragraph>
<Typography.Paragraph className="m-b-0 font-regular text-xs text-grey-muted">
{stringToHTML(description)}
</Typography.Paragraph>
</div>
</div>
</div>
);
})}
</Radio.Group>
);
}, [certifications, selectedCertification]);
const updateCertificationData = async (value?: string) => {
setIsLoadingCertificationData(true);
const certification = certifications.find(
(cert) => cert.fullyQualifiedName === value
);
await onCertificationUpdate?.(certification);
setIsLoadingCertificationData(false);
popoverRef.current?.close();
};
const getCertificationData = async () => {
setIsLoadingCertificationData(true);
try {
const { data } = await getTags({
parent: 'Certification',
limit: 50,
});
// Sort certifications with Gold, Silver, Bronze first
const sortedData = [...data].sort((a, b) => {
const order: Record<string, number> = {
Gold: 0,
Silver: 1,
Bronze: 2,
};
const aName = getEntityName(a);
const bName = getEntityName(b);
const aOrder = order[aName] ?? 3;
const bOrder = order[bName] ?? 3;
return aOrder - bOrder;
});
setCertifications(sortedData);
} catch (err) {
showErrorToast(
err as AxiosError,
t('server.entity-fetch-error', {
entity: t('label.certification-plural-lowercase'),
})
);
} finally {
setIsLoadingCertificationData(false);
}
};
const handleCloseCertification = async () => {
popoverRef.current?.close();
onClose?.();
};
const onOpenChange = (visible: boolean) => {
if (visible) {
getCertificationData();
setSelectedCertification(currentCertificate);
} else {
setSelectedCertification('');
}
};
useEffect(() => {
if (popoverProps?.open && certifications.length === 0) {
getCertificationData();
}
}, [popoverProps?.open]);
return (
<Popover
className="p-0"
content={
<Card
bordered={false}
className="certification-card"
data-testid="certification-cards"
title={
<Space className="w-full justify-between">
<div className="flex gap-2 items-center w-full">
<CertificationIcon height={18} width={18} />
<Typography.Text className="m-b-0 font-semibold text-sm">
{t('label.edit-entity', { entity: t('label.certification') })}
</Typography.Text>
</div>
<Typography.Text
className="m-b-0 font-semibold text-primary text-sm cursor-pointer"
data-testid="clear-certification"
onClick={() => updateCertificationData()}>
{t('label.clear')}
</Typography.Text>
</Space>
}>
<Spin
indicator={<Loader size="small" />}
spinning={isLoadingCertificationData}>
{certificationCardData}
<div className="flex justify-end text-lg gap-2 mt-4">
<Button
data-testid="close-certification"
type="default"
onClick={handleCloseCertification}>
<CloseOutlined />
</Button>
<Button
data-testid="update-certification"
type="primary"
onClick={() => updateCertificationData(selectedCertification)}>
<CheckOutlined />
</Button>
</div>
</Spin>
</Card>
}
overlayClassName="certification-card-popover"
placement="bottomRight"
ref={popoverRef}
showArrow={false}
trigger="click"
onOpenChange={onOpenChange}
{...popoverProps}>
{children}
</Popover>
);
};
export default Certification;

View File

@ -0,0 +1,24 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { PopoverProps } from 'antd';
import { ReactNode } from 'react';
import { Tag } from '../../generated/entity/classification/tag';
export interface CertificationProps {
permission: boolean;
onCertificationUpdate?: (certification?: Tag) => Promise<void>;
onClose?: () => void;
currentCertificate?: string;
popoverProps?: PopoverProps;
children?: ReactNode;
}

View File

@ -0,0 +1,93 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import (reference) '../../styles/variables.less';
.certification-value {
min-width: 80%;
.certification-tag-with-name {
width: fit-content;
color: @grey-700;
}
}
.certification-card-popover {
width: 350px;
.ant-popover-inner-content {
border-radius: 12px;
padding: 20px;
}
.certification-card {
.ant-card-head {
padding: 0px;
width: 100%;
border: none;
}
.ant-card-body {
padding: 0px;
.ant-radio-group {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
.ant-collapse-item {
width: 100%;
border: 1px solid @grey-200;
border-radius: 12px;
padding: 16px;
}
}
.certification-radio-top-right {
position: absolute;
top: 8px;
right: 8px;
}
.certification-card-item {
position: relative;
padding: 16px 16px 16px 12px;
border: 1px solid @grey-200;
border-radius: 12px;
display: flex;
align-items: center;
}
.certification-card-content {
display: flex;
align-items: flex-start;
gap: 12px;
img {
width: 40px;
height: 40px;
object-fit: contain;
flex-shrink: 0;
}
}
}
.ant-card-head-title {
padding: 0px;
}
}
.certification-icon {
padding: 6px;
}
}

View File

@ -38,7 +38,10 @@ import {
import dashboardDetailsClassBase from '../../../utils/DashboardDetailsClassBase';
import { DEFAULT_ENTITY_PERMISSION } from '../../../utils/PermissionsUtils';
import { getEntityDetailsPath } from '../../../utils/RouterUtils';
import { updateTierTag } from '../../../utils/TagsUtils';
import {
updateCertificationTag,
updateTierTag,
} from '../../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils';
import { withActivityFeed } from '../../AppRouter/withActivityFeed';
import { AlignRightIconButton } from '../../common/IconButtons/EditIconButton';
@ -260,6 +263,21 @@ const DashboardDetails = ({
viewAllPermission,
onExtensionUpdate,
]);
const onCertificationUpdate = useCallback(
async (newCertification?: Tag) => {
if (dashboardDetails) {
const certificationTag: Dashboard['certification'] =
updateCertificationTag(newCertification);
const updatedDashboardDetails = {
...dashboardDetails,
certification: certificationTag,
};
await onDashboardUpdate(updatedDashboardDetails, 'certification');
}
},
[dashboardDetails, onDashboardUpdate]
);
const toggleTabExpanded = () => {
setIsTabExpanded(!isTabExpanded);
@ -291,6 +309,7 @@ const DashboardDetails = ({
entityType={EntityType.DASHBOARD}
openTaskCount={feedCount.openTaskCount}
permissions={dashboardPermissions}
onCertificationUpdate={onCertificationUpdate}
onDisplayNameUpdate={onUpdateDisplayName}
onFollowClick={followDashboard}
onOwnerUpdate={onOwnerUpdate}

View File

@ -34,7 +34,10 @@ export interface DashboardDetailsProps {
followDashboardHandler: () => Promise<void>;
unFollowDashboardHandler: () => Promise<void>;
versionHandler: () => void;
onDashboardUpdate: (updatedDashboard: Dashboard) => Promise<void>;
onDashboardUpdate: (
updatedDashboard: Dashboard,
key?: keyof Dashboard
) => Promise<void>;
handleToggleDelete: (version?: number) => void;
onUpdateVote?: (data: QueryVote, id: string) => Promise<void>;
}

View File

@ -19,6 +19,7 @@ import { useTranslation } from 'react-i18next';
import { useHistory, useParams } from 'react-router-dom';
import { FEED_COUNT_INITIAL_DATA } from '../../../../constants/entity.constants';
import { EntityTabs, EntityType } from '../../../../enums/entity.enum';
import { Tag } from '../../../../generated/entity/classification/tag';
import { DashboardDataModel } from '../../../../generated/entity/data/dashboardDataModel';
import { PageType } from '../../../../generated/system/ui/page';
import { useCustomPages } from '../../../../hooks/useCustomPages';
@ -36,6 +37,7 @@ import {
getEntityDetailsPath,
getVersionPath,
} from '../../../../utils/RouterUtils';
import { updateCertificationTag } from '../../../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../../../utils/ToastUtils';
import { withActivityFeed } from '../../../AppRouter/withActivityFeed';
import { AlignRightIconButton } from '../../../common/IconButtons/EditIconButton';
@ -206,6 +208,21 @@ const DataModelDetails = ({
),
[tabs[0], activeTab]
);
const onCertificationUpdate = useCallback(
async (newCertification?: Tag) => {
if (dataModelData) {
const certificationTag: DashboardDataModel['certification'] =
updateCertificationTag(newCertification);
const updatedTableDetails = {
...dataModelData,
certification: certificationTag,
};
await onUpdateDataModel(updatedTableDetails as DashboardDataModel);
}
},
[onUpdateDataModel, dataModelData]
);
if (isLoading) {
return <Loader />;
@ -228,6 +245,7 @@ const DataModelDetails = ({
entityType={EntityType.DASHBOARD_DATA_MODEL}
openTaskCount={feedCount.openTaskCount}
permissions={dataModelPermissions}
onCertificationUpdate={onCertificationUpdate}
onDisplayNameUpdate={handleUpdateDisplayName}
onFollowClick={handleFollowDataModel}
onOwnerUpdate={handleUpdateOwner}

View File

@ -15,7 +15,7 @@ import { Button, Col, Divider, Row, Space, Tooltip, Typography } from 'antd';
import ButtonGroup from 'antd/lib/button/button-group';
import { AxiosError } from 'axios';
import classNames from 'classnames';
import { get, isEmpty } from 'lodash';
import { get, isEmpty, isUndefined } from 'lodash';
import QueryString from 'qs';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -51,7 +51,6 @@ import { getActiveAnnouncement } from '../../../rest/feedsAPI';
import { getDataQualityLineage } from '../../../rest/lineageAPI';
import { getContainerByName } from '../../../rest/storageAPI';
import {
ExtraInfoLabel,
getDataAssetsHeaderInfo,
getEntityExtraInfoLength,
isDataAssetsWithServiceField,
@ -69,6 +68,7 @@ import { getEntityTypeFromServiceCategory } from '../../../utils/ServiceUtils';
import tableClassBase from '../../../utils/TableClassBase';
import { getTierTags } from '../../../utils/TableUtils';
import { showErrorToast } from '../../../utils/ToastUtils';
import Certification from '../../Certification/Certification.component';
import CertificationTag from '../../common/CertificationTag/CertificationTag';
import AnnouncementCard from '../../common/EntityPageInfos/AnnouncementCard/AnnouncementCard';
import AnnouncementDrawer from '../../common/EntityPageInfos/AnnouncementDrawer/AnnouncementDrawer';
@ -117,6 +117,7 @@ export const DataAssetsHeader = ({
disableRunAgentsButton = true,
afterTriggerAction,
isAutoPilotWorkflowStatusLoading = false,
onCertificationUpdate,
}: DataAssetsHeaderProps) => {
const { serviceCategory } = useParams<{ serviceCategory: ServiceCategory }>();
const { currentUser } = useApplicationStore();
@ -130,6 +131,7 @@ export const DataAssetsHeader = ({
const [isFollowingLoading, setIsFollowingLoading] = useState(false);
const history = useHistory();
const [isAutoPilotTriggering, setIsAutoPilotTriggering] = useState(false);
const icon = useMemo(() => {
const serviceType = get(dataAsset, 'serviceType', '');
@ -359,17 +361,24 @@ export const DataAssetsHeader = ({
setIsFollowingLoading(false);
}, [onFollowClick]);
const { editDomainPermission, editOwnerPermission, editTierPermission } =
useMemo(
() => ({
editDomainPermission: permissions.EditAll && !dataAsset.deleted,
editOwnerPermission:
(permissions.EditAll || permissions.EditOwners) && !dataAsset.deleted,
editTierPermission:
(permissions.EditAll || permissions.EditTier) && !dataAsset.deleted,
}),
[permissions, dataAsset]
);
const {
editDomainPermission,
editOwnerPermission,
editTierPermission,
editCertificationPermission,
} = useMemo(
() => ({
editDomainPermission: permissions.EditAll && !dataAsset.deleted,
editOwnerPermission:
(permissions.EditAll || permissions.EditOwners) && !dataAsset.deleted,
editTierPermission:
(permissions.EditAll || permissions.EditTier) && !dataAsset.deleted,
editCertificationPermission:
(permissions.EditAll || permissions.EditCertification) &&
!dataAsset.deleted,
}),
[permissions, dataAsset]
);
const tierSuggestionRender = useMemo(() => {
if (entityType === EntityType.TABLE) {
@ -701,21 +710,58 @@ export const DataAssetsHeader = ({
/>
)}
<Divider className="self-center vertical-divider" type="vertical" />
<ExtraInfoLabel
dataTestId="certification-label"
label={t('label.certification')}
value={
(dataAsset as Table).certification ? (
<CertificationTag
showName
certification={(dataAsset as Table).certification!}
/>
) : (
t('label.no-entity', { entity: t('label.certification') })
)
}
/>
{isUndefined(serviceCategory) && (
<>
<Divider
className="self-center vertical-divider"
type="vertical"
/>
<Certification
currentCertificate={
'certification' in dataAsset
? dataAsset.certification?.tagLabel?.tagFQN
: undefined
}
permission={false}
onCertificationUpdate={onCertificationUpdate}>
<div className="d-flex align-start extra-info-container">
<Typography.Text
className="whitespace-nowrap text-sm d-flex flex-col gap-2"
data-testid="certification-label">
<div className="flex gap-2">
<span className="extra-info-label-heading">
{t('label.certification')}
</span>
{editCertificationPermission && (
<EditIconButton
newLook
data-testid="edit-certification"
size="small"
title={t('label.edit-entity', {
entity: t('label.certification'),
})}
/>
)}
</div>
<div className="font-medium certification-value">
{(dataAsset as Table).certification ? (
<CertificationTag
showName
certification={(dataAsset as Table).certification!}
/>
) : (
t('label.no-entity', {
entity: t('label.certification'),
})
)}
</div>
</Typography.Text>
</div>
</Certification>
</>
)}
{extraInfo}
</div>
</Col>

View File

@ -139,6 +139,7 @@ export type DataAssetsHeaderProps = {
disableRunAgentsButton?: boolean;
afterTriggerAction?: VoidFunction;
isAutoPilotWorkflowStatusLoading?: boolean;
onCertificationUpdate?: (certificate?: Tag) => Promise<void>;
} & (
| DataAssetTable
| DataAssetTopic

View File

@ -12,6 +12,7 @@
*/
import { act, fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { useParams } from 'react-router-dom';
import { AUTO_PILOT_APP_NAME } from '../../../constants/Applications.constant';
import { EntityType } from '../../../enums/entity.enum';
import { ServiceCategory } from '../../../enums/service.enum';
@ -318,7 +319,7 @@ describe('DataAssetsHeader component', () => {
expect(screen.queryByTestId('source-url-button')).not.toBeInTheDocument();
});
it('should always render certification', () => {
it('should render certification only when serviceCategory is undefined', () => {
const mockCertification: AssetCertification = {
tagLabel: {
tagFQN: 'Certification.Bronze',
@ -337,7 +338,13 @@ describe('DataAssetsHeader component', () => {
expiryDate: 1735406645688,
};
// First test with certification
// Mock useParams to return undefined serviceCategory
const useParamsMock = useParams as jest.Mock;
useParamsMock.mockReturnValue({
serviceCategory: undefined,
});
// Test with certification when serviceCategory is undefined
const { unmount } = render(
<DataAssetsHeader
{...mockProps}
@ -357,12 +364,53 @@ describe('DataAssetsHeader component', () => {
// Clean up the first render before rendering again
unmount();
// Second test without certification
// Test without certification when serviceCategory is undefined
render(<DataAssetsHeader {...mockProps} />);
expect(screen.getByTestId('certification-label')).toContainHTML(
'label.no-entity'
);
// Reset the mock to original value
useParamsMock.mockReturnValue({
serviceCategory: ServiceCategory.DATABASE_SERVICES,
});
});
it('should not render certification when serviceCategory has a value', () => {
const mockCertification: AssetCertification = {
tagLabel: {
tagFQN: 'Certification.Bronze',
name: 'Bronze',
displayName: 'Bronze_Medal',
description: 'Bronze certified Data Asset test',
style: {
color: '#C08329',
iconURL: 'BronzeCertification.svg',
},
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
},
appliedDate: 1732814645688,
expiryDate: 1735406645688,
};
// serviceCategory is already set to DATABASE_SERVICES by default mock
render(
<DataAssetsHeader
{...mockProps}
dataAsset={{
...mockProps.dataAsset,
certification: mockCertification,
}}
/>
);
// Certification should not be rendered when serviceCategory has a value
expect(screen.queryByText('label.certification')).not.toBeInTheDocument();
expect(screen.queryByText('CertificationTag')).not.toBeInTheDocument();
expect(screen.queryByTestId('certification-label')).not.toBeInTheDocument();
});
it('should trigger the AutoPilot application when the button is clicked', () => {

View File

@ -13,7 +13,7 @@
import Icon from '@ant-design/icons';
import { Button, Checkbox, Col, Row, Space, Typography } from 'antd';
import classNames from 'classnames';
import { isObject, isString, startCase, uniqueId } from 'lodash';
import { isEmpty, isObject, isString, startCase, uniqueId } from 'lodash';
import { ExtraInfo } from 'Models';
import React, { forwardRef, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@ -286,7 +286,9 @@ const ExploreSearchCard: React.FC<ExploreSearchCardProps> = forwardRef<
</Typography.Text>
</Link>
{(source as Table)?.certification && (
{!isEmpty(
(source as Table)?.certification?.tagLabel?.tagFQN
) && (
<div className="p-l-sm">
<CertificationTag
certification={

View File

@ -37,7 +37,10 @@ import {
} from '../../../utils/CustomizePage/CustomizePageUtils';
import metricDetailsClassBase from '../../../utils/MetricEntityUtils/MetricDetailsClassBase';
import { getEntityDetailsPath } from '../../../utils/RouterUtils';
import { updateTierTag } from '../../../utils/TagsUtils';
import {
updateCertificationTag,
updateTierTag,
} from '../../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils';
import { withActivityFeed } from '../../AppRouter/withActivityFeed';
import { AlignRightIconButton } from '../../common/IconButtons/EditIconButton';
@ -98,6 +101,19 @@ const MetricDetails: React.FC<MetricDetailsProps> = ({
await onMetricUpdate(updatedData, 'displayName');
};
const onCertificationUpdate = useCallback(
async (newCertification?: Tag) => {
const certificationTag = updateCertificationTag(newCertification);
const updatedData = {
...metricDetails,
certification: certificationTag,
};
await onMetricUpdate(updatedData, 'certification');
},
[metricDetails, onMetricUpdate]
);
const handleRestoreMetric = async () => {
try {
const { version: newVersion } = await restoreMetric(metricDetails.id);
@ -248,6 +264,7 @@ const MetricDetails: React.FC<MetricDetailsProps> = ({
entityType={EntityType.METRIC}
openTaskCount={feedCount.openTaskCount}
permissions={metricPermissions}
onCertificationUpdate={onCertificationUpdate}
onDisplayNameUpdate={handleUpdateDisplayName}
onFollowClick={followMetric}
onMetricUpdate={onMetricUpdate}

View File

@ -175,6 +175,7 @@ const mockProp = {
versionHandler: jest.fn(),
handleToggleDelete: jest.fn(),
onUpdateVote: jest.fn(),
onMlModelUpdateCertification: jest.fn(),
};
const mockParams = {

View File

@ -43,7 +43,10 @@ import mlModelDetailsClassBase from '../../../utils/MlModel/MlModelClassBase';
import { DEFAULT_ENTITY_PERMISSION } from '../../../utils/PermissionsUtils';
import { getEntityDetailsPath } from '../../../utils/RouterUtils';
import { getTagsWithoutTier, getTierTags } from '../../../utils/TableUtils';
import { updateTierTag } from '../../../utils/TagsUtils';
import {
updateCertificationTag,
updateTierTag,
} from '../../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils';
import { withActivityFeed } from '../../AppRouter/withActivityFeed';
import { AlignRightIconButton } from '../../common/IconButtons/EditIconButton';
@ -65,6 +68,7 @@ const MlModelDetail: FC<MlModelDetailProp> = ({
versionHandler,
handleToggleDelete,
onMlModelUpdate,
onMlModelUpdateCertification,
}) => {
const { t } = useTranslation();
const { currentUser } = useApplicationStore();
@ -358,6 +362,24 @@ const MlModelDetail: FC<MlModelDetailProp> = ({
fetchMlModel,
customizedPage?.tabs,
]);
const onCertificationUpdate = useCallback(
async (newCertification?: Tag) => {
if (mlModelDetail) {
const certificationTag: Mlmodel['certification'] =
updateCertificationTag(newCertification);
const updatedMlModelDetails = {
...mlModelDetail,
certification: certificationTag,
};
await onMlModelUpdateCertification(
updatedMlModelDetails,
'certification'
);
}
},
[mlModelDetail, onMlModelUpdateCertification]
);
const toggleTabExpanded = () => {
setIsTabExpanded(!isTabExpanded);
@ -387,6 +409,7 @@ const MlModelDetail: FC<MlModelDetailProp> = ({
entityType={EntityType.MLMODEL}
openTaskCount={feedCount.openTaskCount}
permissions={mlModelPermissions}
onCertificationUpdate={onCertificationUpdate}
onDisplayNameUpdate={handleUpdateDisplayName}
onFollowClick={followMlModel}
onOwnerUpdate={onOwnerUpdate}

View File

@ -27,4 +27,8 @@ export interface MlModelDetailProp extends HTMLAttributes<HTMLDivElement> {
handleToggleDelete: (version?: number) => void;
onUpdateVote: (data: QueryVote, id: string) => Promise<void>;
onMlModelUpdate: (data: Mlmodel) => Promise<void>;
onMlModelUpdateCertification: (
data: Mlmodel,
key: keyof Mlmodel
) => Promise<void>;
}

View File

@ -40,7 +40,11 @@ import { DEFAULT_ENTITY_PERMISSION } from '../../../utils/PermissionsUtils';
import pipelineClassBase from '../../../utils/PipelineClassBase';
import { getEntityDetailsPath } from '../../../utils/RouterUtils';
import { getTagsWithoutTier, getTierTags } from '../../../utils/TableUtils';
import { createTagObject, updateTierTag } from '../../../utils/TagsUtils';
import {
createTagObject,
updateCertificationTag,
updateTierTag,
} from '../../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils';
import { withActivityFeed } from '../../AppRouter/withActivityFeed';
import { AlignRightIconButton } from '../../common/IconButtons/EditIconButton';
@ -65,6 +69,7 @@ const PipelineDetails = ({
onUpdateVote,
onExtensionUpdate,
handleToggleDelete,
onPipelineUpdate,
}: PipeLineDetailsProp) => {
const history = useHistory();
const { tab } = useParams<{ tab: EntityTabs }>();
@ -309,6 +314,21 @@ const PipelineDetails = ({
setIsTabExpanded(!isTabExpanded);
};
const onCertificationUpdate = useCallback(
async (newCertification?: Tag) => {
if (pipelineDetails && updatePipelineDetailsState) {
const certificationTag: Pipeline['certification'] =
updateCertificationTag(newCertification);
const updatedPipelineDetails = {
...pipelineDetails,
certification: certificationTag,
};
await onPipelineUpdate(updatedPipelineDetails, 'certification');
}
},
[pipelineDetails, onPipelineUpdate]
);
const isExpandViewSupported = useMemo(
() => checkIfExpandViewSupported(tabs[0], tab, PageType.Pipeline),
[tabs[0], tab]
@ -334,6 +354,7 @@ const PipelineDetails = ({
entityType={EntityType.PIPELINE}
openTaskCount={feedCount.openTaskCount}
permissions={pipelinePermissions}
onCertificationUpdate={onCertificationUpdate}
onDisplayNameUpdate={handleUpdateDisplayName}
onFollowClick={followPipeline}
onOwnerUpdate={onOwnerUpdate}

View File

@ -32,4 +32,8 @@ export interface PipeLineDetailsProp {
onExtensionUpdate: (updatedPipeline: Pipeline) => Promise<void>;
handleToggleDelete: (version?: number) => void;
onUpdateVote: (data: QueryVote, id: string) => Promise<void>;
onPipelineUpdate: (
updatedPipeline: Pipeline,
key?: keyof Pipeline
) => Promise<void>;
}

View File

@ -68,6 +68,7 @@ const PipelineDetailsProps: PipeLineDetailsProp = {
onExtensionUpdate: jest.fn(),
handleToggleDelete: jest.fn(),
onUpdateVote: jest.fn(),
onPipelineUpdate: jest.fn(),
};
jest.mock(

View File

@ -44,7 +44,11 @@ import {
} from '../../../utils/EntityUtils';
import { getEntityDetailsPath } from '../../../utils/RouterUtils';
import { getTagsWithoutTier, getTierTags } from '../../../utils/TableUtils';
import { createTagObject, updateTierTag } from '../../../utils/TagsUtils';
import {
createTagObject,
updateCertificationTag,
updateTierTag,
} from '../../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../../utils/ToastUtils';
import topicClassBase from '../../../utils/TopicClassBase';
import { ActivityFeedTab } from '../../ActivityFeed/ActivityFeedTab/ActivityFeedTab.component';
@ -372,6 +376,21 @@ const TopicDetails: React.FC<TopicDetailsProps> = ({
viewSampleDataPermission,
viewAllPermission,
]);
const onCertificationUpdate = useCallback(
async (newCertification?: Tag) => {
if (topicDetails) {
const certificationTag: Topic['certification'] =
updateCertificationTag(newCertification);
const updatedTopicDetails = {
...topicDetails,
certification: certificationTag,
};
await onTopicUpdate(updatedTopicDetails, 'certification');
}
},
[topicDetails, onTopicUpdate]
);
const toggleTabExpanded = () => {
setIsTabExpanded(!isTabExpanded);
@ -401,6 +420,7 @@ const TopicDetails: React.FC<TopicDetailsProps> = ({
entityType={EntityType.TOPIC}
openTaskCount={feedCount.openTaskCount}
permissions={topicPermissions}
onCertificationUpdate={onCertificationUpdate}
onDisplayNameUpdate={handleUpdateDisplayName}
onFollowClick={followTopic}
onOwnerUpdate={onOwnerUpdate}

View File

@ -10,10 +10,11 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Tag, Tooltip } from 'antd';
import { Tooltip, Typography } from 'antd';
import classNames from 'classnames';
import React from 'react';
import React, { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { ReactComponent as CertificationIcon } from '../../../assets/svg/ic-certification.svg';
import { AssetCertification } from '../../../generated/entity/data/table';
import { getEntityName } from '../../../utils/EntityUtils';
import { getClassificationTagPath } from '../../../utils/RouterUtils';
@ -27,58 +28,65 @@ const CertificationTag = ({
certification: AssetCertification;
showName?: boolean;
}) => {
if (certification.tagLabel.style?.iconURL) {
const imageItem = useMemo(() => {
if (certification.tagLabel.style?.iconURL) {
const name = getEntityName(certification.tagLabel);
const tagSrc = getTagImageSrc(certification.tagLabel.style.iconURL);
return (
<img
alt={`certification: ${name}`}
className="certification-img"
src={tagSrc}
/>
);
}
const iconSize = showName ? 14 : 20;
return <CertificationIcon height={iconSize} width={iconSize} />;
}, [certification.tagLabel.style?.iconURL, showName]);
const certificationRender = useMemo(() => {
const name = getEntityName(certification.tagLabel);
const actualName = certification.tagLabel.name ?? '';
const tagSrc = getTagImageSrc(certification.tagLabel.style.iconURL);
const tagLink = getClassificationTagPath(certification.tagLabel.tagFQN);
const tagStyle = showName
? {
backgroundColor: certification.tagLabel.style?.color
? certification.tagLabel.style?.color + '33'
: '#f8f8f8',
}
: {};
return (
<Tooltip
title={getTagTooltip(name, certification.tagLabel.description)}
trigger="hover">
<Link
className={classNames({
'certification-tag-with-name d-flex items-center gap-1': showName,
className={classNames('d-flex items-center', {
'certification-tag-with-name gap-1': showName,
})}
data-testid={`certification-${certification.tagLabel.tagFQN}`}
style={
showName
? { backgroundColor: certification.tagLabel.style?.color + '33' } // to decrease opacity of the background color by 80%
: {}
}
style={tagStyle}
to={tagLink}>
<img
alt={`certification: ${name}`}
className="certification-img"
src={tagSrc}
/>
{imageItem}
{showName && (
<span
className={classNames('text-sm font-medium', {
<Typography.Text
className={classNames('text-sm font-medium certification-text', {
[`${actualName.toLowerCase()}`]: Boolean(actualName),
})}>
})}
ellipsis={{ tooltip: true }}>
{name}
</span>
</Typography.Text>
)}
</Link>
</Tooltip>
);
}
}, [certification, imageItem]);
return (
<Tag
className="certification-tag"
data-testid={`certification-${certification.tagLabel.tagFQN}`}
style={{
borderColor: certification.tagLabel.style?.color,
backgroundColor: certification.tagLabel.style?.color
? `${certification.tagLabel.style.color}33`
: undefined, // Assuming 33 is the hex transparency for lighter shade
}}>
{getEntityName(certification.tagLabel)}
</Tag>
);
return certificationRender;
};
export default CertificationTag;

View File

@ -11,6 +11,7 @@
* limitations under the License.
*/
@import (reference) '../../../styles/variables.less';
.certification-tag {
display: flex;
align-items: center;
@ -32,18 +33,27 @@
.certification-tag-with-name {
border-radius: 16px;
padding: 2px 8px 2px 10px;
padding: 4px 10px;
.certification-img {
width: 16px;
height: 16px;
}
span.certification-text {
color: @grey-700;
font-size: 12px;
max-width: 120px;
}
span.bronze {
color: @red-16;
}
span.silver {
color: @grey-700;
}
span.gold {
color: @yellow-11;
}

View File

@ -600,6 +600,7 @@ export enum DataModelType {
LookMlExplore = "LookMlExplore",
LookMlView = "LookMlView",
MetabaseDataModel = "MetabaseDataModel",
MicroStrategyDataset = "MicroStrategyDataset",
PowerBIDataFlow = "PowerBIDataFlow",
PowerBIDataModel = "PowerBIDataModel",
QlikDataModel = "QlikDataModel",

View File

@ -156,6 +156,7 @@ export enum Operation {
DeleteTestCaseFailedRowsSample = "DeleteTestCaseFailedRowsSample",
Deploy = "Deploy",
EditAll = "EditAll",
EditCertification = "EditCertification",
EditCustomFields = "EditCustomFields",
EditDataProfile = "EditDataProfile",
EditDescription = "EditDescription",

View File

@ -38,6 +38,7 @@ export enum Operation {
DeleteTestCaseFailedRowsSample = "DeleteTestCaseFailedRowsSample",
Deploy = "Deploy",
EditAll = "EditAll",
EditCertification = "EditCertification",
EditCustomFields = "EditCustomFields",
EditDataProfile = "EditDataProfile",
EditDescription = "EditDescription",

View File

@ -80,6 +80,7 @@ export enum Operation {
DeleteTestCaseFailedRowsSample = "DeleteTestCaseFailedRowsSample",
Deploy = "Deploy",
EditAll = "EditAll",
EditCertification = "EditCertification",
EditCustomFields = "EditCustomFields",
EditDataProfile = "EditDataProfile",
EditDescription = "EditDescription",

View File

@ -63,6 +63,7 @@ export enum Operation {
DeleteTestCaseFailedRowsSample = "DeleteTestCaseFailedRowsSample",
Deploy = "Deploy",
EditAll = "EditAll",
EditCertification = "EditCertification",
EditCustomFields = "EditCustomFields",
EditDataProfile = "EditDataProfile",
EditDescription = "EditDescription",

View File

@ -306,6 +306,7 @@ export enum Operation {
DeleteTestCaseFailedRowsSample = "DeleteTestCaseFailedRowsSample",
Deploy = "Deploy",
EditAll = "EditAll",
EditCertification = "EditCertification",
EditCustomFields = "EditCustomFields",
EditDataProfile = "EditDataProfile",
EditDescription = "EditDescription",

View File

@ -1,15 +1,3 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* This schema defines the `Database Service` is a service such as MySQL, BigQuery,
* Redshift, Postgres, or Snowflake. Alternative terms such as Database Cluster, Database

View File

@ -1,15 +1,3 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Ingestion Pipeline Config is used to set up a DAG and deploy. This entity is used to
* setup metadata/quality pipelines on Apache Airflow.

View File

@ -1,15 +1,3 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* This schema defines the Pipeline Service entity, such as Airflow and Prefect.
*/

View File

@ -1,15 +1,3 @@
/*
* Copyright 2025 Collate.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Defines a background job that is triggered on insertion of new record in background_jobs
* table.

View File

@ -73,7 +73,7 @@ import entityUtilClassBase from '../../utils/EntityUtilClassBase';
import { getEntityName } from '../../utils/EntityUtils';
import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils';
import { getEntityDetailsPath, getVersionPath } from '../../utils/RouterUtils';
import { updateTierTag } from '../../utils/TagsUtils';
import { updateCertificationTag, updateTierTag } from '../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils';
const APICollectionPage: FunctionComponent = () => {
@ -443,6 +443,21 @@ const APICollectionPage: FunctionComponent = () => {
showErrorToast(error as AxiosError);
}
};
const onCertificationUpdate = useCallback(
async (newCertification?: Tag) => {
if (apiCollection) {
const certificationTag: APICollection['certification'] =
updateCertificationTag(newCertification);
const updatedTableDetails = {
...apiCollection,
certification: certificationTag,
};
await handleAPICollectionUpdate(updatedTableDetails as APICollection);
}
},
[handleAPICollectionUpdate, apiCollection]
);
const toggleTabExpanded = () => {
setIsTabExpanded(!isTabExpanded);
@ -502,6 +517,7 @@ const APICollectionPage: FunctionComponent = () => {
entityType={EntityType.API_COLLECTION}
extraDropdownContent={extraDropdownContent}
permissions={apiCollectionPermission}
onCertificationUpdate={onCertificationUpdate}
onDisplayNameUpdate={handleUpdateDisplayName}
onOwnerUpdate={handleUpdateOwner}
onRestoreDataAsset={handleRestoreAPICollection}

View File

@ -73,7 +73,7 @@ import {
import { getEntityName } from '../../utils/EntityUtils';
import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils';
import { getEntityDetailsPath, getVersionPath } from '../../utils/RouterUtils';
import { updateTierTag } from '../../utils/TagsUtils';
import { updateCertificationTag, updateTierTag } from '../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils';
const ContainerPage = () => {
@ -441,6 +441,27 @@ const ContainerPage = () => {
}
};
const onContainerUpdateCertification = async (
updatedContainer: Container,
key?: keyof Container
) => {
try {
const response = await handleUpdateContainerData(updatedContainer);
setContainerData((previous) => {
if (!previous) {
return previous;
}
return {
...previous,
version: response.version,
...(key ? { [key]: response[key] } : response),
};
});
} catch (error) {
showErrorToast(error as AxiosError);
}
};
const tabs = useMemo(() => {
const tabLabelMap = getTabLabelMapFromTabs(customizedPage?.tabs);
@ -517,6 +538,24 @@ const ContainerPage = () => {
[tabs[0], tab]
);
const onCertificationUpdate = useCallback(
async (newCertification?: Tag) => {
if (containerData) {
const certificationTag: Container['certification'] =
updateCertificationTag(newCertification);
const updatedTableDetails = {
...containerData,
certification: certificationTag,
};
await onContainerUpdateCertification(
updatedTableDetails,
'certification'
);
}
},
[containerData, handleContainerUpdate]
);
// Rendering
if (isLoading || loading) {
return <Loader />;
@ -562,6 +601,7 @@ const ContainerPage = () => {
entityType={EntityType.CONTAINER}
openTaskCount={feedCount.openTaskCount}
permissions={containerPermissions}
onCertificationUpdate={onCertificationUpdate}
onDisplayNameUpdate={handleUpdateDisplayName}
onFollowClick={handleFollowContainer}
onOwnerUpdate={handleUpdateOwner}

View File

@ -217,8 +217,7 @@ const DataModelsPage = () => {
try {
const response = await handleUpdateDataModelData(updatedDataModel);
setDataModelData((prev) => ({
...prev,
setDataModelData(() => ({
...response,
...(key && { [key]: response[key] }),
}));

View File

@ -85,7 +85,7 @@ import {
getVersionPath,
} from '../../utils/RouterUtils';
import { getTierTags } from '../../utils/TableUtils';
import { updateTierTag } from '../../utils/TagsUtils';
import { updateCertificationTag, updateTierTag } from '../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils';
const DatabaseDetails: FunctionComponent = () => {
@ -516,6 +516,21 @@ const DatabaseDetails: FunctionComponent = () => {
const toggleTabExpanded = () => {
setIsTabExpanded(!isTabExpanded);
};
const onCertificationUpdate = useCallback(
async (newCertification?: Tag) => {
if (database) {
const certificationTag: Database['certification'] =
updateCertificationTag(newCertification);
const updatedTableDetails = {
...database,
certification: certificationTag,
};
await settingsUpdateHandler(updatedTableDetails as Database);
}
},
[settingsUpdateHandler, database]
);
const isExpandViewSupported = useMemo(
() => checkIfExpandViewSupported(tabs[0], activeTab, PageType.Database),
@ -559,6 +574,7 @@ const DatabaseDetails: FunctionComponent = () => {
extraDropdownContent={extraDropdownContent}
openTaskCount={feedCount.openTaskCount}
permissions={databasePermission}
onCertificationUpdate={onCertificationUpdate}
onDisplayNameUpdate={handleUpdateDisplayName}
onFollowClick={handleFollowClick}
onOwnerUpdate={handleUpdateOwner}

View File

@ -83,7 +83,7 @@ import entityUtilClassBase from '../../utils/EntityUtilClassBase';
import { getEntityName } from '../../utils/EntityUtils';
import { DEFAULT_ENTITY_PERMISSION } from '../../utils/PermissionsUtils';
import { getEntityDetailsPath, getVersionPath } from '../../utils/RouterUtils';
import { updateTierTag } from '../../utils/TagsUtils';
import { updateCertificationTag, updateTierTag } from '../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils';
const DatabaseSchemaPage: FunctionComponent = () => {
@ -581,6 +581,22 @@ const DatabaseSchemaPage: FunctionComponent = () => {
}
}, [USERId, databaseSchemaId]);
const onCertificationUpdate = useCallback(
async (newCertification?: Tag) => {
if (databaseSchema) {
const certificationTag: DatabaseSchema['certification'] =
updateCertificationTag(newCertification);
const updatedTableDetails = {
...databaseSchema,
certification: certificationTag,
};
await handleUpdateDatabaseSchema(updatedTableDetails as DatabaseSchema);
}
},
[handleUpdateDatabaseSchema, databaseSchema]
);
const handleFollowClick = useCallback(async () => {
isFollowing ? await unFollowSchema() : await followSchema();
}, [isFollowing, unFollowSchema, followSchema]);
@ -634,6 +650,7 @@ const DatabaseSchemaPage: FunctionComponent = () => {
entityType={EntityType.DATABASE_SCHEMA}
extraDropdownContent={extraDropdownContent}
permissions={databaseSchemaPermission}
onCertificationUpdate={onCertificationUpdate}
onDisplayNameUpdate={handleUpdateDisplayName}
onFollowClick={handleFollowClick}
onOwnerUpdate={handleUpdateOwner}

View File

@ -257,6 +257,23 @@ const MlModelPage = () => {
},
[saveUpdatedMlModelData]
);
const onMlModelUpdateCertification = async (
updatedMlModel: Mlmodel,
key?: keyof Mlmodel
) => {
try {
const response = await saveUpdatedMlModelData(updatedMlModel);
setMlModelDetail((previous) => {
return {
...previous,
version: response.version,
...(key ? { [key]: response[key] } : response),
};
});
} catch (error) {
showErrorToast(error as AxiosError);
}
};
useEffect(() => {
fetchResourcePermission(mlModelFqn);
@ -297,6 +314,7 @@ const MlModelPage = () => {
updateMlModelDetailsState={updateMlModelDetailsState}
versionHandler={versionHandler}
onMlModelUpdate={handleMlModelUpdate}
onMlModelUpdateCertification={onMlModelUpdateCertification}
onUpdateVote={updateVote}
/>
);

View File

@ -198,6 +198,24 @@ const PipelineDetailsPage = () => {
}
};
const onPipelineUpdate = async (
updatedPipeline: Pipeline,
key?: keyof Pipeline
) => {
try {
const response = await saveUpdatedPipelineData(updatedPipeline);
setPipelineDetails((previous) => {
return {
...previous,
version: response.version,
...(key ? { [key]: response[key] } : response),
};
});
} catch (error) {
showErrorToast(error as AxiosError);
}
};
const settingsUpdateHandler = async (updatedPipeline: Pipeline) => {
try {
const res = await saveUpdatedPipelineData(updatedPipeline);
@ -332,6 +350,7 @@ const PipelineDetailsPage = () => {
updatePipelineDetailsState={updatePipelineDetailsState}
versionHandler={versionHandler}
onExtensionUpdate={handleExtensionUpdate}
onPipelineUpdate={onPipelineUpdate}
onUpdateVote={updateVote}
/>
);

View File

@ -64,7 +64,7 @@ import { getEntityDetailsPath, getVersionPath } from '../../utils/RouterUtils';
import searchIndexClassBase from '../../utils/SearchIndexDetailsClassBase';
import { defaultFields } from '../../utils/SearchIndexUtils';
import { getTagsWithoutTier, getTierTags } from '../../utils/TableUtils';
import { updateTierTag } from '../../utils/TagsUtils';
import { updateCertificationTag, updateTierTag } from '../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils';
function SearchIndexDetailsPage() {
@ -511,6 +511,22 @@ function SearchIndexDetailsPage() {
setIsTabExpanded(!isTabExpanded);
};
const onCertificationUpdate = useCallback(
async (newCertification?: Tag) => {
if (searchIndexDetails) {
const certificationTag: SearchIndex['certification'] =
updateCertificationTag(newCertification);
const updatedTableDetails = {
...searchIndexDetails,
certification: certificationTag,
};
await onSearchIndexUpdate(updatedTableDetails, 'certification');
}
},
[onSearchIndexUpdate, searchIndexDetails]
);
const isExpandViewSupported = useMemo(
() => checkIfExpandViewSupported(tabs[0], activeTab, PageType.SearchIndex),
[tabs[0], activeTab]
@ -554,6 +570,7 @@ function SearchIndexDetailsPage() {
entityType={EntityType.SEARCH_INDEX}
openTaskCount={feedCount.openTaskCount}
permissions={searchIndexPermissions}
onCertificationUpdate={onCertificationUpdate}
onDisplayNameUpdate={handleDisplayNameUpdate}
onFollowClick={handleFollowSearchIndex}
onOwnerUpdate={handleUpdateOwner}

View File

@ -69,7 +69,7 @@ import {
STORED_PROCEDURE_DEFAULT_FIELDS,
} from '../../utils/StoredProceduresUtils';
import { getTagsWithoutTier, getTierTags } from '../../utils/TableUtils';
import { updateTierTag } from '../../utils/TagsUtils';
import { updateCertificationTag, updateTierTag } from '../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils';
const StoredProcedurePage = () => {
@ -511,6 +511,24 @@ const StoredProcedurePage = () => {
}
};
const onCertificationUpdate = useCallback(
async (newCertification?: Tag) => {
if (storedProcedure) {
const certificationTag: StoredProcedure['certification'] =
updateCertificationTag(newCertification);
const updatedStoredProcedureDetails = {
...storedProcedure,
certification: certificationTag,
};
await handleStoreProcedureUpdate(
updatedStoredProcedureDetails,
'certification'
);
}
},
[storedProcedure, handleStoreProcedureUpdate]
);
useEffect(() => {
if (decodedStoredProcedureFQN) {
fetchResourcePermission();
@ -559,6 +577,7 @@ const StoredProcedurePage = () => {
entityType={EntityType.STORED_PROCEDURE}
openTaskCount={feedCount.openTaskCount}
permissions={storedProcedurePermissions}
onCertificationUpdate={onCertificationUpdate}
onDisplayNameUpdate={handleDisplayNameUpdate}
onFollowClick={handleFollow}
onOwnerUpdate={handleUpdateOwner}

View File

@ -98,7 +98,7 @@ import {
getTierTags,
updateColumnInNestedStructure,
} from '../../utils/TableUtils';
import { updateTierTag } from '../../utils/TagsUtils';
import { updateCertificationTag, updateTierTag } from '../../utils/TagsUtils';
import { showErrorToast, showSuccessToast } from '../../utils/ToastUtils';
import { useTestCaseStore } from '../IncidentManager/IncidentManagerDetailPage/useTestCase.store';
@ -565,6 +565,21 @@ const TableDetailsPageV1: React.FC = () => {
[tableDetails, onTableUpdate, tableTags]
);
const onCertificationUpdate = useCallback(
async (newCertification?: Tag) => {
if (tableDetails) {
const certificationTag: Table['certification'] =
updateCertificationTag(newCertification);
const updatedTableDetails = {
...tableDetails,
certification: certificationTag,
};
await onTableUpdate(updatedTableDetails, 'certification');
}
},
[tableDetails, onTableUpdate]
);
const handleToggleDelete = (version?: number) => {
setTableDetails((prev) => {
if (!prev) {
@ -807,6 +822,7 @@ const TableDetailsPageV1: React.FC = () => {
extraDropdownContent={extraDropdownContent}
openTaskCount={feedCount.openTaskCount}
permissions={tablePermissions}
onCertificationUpdate={onCertificationUpdate}
onDisplayNameUpdate={handleDisplayNameUpdate}
onFollowClick={handleFollowTable}
onOwnerUpdate={handleUpdateOwner}

View File

@ -268,6 +268,13 @@
.h-max-56 {
max-height: 14rem /* 224px */;
}
.h-max-80 {
max-height: 20rem /* 320px */;
}
.h-max-100 {
max-height: 25rem /* 400px */;
}
.h-min-0 {
min-height: 0px;
}

View File

@ -16,6 +16,7 @@ import Select, { DefaultOptionType } from 'antd/lib/select';
import { t } from 'i18next';
import { toString } from 'lodash';
import React, { ReactNode } from 'react';
import Certification from '../../components/Certification/Certification.component';
import TreeAsyncSelectList from '../../components/common/AsyncSelectList/TreeAsyncSelectList';
import DomainSelectableList from '../../components/common/DomainSelectableList/DomainSelectableList.component';
import InlineEdit from '../../components/common/InlineEdit/InlineEdit.component';
@ -206,6 +207,31 @@ class CSVUtilsClassBase {
</TierCard>
);
};
case 'certification':
return ({ value, ...props }) => {
const handleChange = async (tag?: Tag) => {
props.onChange(tag?.fullyQualifiedName);
setTimeout(() => {
props.onComplete(tag?.fullyQualifiedName);
}, 1);
};
const onClose = () => {
props.onCancel();
};
return (
<Certification
permission
currentCertificate={value}
popoverProps={{ open: true }}
onCertificationUpdate={handleChange}
onClose={onClose}
/>
);
};
case 'domain':
return ({ value, ...props }) => {
const handleChange = async (domain?: EntityReference) => {

View File

@ -34,7 +34,11 @@ import { SearchIndex } from '../enums/search.enum';
import { Classification } from '../generated/entity/classification/classification';
import { Tag } from '../generated/entity/classification/tag';
import { GlossaryTerm } from '../generated/entity/data/glossaryTerm';
import { Column } from '../generated/entity/data/table';
import {
AssetCertification,
Column,
TagSource,
} from '../generated/entity/data/table';
import { Operation } from '../generated/entity/policies/policy';
import { Paging } from '../generated/type/paging';
import { LabelType, State, TagLabel } from '../generated/type/tagLabel';
@ -306,12 +310,48 @@ export const createTierTag = (tag: Tag) => {
};
};
export const createCertificationTag = (tag: Tag) => {
return {
tagLabel: {
displayName: tag.displayName,
name: tag.name,
href: tag.href,
description: tag.description,
tagFQN: tag.fullyQualifiedName,
labelType: LabelType.Manual,
state: State.Confirmed,
},
};
};
export const updateTierTag = (oldTags: Tag[] | TagLabel[], newTier?: Tag) => {
return newTier
? [...getTagsWithoutTier(oldTags), createTierTag(newTier)]
: getTagsWithoutTier(oldTags);
};
export const updateCertificationTag = (
newCertification?: Tag
): AssetCertification | undefined => {
if (!newCertification) {
return undefined;
}
return {
tagLabel: {
tagFQN: newCertification.fullyQualifiedName || '',
name: newCertification.name,
displayName: newCertification.displayName,
description: newCertification.description || '',
source: TagSource.Classification,
labelType: LabelType.Manual,
state: State.Confirmed,
style: newCertification.style,
},
appliedDate: Date.now(),
expiryDate: Date.now() + 90 * 24 * 60 * 60 * 1000, // 90 days from now
};
};
export const createTagObject = (tags: EntityTags[]) => {
return tags.map(
(tag) =>