diff --git a/openmetadata-service/src/main/java/org/openmetadata/csv/EntityCsv.java b/openmetadata-service/src/main/java/org/openmetadata/csv/EntityCsv.java index b65ba943a86..8d408009cdb 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/csv/EntityCsv.java +++ b/openmetadata-service/src/main/java/org/openmetadata/csv/EntityCsv.java @@ -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 { 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 getExtension(CSVPrinter printer, CSVRecord csvRecord, int fieldNumber) throws IOException { String extensionString = csvRecord.get(fieldNumber); @@ -1077,6 +1091,8 @@ public abstract class EntityCsv { 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 { .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 { 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 { .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 { 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 { } 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 { 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 { 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 { } 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 { } column.withDataLength( - parseDataLength(csvRecord.get(16), column.getDataType(), column.getName())); + parseDataLength(csvRecord.get(17), column.getDataType(), column.getName())); List tagLabels = getTagLabels( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/ResourceRegistry.java b/openmetadata-service/src/main/java/org/openmetadata/service/ResourceRegistry.java index 970302c42f7..d2b519a1667 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/ResourceRegistry.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/ResourceRegistry.java @@ -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 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); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DatabaseRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DatabaseRepository.java index 807c5b0a9b1..7629369f84f 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DatabaseRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DatabaseRepository.java @@ -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 { 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 { } // 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 { .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 tagLabels = @@ -454,6 +461,9 @@ public class DatabaseRepository extends EntityRepository { 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 { .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); } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DatabaseSchemaRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DatabaseSchemaRepository.java index f60d1c9d513..deebf465311 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DatabaseSchemaRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DatabaseSchemaRepository.java @@ -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 { 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 { .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 { 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 { .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 { 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); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DatabaseServiceRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DatabaseServiceRepository.java index 4827cb5b108..54a893ccd39 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DatabaseServiceRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DatabaseServiceRepository.java @@ -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 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) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index f2736512425..ec952732431 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java @@ -1057,7 +1057,10 @@ public abstract class EntityRepository { 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 { 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 = diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TableRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TableRepository.java index 3c1a658f3e9..9dcbb7253dc 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TableRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TableRepository.java @@ -863,6 +863,7 @@ public class TableRepository extends EntityRepository { // 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) diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/migration/mysql/v180/Migration.java b/openmetadata-service/src/main/java/org/openmetadata/service/migration/mysql/v180/Migration.java new file mode 100644 index 00000000000..1b223c731b8 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/migration/mysql/v180/Migration.java @@ -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); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/migration/postgres/v180/Migration.java b/openmetadata-service/src/main/java/org/openmetadata/service/migration/postgres/v180/Migration.java new file mode 100644 index 00000000000..8204981e272 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/migration/postgres/v180/Migration.java @@ -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); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/v180/MigrationUtil.java b/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/v180/MigrationUtil.java new file mode 100644 index 00000000000..0409e1fd0a7 --- /dev/null +++ b/openmetadata-service/src/main/java/org/openmetadata/service/migration/utils/v180/MigrationUtil.java @@ -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); + } +} diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseResource.java b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseResource.java index 86f5827c21a..f61a640f110 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseResource.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/resources/databases/DatabaseResource.java @@ -79,7 +79,7 @@ public class DatabaseResource extends EntityResource 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 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 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 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 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()); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/util/JsonPatchUtils.java b/openmetadata-service/src/main/java/org/openmetadata/service/util/JsonPatchUtils.java index e7d0bb9208f..105f59995b6 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/util/JsonPatchUtils.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/util/JsonPatchUtils.java @@ -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; diff --git a/openmetadata-service/src/main/resources/json/data/database/databaseCsvDocumentation.json b/openmetadata-service/src/main/resources/json/data/database/databaseCsvDocumentation.json index 90b7c5fb122..3325fca9b06 100644 --- a/openmetadata-service/src/main/resources/json/data/database/databaseCsvDocumentation.json +++ b/openmetadata-service/src/main/resources/json/data/database/databaseCsvDocumentation.json @@ -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, diff --git a/openmetadata-service/src/main/resources/json/data/databaseSchema/databaseSchemaCsvDocumentation.json b/openmetadata-service/src/main/resources/json/data/databaseSchema/databaseSchemaCsvDocumentation.json index 32bd6c12f69..d1ffa7dfe2a 100644 --- a/openmetadata-service/src/main/resources/json/data/databaseSchema/databaseSchemaCsvDocumentation.json +++ b/openmetadata-service/src/main/resources/json/data/databaseSchema/databaseSchemaCsvDocumentation.json @@ -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, diff --git a/openmetadata-service/src/main/resources/json/data/databaseService/databaseServiceCsvDocumentation.json b/openmetadata-service/src/main/resources/json/data/databaseService/databaseServiceCsvDocumentation.json index 2b643158d13..96bea5b9798 100644 --- a/openmetadata-service/src/main/resources/json/data/databaseService/databaseServiceCsvDocumentation.json +++ b/openmetadata-service/src/main/resources/json/data/databaseService/databaseServiceCsvDocumentation.json @@ -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, diff --git a/openmetadata-service/src/main/resources/json/data/entity/entityCsvDocumentation.json b/openmetadata-service/src/main/resources/json/data/entity/entityCsvDocumentation.json index e7e4c3e1266..28be4b93e60 100644 --- a/openmetadata-service/src/main/resources/json/data/entity/entityCsvDocumentation.json +++ b/openmetadata-service/src/main/resources/json/data/entity/entityCsvDocumentation.json @@ -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, diff --git a/openmetadata-service/src/main/resources/json/data/policy/DataConsumerPolicy.json b/openmetadata-service/src/main/resources/json/data/policy/DataConsumerPolicy.json index 4758c16fe8b..a02039cbb76 100644 --- a/openmetadata-service/src/main/resources/json/data/policy/DataConsumerPolicy.json +++ b/openmetadata-service/src/main/resources/json/data/policy/DataConsumerPolicy.json @@ -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" } ] diff --git a/openmetadata-service/src/main/resources/json/data/policy/DataStewardPolicy.json b/openmetadata-service/src/main/resources/json/data/policy/DataStewardPolicy.json index 270976049b4..76a586bfcb1 100644 --- a/openmetadata-service/src/main/resources/json/data/policy/DataStewardPolicy.json +++ b/openmetadata-service/src/main/resources/json/data/policy/DataStewardPolicy.json @@ -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" } ] diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/databases/DatabaseResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/databases/DatabaseResourceTest.java index bfc37f692d0..732eef6c306 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/databases/DatabaseResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/databases/DatabaseResourceTest.java @@ -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 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 clearRecords = listOf("s1,dsp1,new-dsc2,,,,,P23DT23H,http://test.com,,"); + List 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 diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/resources/services/DatabaseServiceResourceTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/resources/services/DatabaseServiceResourceTest.java index f9828566dd0..eddb32e61cc 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/resources/services/DatabaseServiceResourceTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/resources/services/DatabaseServiceResourceTest.java @@ -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(), diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/util/JsonPatchUtilsTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/util/JsonPatchUtilsTest.java index a8c6cc58f01..fc4449fdd94 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/util/JsonPatchUtilsTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/util/JsonPatchUtilsTest.java @@ -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 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 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 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" + + "]"; + } } diff --git a/openmetadata-spec/src/main/resources/json/schema/entity/policies/accessControl/resourceDescriptor.json b/openmetadata-spec/src/main/resources/json/schema/entity/policies/accessControl/resourceDescriptor.json index 0ffcb4f713c..3bd5dd82d7c 100644 --- a/openmetadata-spec/src/main/resources/json/schema/entity/policies/accessControl/resourceDescriptor.json +++ b/openmetadata-spec/src/main/resources/json/schema/entity/policies/accessControl/resourceDescriptor.json @@ -42,6 +42,7 @@ "EditGlossaryTerms", "EditTeams", "EditTier", + "EditCertification", "EditTests", "EditUsage", "EditUsers", diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/service.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/service.ts index 31ce5b975b0..58e694d3a3c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/service.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/service.ts @@ -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, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkImport.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkImport.spec.ts index 68375de4e31..47700e33aac 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkImport.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/BulkImport.spec.ts @@ -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); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Entity.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Entity.spec.ts index c7939d50a62..d217a9dd092 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Entity.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Entity.spec.ts @@ -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, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ServiceEntity.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ServiceEntity.spec.ts index cf1c89fdf69..2f733babfaa 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ServiceEntity.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/ServiceEntity.spec.ts @@ -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); }); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts index 9e4381d9716..7cc0551b88c 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityClass.ts @@ -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 diff --git a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityDataClass.ts b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityDataClass.ts index c2af0c3401e..43e73a50b2a 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityDataClass.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/support/entity/EntityDataClass.ts @@ -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), diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts index 84682b4788a..3308fcf81c9 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/entity.ts @@ -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, diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts index a2356306378..de5b48da823 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/importUtils.ts @@ -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); diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-certification.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-certification.svg new file mode 100644 index 00000000000..0fa221de91e --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-certification.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointDetails/APIEndpointDetails.tsx b/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointDetails/APIEndpointDetails.tsx index 48a144dcb01..16f098d1071 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointDetails/APIEndpointDetails.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/APIEndpoint/APIEndpointDetails/APIEndpointDetails.tsx @@ -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 = ({ 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 = ({ entityType={EntityType.API_ENDPOINT} openTaskCount={feedCount.openTaskCount} permissions={apiEndpointPermissions} + onCertificationUpdate={onCertificationUpdate} onDisplayNameUpdate={handleUpdateDisplayName} onFollowClick={followApiEndpoint} onOwnerUpdate={onOwnerUpdate} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.component.tsx new file mode 100644 index 00000000000..305e94da1a9 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.component.tsx @@ -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(null); + const [isLoadingCertificationData, setIsLoadingCertificationData] = + useState(false); + const [certifications, setCertifications] = useState>([]); + const [selectedCertification, setSelectedCertification] = useState( + currentCertificate ?? '' + ); + const certificationCardData = useMemo(() => { + return ( + + {certifications.map((certificate) => { + const tagSrc = getTagImageSrc(certificate.style?.iconURL ?? ''); + const title = getEntityName(certificate); + const { id, fullyQualifiedName, description } = certificate; + + return ( +
{ + setSelectedCertification(fullyQualifiedName ?? ''); + }}> + +
+ {tagSrc ? ( + {title} + ) : ( +
+ +
+ )} +
+ + {title} + + + {stringToHTML(description)} + +
+
+
+ ); + })} +
+ ); + }, [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 = { + 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 ( + +
+ + + {t('label.edit-entity', { entity: t('label.certification') })} + +
+ updateCertificationData()}> + {t('label.clear')} + + + }> + } + spinning={isLoadingCertificationData}> + {certificationCardData} +
+ + +
+
+ + } + overlayClassName="certification-card-popover" + placement="bottomRight" + ref={popoverRef} + showArrow={false} + trigger="click" + onOpenChange={onOpenChange} + {...popoverProps}> + {children} +
+ ); +}; + +export default Certification; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.interface.ts new file mode 100644 index 00000000000..b2260f1d050 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Certification/Certification.interface.ts @@ -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; + onClose?: () => void; + currentCertificate?: string; + popoverProps?: PopoverProps; + children?: ReactNode; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Certification/certification.less b/openmetadata-ui/src/main/resources/ui/src/components/Certification/certification.less new file mode 100644 index 00000000000..77f81c088d5 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Certification/certification.less @@ -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; + } +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.tsx index 96a8d6011b1..ab0ee31907e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.component.tsx @@ -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} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.interface.ts index baa6b799fa5..4b8c9892427 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DashboardDetails/DashboardDetails.interface.ts @@ -34,7 +34,10 @@ export interface DashboardDetailsProps { followDashboardHandler: () => Promise; unFollowDashboardHandler: () => Promise; versionHandler: () => void; - onDashboardUpdate: (updatedDashboard: Dashboard) => Promise; + onDashboardUpdate: ( + updatedDashboard: Dashboard, + key?: keyof Dashboard + ) => Promise; handleToggleDelete: (version?: number) => void; onUpdateVote?: (data: QueryVote, id: string) => Promise; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.tsx index 3453aff2625..4d0660a3720 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Dashboard/DataModel/DataModels/DataModelDetails.component.tsx @@ -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 ; @@ -228,6 +245,7 @@ const DataModelDetails = ({ entityType={EntityType.DASHBOARD_DATA_MODEL} openTaskCount={feedCount.openTaskCount} permissions={dataModelPermissions} + onCertificationUpdate={onCertificationUpdate} onDisplayNameUpdate={handleUpdateDisplayName} onFollowClick={handleFollowDataModel} onOwnerUpdate={handleUpdateOwner} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx index e620100ee4b..169fa7ae79c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.component.tsx @@ -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 = ({ /> )} - - - ) : ( - t('label.no-entity', { entity: t('label.certification') }) - ) - } - /> + {isUndefined(serviceCategory) && ( + <> + + +
+ +
+ + {t('label.certification')} + + + {editCertificationPermission && ( + + )} +
+
+ {(dataAsset as Table).certification ? ( + + ) : ( + t('label.no-entity', { + entity: t('label.certification'), + }) + )} +
+
+
+
+ + )} + {extraInfo} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.interface.ts index 13800e23782..421dac40ece 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.interface.ts @@ -139,6 +139,7 @@ export type DataAssetsHeaderProps = { disableRunAgentsButton?: boolean; afterTriggerAction?: VoidFunction; isAutoPilotWorkflowStatusLoading?: boolean; + onCertificationUpdate?: (certificate?: Tag) => Promise; } & ( | DataAssetTable | DataAssetTopic diff --git a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.test.tsx index 877ccdcc75d..d23766dd86b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/DataAssets/DataAssetsHeader/DataAssetsHeader.test.tsx @@ -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( { // Clean up the first render before rendering again unmount(); - // Second test without certification + // Test without certification when serviceCategory is undefined render(); 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( + + ); + + // 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', () => { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx index 48de299eab5..bff94bfb2ff 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ExploreV1/ExploreSearchCard/ExploreSearchCard.tsx @@ -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 = forwardRef< - {(source as Table)?.certification && ( + {!isEmpty( + (source as Table)?.certification?.tagLabel?.tagFQN + ) && (
= ({ 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 = ({ entityType={EntityType.METRIC} openTaskCount={feedCount.openTaskCount} permissions={metricPermissions} + onCertificationUpdate={onCertificationUpdate} onDisplayNameUpdate={handleUpdateDisplayName} onFollowClick={followMetric} onMetricUpdate={onMetricUpdate} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.test.tsx index 21b6e500601..61cb4c2f0e6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.test.tsx @@ -175,6 +175,7 @@ const mockProp = { versionHandler: jest.fn(), handleToggleDelete: jest.fn(), onUpdateVote: jest.fn(), + onMlModelUpdateCertification: jest.fn(), }; const mockParams = { diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.tsx index 8eba770da84..f15d0abf0d6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.component.tsx @@ -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 = ({ versionHandler, handleToggleDelete, onMlModelUpdate, + onMlModelUpdateCertification, }) => { const { t } = useTranslation(); const { currentUser } = useApplicationStore(); @@ -358,6 +362,24 @@ const MlModelDetail: FC = ({ 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 = ({ entityType={EntityType.MLMODEL} openTaskCount={feedCount.openTaskCount} permissions={mlModelPermissions} + onCertificationUpdate={onCertificationUpdate} onDisplayNameUpdate={handleUpdateDisplayName} onFollowClick={followMlModel} onOwnerUpdate={onOwnerUpdate} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.interface.ts index 7b1fccd8499..041180241c6 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/MlModel/MlModelDetail/MlModelDetail.interface.ts @@ -27,4 +27,8 @@ export interface MlModelDetailProp extends HTMLAttributes { handleToggleDelete: (version?: number) => void; onUpdateVote: (data: QueryVote, id: string) => Promise; onMlModelUpdate: (data: Mlmodel) => Promise; + onMlModelUpdateCertification: ( + data: Mlmodel, + key: keyof Mlmodel + ) => Promise; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.component.tsx index 7a528deeaec..dbefaee3249 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.component.tsx @@ -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} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.interface.ts index 9c7795fbbec..0a25e3b0c1e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.interface.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.interface.ts @@ -32,4 +32,8 @@ export interface PipeLineDetailsProp { onExtensionUpdate: (updatedPipeline: Pipeline) => Promise; handleToggleDelete: (version?: number) => void; onUpdateVote: (data: QueryVote, id: string) => Promise; + onPipelineUpdate: ( + updatedPipeline: Pipeline, + key?: keyof Pipeline + ) => Promise; } diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.test.tsx index 1c2988afc0b..154db725eab 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Pipeline/PipelineDetails/PipelineDetails.test.tsx @@ -68,6 +68,7 @@ const PipelineDetailsProps: PipeLineDetailsProp = { onExtensionUpdate: jest.fn(), handleToggleDelete: jest.fn(), onUpdateVote: jest.fn(), + onPipelineUpdate: jest.fn(), }; jest.mock( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx index dcfe609f837..e7f91ad7ea8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Topic/TopicDetails/TopicDetails.component.tsx @@ -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 = ({ 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 = ({ entityType={EntityType.TOPIC} openTaskCount={feedCount.openTaskCount} permissions={topicPermissions} + onCertificationUpdate={onCertificationUpdate} onDisplayNameUpdate={handleUpdateDisplayName} onFollowClick={followTopic} onOwnerUpdate={onOwnerUpdate} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CertificationTag/CertificationTag.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CertificationTag/CertificationTag.tsx index 0fba4de02c7..6ec1053a792 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CertificationTag/CertificationTag.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CertificationTag/CertificationTag.tsx @@ -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 ( + {`certification: + ); + } + + const iconSize = showName ? 14 : 20; + + return ; + }, [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 ( - {`certification: + {imageItem} {showName && ( - + })} + ellipsis={{ tooltip: true }}> {name} - + )} ); - } + }, [certification, imageItem]); - return ( - - {getEntityName(certification.tagLabel)} - - ); + return certificationRender; }; export default CertificationTag; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CertificationTag/certification-tag.less b/openmetadata-ui/src/main/resources/ui/src/components/common/CertificationTag/certification-tag.less index 9cab16625b6..3e5f903736e 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CertificationTag/certification-tag.less +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CertificationTag/certification-tag.less @@ -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; } diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createDashboardDataModel.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createDashboardDataModel.ts index fb095e6db7e..06a4ec6b76a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createDashboardDataModel.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/data/createDashboardDataModel.ts @@ -600,6 +600,7 @@ export enum DataModelType { LookMlExplore = "LookMlExplore", LookMlView = "LookMlView", MetabaseDataModel = "MetabaseDataModel", + MicroStrategyDataset = "MicroStrategyDataset", PowerBIDataFlow = "PowerBIDataFlow", PowerBIDataModel = "PowerBIDataModel", QlikDataModel = "QlikDataModel", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/api/policies/createPolicy.ts b/openmetadata-ui/src/main/resources/ui/src/generated/api/policies/createPolicy.ts index 2c76324a1c1..41b55fe7333 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/api/policies/createPolicy.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/api/policies/createPolicy.ts @@ -156,6 +156,7 @@ export enum Operation { DeleteTestCaseFailedRowsSample = "DeleteTestCaseFailedRowsSample", Deploy = "Deploy", EditAll = "EditAll", + EditCertification = "EditCertification", EditCustomFields = "EditCustomFields", EditDataProfile = "EditDataProfile", EditDescription = "EditDescription", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourceDescriptor.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourceDescriptor.ts index c5923c07b96..288b5a16ab8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourceDescriptor.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourceDescriptor.ts @@ -38,6 +38,7 @@ export enum Operation { DeleteTestCaseFailedRowsSample = "DeleteTestCaseFailedRowsSample", Deploy = "Deploy", EditAll = "EditAll", + EditCertification = "EditCertification", EditCustomFields = "EditCustomFields", EditDataProfile = "EditDataProfile", EditDescription = "EditDescription", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourcePermission.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourcePermission.ts index a95c113e11d..83c6f2c867c 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourcePermission.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/resourcePermission.ts @@ -80,6 +80,7 @@ export enum Operation { DeleteTestCaseFailedRowsSample = "DeleteTestCaseFailedRowsSample", Deploy = "Deploy", EditAll = "EditAll", + EditCertification = "EditCertification", EditCustomFields = "EditCustomFields", EditDataProfile = "EditDataProfile", EditDescription = "EditDescription", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/rule.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/rule.ts index 0c041b1d768..ff43285debd 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/rule.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/accessControl/rule.ts @@ -63,6 +63,7 @@ export enum Operation { DeleteTestCaseFailedRowsSample = "DeleteTestCaseFailedRowsSample", Deploy = "Deploy", EditAll = "EditAll", + EditCertification = "EditCertification", EditCustomFields = "EditCustomFields", EditDataProfile = "EditDataProfile", EditDescription = "EditDescription", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/policy.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/policy.ts index 40b0ff6a5d7..4e66f80a8c4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/policy.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/policies/policy.ts @@ -306,6 +306,7 @@ export enum Operation { DeleteTestCaseFailedRowsSample = "DeleteTestCaseFailedRowsSample", Deploy = "Deploy", EditAll = "EditAll", + EditCertification = "EditCertification", EditCustomFields = "EditCustomFields", EditDataProfile = "EditDataProfile", EditDescription = "EditDescription", diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/databaseService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/databaseService.ts index b91253c36eb..66b51e8b6d2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/databaseService.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/databaseService.ts @@ -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 diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/ingestionPipelines/ingestionPipeline.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/ingestionPipelines/ingestionPipeline.ts index 64db32c1127..d125de3f6ff 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/ingestionPipelines/ingestionPipeline.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/ingestionPipelines/ingestionPipeline.ts @@ -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. diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/pipelineService.ts b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/pipelineService.ts index d3ef970388b..1e2120a16de 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/pipelineService.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/entity/services/pipelineService.ts @@ -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. */ diff --git a/openmetadata-ui/src/main/resources/ui/src/generated/jobs/backgroundJob.ts b/openmetadata-ui/src/main/resources/ui/src/generated/jobs/backgroundJob.ts index 69cf560b48a..5d3f0f9e7d2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/generated/jobs/backgroundJob.ts +++ b/openmetadata-ui/src/main/resources/ui/src/generated/jobs/backgroundJob.ts @@ -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. diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.tsx index f2ccabbb458..3224bf7f427 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/APICollectionPage/APICollectionPage.tsx @@ -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} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx index cc01fab8656..790d5449233 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/ContainerPage/ContainerPage.tsx @@ -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 ; @@ -562,6 +601,7 @@ const ContainerPage = () => { entityType={EntityType.CONTAINER} openTaskCount={feedCount.openTaskCount} permissions={containerPermissions} + onCertificationUpdate={onCertificationUpdate} onDisplayNameUpdate={handleUpdateDisplayName} onFollowClick={handleFollowContainer} onOwnerUpdate={handleUpdateOwner} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelPage.component.tsx index d06ee53be5d..adfa278f488 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DataModelPage/DataModelPage.component.tsx @@ -217,8 +217,7 @@ const DataModelsPage = () => { try { const response = await handleUpdateDataModelData(updatedDataModel); - setDataModelData((prev) => ({ - ...prev, + setDataModelData(() => ({ ...response, ...(key && { [key]: response[key] }), })); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx index 42e6acc938c..516be890803 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseDetailsPage/DatabaseDetailsPage.tsx @@ -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} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx index e76821e9572..8ac2d787130 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/DatabaseSchemaPage/DatabaseSchemaPage.component.tsx @@ -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} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.tsx index e3445bcc2ff..c94bee5fa00 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/MlModelPage/MlModelPage.component.tsx @@ -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} /> ); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx index 75bfdae70dd..c1e4fef9f13 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/PipelineDetails/PipelineDetailsPage.component.tsx @@ -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} /> ); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx index a15d80dd688..eea1616268d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/SearchIndexDetailsPage/SearchIndexDetailsPage.tsx @@ -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} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx index b0570e74921..6bb62042e01 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/StoredProcedure/StoredProcedurePage.tsx @@ -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} diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx index 84ad4cd7d33..a4b942d1b53 100644 --- a/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/pages/TableDetailsPageV1/TableDetailsPageV1.tsx @@ -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} diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/components/size.less b/openmetadata-ui/src/main/resources/ui/src/styles/components/size.less index b3de94cc3f7..11935f6d94b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/components/size.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/components/size.less @@ -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; } diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSVUtilsClassBase.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSVUtilsClassBase.tsx index 578379e6dcf..cd10e2dee96 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSVUtilsClassBase.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/CSV/CSVUtilsClassBase.tsx @@ -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 { ); }; + + 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 ( + + ); + }; case 'domain': return ({ value, ...props }) => { const handleChange = async (domain?: EntityReference) => { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx index 43c33c93e4b..2373dfceedf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TagsUtils.tsx @@ -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) =>