Add new ‘enumWithDescriptions’ Custom Property to allow adding Enum Keys with Description (#17777)

* Add new ‘metaEnum’ Custom Property to allow adding Enum Keys with Description

* replace JsonNodeFactory method with JsonUtils

* rename property from metaEnum to enumWithDescriptions, and other method optimizations

* ui: add support for creating enumWithDescription property

* minor locale changes

* ui: add edit support for created enumWithDescription property

* Refactor enum description field layout in AddCustomProperty and EditCustomPropertyModal

* add support for adding values to enumWithDescription custom property type

* Refactor custom property input IDs in AddCustomProperty and EditCustomPropertyModal components

* Refactor custom property table rendering logic and UI components

* Refactor custom property table rendering logic and UI components

* Refactor custom property table rendering logic and UI components

* add basic card layout

* Refactor CustomPropertyTable component to improve UI and functionality

* update playwright test part 1

* Refactor PropertyValue component to conditionally render right panel styles

* fix: entity reference property update

* Refactor CustomPropertyTable component to conditionally render right panel styles

* fix: flaky test

* Refactor CustomPropertyTable test to use updated test IDs and remove unnecessary code

* fix flaky test

* improve the playwright test

* add more test

---------

Co-authored-by: Sachin Chaurasiya <sachinchaurasiyachotey87@gmail.com>
Co-authored-by: Sriharsha Chintalapani <harshach@users.noreply.github.com>
(cherry picked from commit 1b029d2bf2793c6a4f016c91a4634c490462d7f4)
This commit is contained in:
sonika-shah 2024-09-29 00:45:46 +05:30 committed by Sachin Chaurasiya
parent 075c67b8b3
commit e320a1b516
34 changed files with 1512 additions and 227 deletions

View File

@ -118,7 +118,8 @@ public class TypeRegistry {
}
}
}
return null;
throw EntityNotFoundException.byMessage(
CatalogExceptionMessage.entityNotFound(Entity.TYPE, String.valueOf(type)));
}
public static String getCustomPropertyConfig(String entityType, String propertyName) {
@ -128,7 +129,13 @@ public class TypeRegistry {
if (property.getName().equals(propertyName)
&& property.getCustomPropertyConfig() != null
&& property.getCustomPropertyConfig().getConfig() != null) {
return property.getCustomPropertyConfig().getConfig().toString();
Object config = property.getCustomPropertyConfig().getConfig();
if (config instanceof String || config instanceof Integer) {
return config.toString(); // for simple type config return as string
} else {
return JsonUtils.pojoToJson(
config); // for complex object in config return as JSON string
}
}
}
}

View File

@ -67,6 +67,7 @@ import static org.openmetadata.service.util.EntityUtil.objectMatch;
import static org.openmetadata.service.util.EntityUtil.tagLabelMatch;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.cache.CacheBuilder;
@ -100,6 +101,7 @@ import java.util.concurrent.TimeUnit;
import java.util.function.BiPredicate;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
import javax.json.JsonPatch;
import javax.validation.constraints.NotNull;
import javax.ws.rs.core.Response.Status;
@ -145,6 +147,7 @@ import org.openmetadata.schema.type.api.BulkAssets;
import org.openmetadata.schema.type.api.BulkOperationResult;
import org.openmetadata.schema.type.api.BulkResponse;
import org.openmetadata.schema.type.csv.CsvImportResult;
import org.openmetadata.schema.type.customproperties.EnumWithDescriptionsConfig;
import org.openmetadata.schema.utils.EntityInterfaceUtil;
import org.openmetadata.service.Entity;
import org.openmetadata.service.OpenMetadataApplicationConfig;
@ -1454,34 +1457,20 @@ public abstract class EntityRepository<T extends EntityInterface> {
}
String customPropertyType = TypeRegistry.getCustomPropertyType(entityType, fieldName);
String propertyConfig = TypeRegistry.getCustomPropertyConfig(entityType, fieldName);
DateTimeFormatter formatter = null;
try {
if ("date-cp".equals(customPropertyType)) {
DateTimeFormatter inputFormatter =
DateTimeFormatter.ofPattern(Objects.requireNonNull(propertyConfig), Locale.ENGLISH);
// Parse the input string into a TemporalAccessor
TemporalAccessor date = inputFormatter.parse(fieldValue.textValue());
// Create a formatter for the desired output format
DateTimeFormatter outputFormatter =
DateTimeFormatter.ofPattern(propertyConfig, Locale.ENGLISH);
((ObjectNode) jsonNode).put(fieldName, outputFormatter.format(date));
} else if ("dateTime-cp".equals(customPropertyType)) {
formatter = DateTimeFormatter.ofPattern(Objects.requireNonNull(propertyConfig));
LocalDateTime dateTime = LocalDateTime.parse(fieldValue.textValue(), formatter);
((ObjectNode) jsonNode).put(fieldName, dateTime.format(formatter));
} else if ("time-cp".equals(customPropertyType)) {
formatter = DateTimeFormatter.ofPattern(Objects.requireNonNull(propertyConfig));
LocalTime time = LocalTime.parse(fieldValue.textValue(), formatter);
((ObjectNode) jsonNode).put(fieldName, time.format(formatter));
}
validateAndUpdateExtensionBasedOnPropertyType(
entity,
(ObjectNode) jsonNode,
fieldName,
fieldValue,
customPropertyType,
propertyConfig);
} catch (DateTimeParseException e) {
throw new IllegalArgumentException(
CatalogExceptionMessage.dateTimeValidationError(
fieldName, TypeRegistry.getCustomPropertyConfig(entityType, fieldName)));
CatalogExceptionMessage.dateTimeValidationError(fieldName, propertyConfig));
}
Set<ValidationMessage> validationMessages = jsonSchema.validate(fieldValue);
Set<ValidationMessage> validationMessages = jsonSchema.validate(entry.getValue());
if (!validationMessages.isEmpty()) {
throw new IllegalArgumentException(
CatalogExceptionMessage.jsonValidationError(fieldName, validationMessages.toString()));
@ -1489,6 +1478,94 @@ public abstract class EntityRepository<T extends EntityInterface> {
}
}
private void validateAndUpdateExtensionBasedOnPropertyType(
T entity,
ObjectNode jsonNode,
String fieldName,
JsonNode fieldValue,
String customPropertyType,
String propertyConfig) {
switch (customPropertyType) {
case "date-cp", "dateTime-cp", "time-cp" -> {
String formattedValue =
getFormattedDateTimeField(
fieldValue.textValue(), customPropertyType, propertyConfig, fieldName);
jsonNode.put(fieldName, formattedValue);
}
case "enumWithDescriptions" -> handleEnumWithDescriptions(
fieldName, fieldValue, propertyConfig, jsonNode, entity);
default -> {}
}
}
private String getFormattedDateTimeField(
String fieldValue, String customPropertyType, String propertyConfig, String fieldName) {
DateTimeFormatter formatter;
try {
return switch (customPropertyType) {
case "date-cp" -> {
DateTimeFormatter inputFormatter =
DateTimeFormatter.ofPattern(propertyConfig, Locale.ENGLISH);
TemporalAccessor date = inputFormatter.parse(fieldValue);
DateTimeFormatter outputFormatter =
DateTimeFormatter.ofPattern(propertyConfig, Locale.ENGLISH);
yield outputFormatter.format(date);
}
case "dateTime-cp" -> {
formatter = DateTimeFormatter.ofPattern(propertyConfig);
LocalDateTime dateTime = LocalDateTime.parse(fieldValue, formatter);
yield dateTime.format(formatter);
}
case "time-cp" -> {
formatter = DateTimeFormatter.ofPattern(propertyConfig);
LocalTime time = LocalTime.parse(fieldValue, formatter);
yield time.format(formatter);
}
default -> throw new IllegalArgumentException(
"Unsupported customPropertyType: " + customPropertyType);
};
} catch (DateTimeParseException e) {
throw new IllegalArgumentException(
CatalogExceptionMessage.dateTimeValidationError(fieldName, propertyConfig));
}
}
private void handleEnumWithDescriptions(
String fieldName, JsonNode fieldValue, String propertyConfig, ObjectNode jsonNode, T entity) {
JsonNode propertyConfigNode = JsonUtils.readTree(propertyConfig);
EnumWithDescriptionsConfig config =
JsonUtils.treeToValue(propertyConfigNode, EnumWithDescriptionsConfig.class);
if (!config.getMultiSelect() && fieldValue.size() > 1) {
throw new IllegalArgumentException(
"Only one key is allowed for non-multiSelect enumWithDescriptions");
}
// Replace each enumWithDescriptions key in the fieldValue with the corresponding object from
// the propertyConfig
Map<String, JsonNode> keyToObjectMap =
StreamSupport.stream(propertyConfigNode.get("values").spliterator(), false)
.collect(Collectors.toMap(node -> node.get("key").asText(), node -> node));
if (fieldValue.isArray()) {
ArrayNode newArray = JsonUtils.getObjectNode().arrayNode();
fieldValue.forEach(
valueNode -> {
String key = valueNode.isTextual() ? valueNode.asText() : valueNode.get("key").asText();
JsonNode valueObject = keyToObjectMap.get(key);
if (valueObject == null) {
throw new IllegalArgumentException("Key not found in propertyConfig: " + key);
}
newArray.add(valueNode.isTextual() ? valueObject : valueNode);
});
jsonNode.replace(fieldName, newArray);
entity.setExtension(JsonUtils.treeToValue(jsonNode, Object.class));
}
}
public final void storeExtension(EntityInterface entity) {
JsonNode jsonNode = JsonUtils.valueToTree(entity.getExtension());
Iterator<Entry<String, JsonNode>> customFields = jsonNode.fields();

View File

@ -28,6 +28,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.ws.rs.core.UriInfo;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.tuple.Triple;
@ -40,6 +41,8 @@ import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.Include;
import org.openmetadata.schema.type.Relationship;
import org.openmetadata.schema.type.customproperties.EnumConfig;
import org.openmetadata.schema.type.customproperties.EnumWithDescriptionsConfig;
import org.openmetadata.schema.type.customproperties.Value;
import org.openmetadata.service.Entity;
import org.openmetadata.service.TypeRegistry;
import org.openmetadata.service.resources.types.TypeResource;
@ -169,24 +172,9 @@ public class TypeRepository extends EntityRepository<Type> {
private void validateProperty(CustomProperty customProperty) {
switch (customProperty.getPropertyType().getName()) {
case "enum" -> {
CustomPropertyConfig config = customProperty.getCustomPropertyConfig();
if (config != null) {
EnumConfig enumConfig = JsonUtils.convertValue(config.getConfig(), EnumConfig.class);
if (enumConfig == null
|| (enumConfig.getValues() != null && enumConfig.getValues().isEmpty())) {
throw new IllegalArgumentException(
"Enum Custom Property Type must have EnumConfig populated with values.");
} else if (enumConfig.getValues() != null
&& enumConfig.getValues().stream().distinct().count()
!= enumConfig.getValues().size()) {
throw new IllegalArgumentException(
"Enum Custom Property values cannot have duplicates.");
}
} else {
throw new IllegalArgumentException("Enum Custom Property Type must have EnumConfig.");
}
}
case "enum" -> validateEnumConfig(customProperty.getCustomPropertyConfig());
case "enumWithDescriptions" -> validateEnumWithDescriptionsConfig(
customProperty.getCustomPropertyConfig());
case "date-cp" -> validateDateFormat(
customProperty.getCustomPropertyConfig(), getDateTokens(), "Invalid date format");
case "dateTime-cp" -> validateDateFormat(
@ -229,6 +217,44 @@ public class TypeRepository extends EntityRepository<Type> {
return Set.of('H', 'h', 'm', 's', 'a', 'S');
}
private void validateEnumConfig(CustomPropertyConfig config) {
if (config != null) {
EnumConfig enumConfig = JsonUtils.convertValue(config.getConfig(), EnumConfig.class);
if (enumConfig == null
|| (enumConfig.getValues() != null && enumConfig.getValues().isEmpty())) {
throw new IllegalArgumentException(
"Enum Custom Property Type must have EnumConfig populated with values.");
} else if (enumConfig.getValues() != null
&& enumConfig.getValues().stream().distinct().count() != enumConfig.getValues().size()) {
throw new IllegalArgumentException("Enum Custom Property values cannot have duplicates.");
}
} else {
throw new IllegalArgumentException("Enum Custom Property Type must have EnumConfig.");
}
}
private void validateEnumWithDescriptionsConfig(CustomPropertyConfig config) {
if (config != null) {
EnumWithDescriptionsConfig enumWithDescriptionsConfig =
JsonUtils.convertValue(config.getConfig(), EnumWithDescriptionsConfig.class);
if (enumWithDescriptionsConfig == null
|| (enumWithDescriptionsConfig.getValues() != null
&& enumWithDescriptionsConfig.getValues().isEmpty())) {
throw new IllegalArgumentException(
"EnumWithDescriptions Custom Property Type must have customPropertyConfig populated with values.");
}
JsonUtils.validateJsonSchema(config.getConfig(), EnumWithDescriptionsConfig.class);
if (enumWithDescriptionsConfig.getValues().stream().map(Value::getKey).distinct().count()
!= enumWithDescriptionsConfig.getValues().size()) {
throw new IllegalArgumentException(
"EnumWithDescriptions Custom Property key cannot have duplicates.");
}
} else {
throw new IllegalArgumentException(
"EnumWithDescriptions Custom Property Type must have customPropertyConfig.");
}
}
/** Handles entity updated from PUT and POST operation. */
public class TypeUpdater extends EntityUpdater {
public TypeUpdater(Type original, Type updated, Operation operation) {
@ -387,6 +413,27 @@ public class TypeRepository extends EntityRepository<Type> {
throw new IllegalArgumentException(
"Existing Enum Custom Property values cannot be removed.");
}
} else if (origProperty.getPropertyType().getName().equals("enumWithDescriptions")) {
EnumWithDescriptionsConfig origConfig =
JsonUtils.convertValue(
origProperty.getCustomPropertyConfig().getConfig(),
EnumWithDescriptionsConfig.class);
EnumWithDescriptionsConfig updatedConfig =
JsonUtils.convertValue(
updatedProperty.getCustomPropertyConfig().getConfig(),
EnumWithDescriptionsConfig.class);
HashSet<String> updatedValues =
updatedConfig.getValues().stream()
.map(Value::getKey)
.collect(Collectors.toCollection(HashSet::new));
if (updatedValues.size() != updatedConfig.getValues().size()) {
throw new IllegalArgumentException(
"EnumWithDescriptions Custom Property values cannot have duplicates.");
} else if (!updatedValues.containsAll(
origConfig.getValues().stream().map(Value::getKey).collect(Collectors.toSet()))) {
throw new IllegalArgumentException(
"Existing EnumWithDescriptions Custom Property values cannot be removed.");
}
}
}
}

View File

@ -387,6 +387,8 @@ public abstract class EntityResourceTest<T extends EntityInterface, K extends Cr
public static Type ENUM_TYPE;
public static Type ENUM_WITH_DESCRIPTIONS_TYPE;
// Run webhook related tests randomly. This will ensure these tests are not run for every entity
// evey time junit tests are run to save time. But over the course of development of a release,
// when tests are run enough times, the webhook tests are run for all the entities.

View File

@ -45,6 +45,8 @@ import org.openmetadata.schema.type.ChangeDescription;
import org.openmetadata.schema.type.CustomPropertyConfig;
import org.openmetadata.schema.type.EntityReference;
import org.openmetadata.schema.type.customproperties.EnumConfig;
import org.openmetadata.schema.type.customproperties.EnumWithDescriptionsConfig;
import org.openmetadata.schema.type.customproperties.Value;
import org.openmetadata.service.Entity;
import org.openmetadata.service.resources.EntityResourceTest;
import org.openmetadata.service.resources.types.TypeResource;
@ -70,6 +72,7 @@ public class TypeResourceTest extends EntityResourceTest<Type, CreateType> {
INT_TYPE = getEntityByName("integer", "", ADMIN_AUTH_HEADERS);
STRING_TYPE = getEntityByName("string", "", ADMIN_AUTH_HEADERS);
ENUM_TYPE = getEntityByName("enum", "", ADMIN_AUTH_HEADERS);
ENUM_WITH_DESCRIPTIONS_TYPE = getEntityByName("enumWithDescriptions", "", ADMIN_AUTH_HEADERS);
}
@Override
@ -280,6 +283,177 @@ public class TypeResourceTest extends EntityResourceTest<Type, CreateType> {
new ArrayList<>(List.of(fieldA, fieldB)), tableEntity.getCustomProperties());*/
}
@Test
void put_patch_customProperty_enumWithDescriptions_200() throws IOException {
Type databaseEntity = getEntityByName("database", "customProperties", ADMIN_AUTH_HEADERS);
// Add a custom property of type enumWithDescriptions with PUT
CustomProperty enumWithDescriptionsFieldA =
new CustomProperty()
.withName("enumWithDescriptionsTest")
.withDescription("enumWithDescriptionsTest")
.withPropertyType(ENUM_WITH_DESCRIPTIONS_TYPE.getEntityReference());
ChangeDescription change = getChangeDescription(databaseEntity, MINOR_UPDATE);
fieldAdded(change, "customProperties", new ArrayList<>(List.of(enumWithDescriptionsFieldA)));
Type finalDatabaseEntity = databaseEntity;
ChangeDescription finalChange = change;
assertResponseContains(
() ->
addCustomPropertyAndCheck(
finalDatabaseEntity.getId(),
enumWithDescriptionsFieldA,
ADMIN_AUTH_HEADERS,
MINOR_UPDATE,
finalChange),
Status.BAD_REQUEST,
"EnumWithDescriptions Custom Property Type must have customPropertyConfig.");
enumWithDescriptionsFieldA.setCustomPropertyConfig(
new CustomPropertyConfig().withConfig(new EnumWithDescriptionsConfig()));
ChangeDescription change1 = getChangeDescription(databaseEntity, MINOR_UPDATE);
Type databaseEntity1 = databaseEntity;
assertResponseContains(
() ->
addCustomPropertyAndCheck(
databaseEntity1.getId(),
enumWithDescriptionsFieldA,
ADMIN_AUTH_HEADERS,
MINOR_UPDATE,
change1),
Status.BAD_REQUEST,
"EnumWithDescriptions Custom Property Type must have customPropertyConfig populated with values.");
List<Value> valuesWithDuplicateKey =
List.of(
new Value().withKey("A").withDescription("Description A"),
new Value().withKey("B").withDescription("Description B"),
new Value().withKey("C").withDescription("Description C"),
new Value().withKey("C").withDescription("Description C"));
enumWithDescriptionsFieldA.setCustomPropertyConfig(
new CustomPropertyConfig()
.withConfig(new EnumWithDescriptionsConfig().withValues(valuesWithDuplicateKey)));
ChangeDescription change7 = getChangeDescription(databaseEntity, MINOR_UPDATE);
Type databaseEntity2 = databaseEntity;
assertResponseContains(
() ->
addCustomPropertyAndCheck(
databaseEntity2.getId(),
enumWithDescriptionsFieldA,
ADMIN_AUTH_HEADERS,
MINOR_UPDATE,
change7),
Status.BAD_REQUEST,
"EnumWithDescriptions Custom Property key cannot have duplicates.");
List<Value> valuesWithUniqueKey =
List.of(
new Value().withKey("A").withDescription("Description A"),
new Value().withKey("B").withDescription("Description B"),
new Value().withKey("C").withDescription("Description C"));
enumWithDescriptionsFieldA.setCustomPropertyConfig(
new CustomPropertyConfig()
.withConfig(new EnumWithDescriptionsConfig().withValues(valuesWithUniqueKey)));
databaseEntity =
addCustomPropertyAndCheck(
databaseEntity.getId(),
enumWithDescriptionsFieldA,
ADMIN_AUTH_HEADERS,
MINOR_UPDATE,
change);
assertCustomProperties(
new ArrayList<>(List.of(enumWithDescriptionsFieldA)), databaseEntity.getCustomProperties());
CustomPropertyConfig prevConfig = enumWithDescriptionsFieldA.getCustomPropertyConfig();
// Changing custom property description with PUT
enumWithDescriptionsFieldA.withDescription("updatedEnumWithDescriptionsTest");
ChangeDescription change2 = getChangeDescription(databaseEntity, MINOR_UPDATE);
fieldUpdated(
change2,
EntityUtil.getCustomField(enumWithDescriptionsFieldA, "description"),
"enumWithDescriptionsTest",
"updatedEnumWithDescriptionsTest");
databaseEntity =
addCustomPropertyAndCheck(
databaseEntity.getId(),
enumWithDescriptionsFieldA,
ADMIN_AUTH_HEADERS,
MINOR_UPDATE,
change2);
assertCustomProperties(
new ArrayList<>(List.of(enumWithDescriptionsFieldA)), databaseEntity.getCustomProperties());
List<Value> valuesWithUniqueKeyAB =
List.of(
new Value().withKey("A").withDescription("Description A"),
new Value().withKey("B").withDescription("Description B"));
enumWithDescriptionsFieldA.setCustomPropertyConfig(
new CustomPropertyConfig()
.withConfig(new EnumWithDescriptionsConfig().withValues(valuesWithUniqueKeyAB)));
ChangeDescription change3 = getChangeDescription(databaseEntity, MINOR_UPDATE);
assertResponseContains(
() ->
addCustomPropertyAndCheck(
databaseEntity1.getId(),
enumWithDescriptionsFieldA,
ADMIN_AUTH_HEADERS,
MINOR_UPDATE,
change3),
Status.BAD_REQUEST,
"Existing EnumWithDescriptions Custom Property values cannot be removed.");
enumWithDescriptionsFieldA.setCustomPropertyConfig(
new CustomPropertyConfig()
.withConfig(new EnumWithDescriptionsConfig().withValues(valuesWithDuplicateKey)));
ChangeDescription change4 = getChangeDescription(databaseEntity, MINOR_UPDATE);
assertResponseContains(
() ->
addCustomPropertyAndCheck(
databaseEntity1.getId(),
enumWithDescriptionsFieldA,
ADMIN_AUTH_HEADERS,
MINOR_UPDATE,
change4),
Status.BAD_REQUEST,
"EnumWithDescriptions Custom Property key cannot have duplicates.");
valuesWithUniqueKey =
List.of(
new Value().withKey("A").withDescription("Description A"),
new Value().withKey("B").withDescription("Description B"),
new Value().withKey("C").withDescription("Description C"),
new Value().withKey("D").withDescription("Description D"));
ChangeDescription change5 = getChangeDescription(databaseEntity, MINOR_UPDATE);
enumWithDescriptionsFieldA.setCustomPropertyConfig(
new CustomPropertyConfig()
.withConfig(new EnumWithDescriptionsConfig().withValues(valuesWithUniqueKey)));
fieldUpdated(
change5,
EntityUtil.getCustomField(enumWithDescriptionsFieldA, "customPropertyConfig"),
prevConfig,
enumWithDescriptionsFieldA.getCustomPropertyConfig());
databaseEntity =
addCustomPropertyAndCheck(
databaseEntity.getId(),
enumWithDescriptionsFieldA,
ADMIN_AUTH_HEADERS,
MINOR_UPDATE,
change5);
assertCustomProperties(
new ArrayList<>(List.of(enumWithDescriptionsFieldA)), databaseEntity.getCustomProperties());
// Changing custom property description with PATCH
// Changes from this PATCH is consolidated with the previous changes
enumWithDescriptionsFieldA.withDescription("updated2");
String json = JsonUtils.pojoToJson(databaseEntity);
databaseEntity.setCustomProperties(List.of(enumWithDescriptionsFieldA));
change = getChangeDescription(databaseEntity, CHANGE_CONSOLIDATED);
fieldUpdated(
change5,
EntityUtil.getCustomField(enumWithDescriptionsFieldA, "description"),
"updatedEnumWithDescriptionsTest",
"updated2");
databaseEntity =
patchEntityAndCheck(databaseEntity, json, ADMIN_AUTH_HEADERS, CHANGE_CONSOLIDATED, change5);
}
@Test
void put_customPropertyToPropertyType_4xx() {
// Adding a custom property to a property type is not allowed (only entity type is allowed)

View File

@ -105,6 +105,22 @@
"type": "string"
}
},
"enumWithDescriptions": {
"$comment": "@om-field-type",
"description": "List of values in Enum with description for each key.",
"type": "array",
"items": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"description": {
"type": "string"
}
}
}
},
"timezone": {
"description": "Timezone of the user in the format `America/Los_Angeles`, `Brazil/East`, etc.",
"type": "string",

View File

@ -0,0 +1,31 @@
{
"$id": "https://open-metadata.org/schema/type/customPropertyEnumWithDescriptionsConfig.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EnumWithDescriptionsConfig",
"type": "object",
"javaType": "org.openmetadata.schema.type.customproperties.EnumWithDescriptionsConfig",
"description": "Custom property to define enum values with descriptions and extendable fields",
"properties": {
"multiSelect": {
"type": "boolean",
"default": false
},
"values": {
"type": "array",
"items": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": ["key","description"],
"additionalProperties": false
}
}
},
"additionalProperties": false
}

View File

@ -33,6 +33,9 @@
},
{
"$ref": "#/definitions/entityTypes"
},
{
"$ref": "../type/customProperties/enumWithDescriptionsConfig.json"
}
]
}

View File

@ -47,6 +47,19 @@ export const CUSTOM_PROPERTIES_ENTITIES = {
values: ['enum1', 'enum2', 'enum3'],
multiSelect: false,
},
enumWithDescriptionConfig: {
values: [
{
key: 'enumWithDescription1',
description: 'This is enumWithDescription1',
},
{
key: 'enumWithDescription2',
description: 'This is enumWithDescription2',
},
],
multiSelect: false,
},
dateFormatConfig: 'yyyy-MM-dd',
dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss',
timeFormatConfig: 'HH:mm:ss',
@ -65,6 +78,19 @@ export const CUSTOM_PROPERTIES_ENTITIES = {
values: ['enum1', 'enum2', 'enum3'],
multiSelect: false,
},
enumWithDescriptionConfig: {
values: [
{
key: 'enumWithDescription1',
description: 'This is enumWithDescription1',
},
{
key: 'enumWithDescription2',
description: 'This is enumWithDescription2',
},
],
multiSelect: false,
},
dateFormatConfig: 'yyyy-MM-dd',
dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss',
timeFormatConfig: 'HH:mm:ss',
@ -83,6 +109,19 @@ export const CUSTOM_PROPERTIES_ENTITIES = {
values: ['enum1', 'enum2', 'enum3'],
multiSelect: false,
},
enumWithDescriptionConfig: {
values: [
{
key: 'enumWithDescription1',
description: 'This is enumWithDescription1',
},
{
key: 'enumWithDescription2',
description: 'This is enumWithDescription2',
},
],
multiSelect: false,
},
dateFormatConfig: 'yyyy-MM-dd',
dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss',
timeFormatConfig: 'HH:mm:ss',
@ -101,6 +140,19 @@ export const CUSTOM_PROPERTIES_ENTITIES = {
values: ['enum1', 'enum2', 'enum3'],
multiSelect: false,
},
enumWithDescriptionConfig: {
values: [
{
key: 'enumWithDescription1',
description: 'This is enumWithDescription1',
},
{
key: 'enumWithDescription2',
description: 'This is enumWithDescription2',
},
],
multiSelect: false,
},
dateFormatConfig: 'yyyy-MM-dd',
dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss',
timeFormatConfig: 'HH:mm:ss',
@ -119,6 +171,19 @@ export const CUSTOM_PROPERTIES_ENTITIES = {
values: ['enum1', 'enum2', 'enum3'],
multiSelect: false,
},
enumWithDescriptionConfig: {
values: [
{
key: 'enumWithDescription1',
description: 'This is enumWithDescription1',
},
{
key: 'enumWithDescription2',
description: 'This is enumWithDescription2',
},
],
multiSelect: false,
},
dateFormatConfig: 'yyyy-MM-dd',
dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss',
timeFormatConfig: 'HH:mm:ss',
@ -137,6 +202,19 @@ export const CUSTOM_PROPERTIES_ENTITIES = {
values: ['enum1', 'enum2', 'enum3'],
multiSelect: false,
},
enumWithDescriptionConfig: {
values: [
{
key: 'enumWithDescription1',
description: 'This is enumWithDescription1',
},
{
key: 'enumWithDescription2',
description: 'This is enumWithDescription2',
},
],
multiSelect: false,
},
dateFormatConfig: 'yyyy-MM-dd',
dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss',
timeFormatConfig: 'HH:mm:ss',
@ -155,6 +233,19 @@ export const CUSTOM_PROPERTIES_ENTITIES = {
values: ['enum1', 'enum2', 'enum3'],
multiSelect: true,
},
enumWithDescriptionConfig: {
values: [
{
key: 'enumWithDescription1',
description: 'This is enumWithDescription1',
},
{
key: 'enumWithDescription2',
description: 'This is enumWithDescription2',
},
],
multiSelect: false,
},
dateFormatConfig: 'yyyy-MM-dd',
dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss',
timeFormatConfig: 'HH:mm:ss',
@ -173,6 +264,19 @@ export const CUSTOM_PROPERTIES_ENTITIES = {
values: ['enum1', 'enum2', 'enum3'],
multiSelect: false,
},
enumWithDescriptionConfig: {
values: [
{
key: 'enumWithDescription1',
description: 'This is enumWithDescription1',
},
{
key: 'enumWithDescription2',
description: 'This is enumWithDescription2',
},
],
multiSelect: false,
},
dateFormatConfig: 'yyyy-MM-dd',
dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss',
timeFormatConfig: 'HH:mm:ss',
@ -191,6 +295,19 @@ export const CUSTOM_PROPERTIES_ENTITIES = {
values: ['enum1', 'enum2', 'enum3'],
multiSelect: false,
},
enumWithDescriptionConfig: {
values: [
{
key: 'enumWithDescription1',
description: 'This is enumWithDescription1',
},
{
key: 'enumWithDescription2',
description: 'This is enumWithDescription2',
},
],
multiSelect: false,
},
dateFormatConfig: 'yyyy-MM-dd',
dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss',
timeFormatConfig: 'HH:mm:ss',
@ -209,6 +326,19 @@ export const CUSTOM_PROPERTIES_ENTITIES = {
values: ['enum1', 'enum2', 'enum3'],
multiSelect: false,
},
enumWithDescriptionConfig: {
values: [
{
key: 'enumWithDescription1',
description: 'This is enumWithDescription1',
},
{
key: 'enumWithDescription2',
description: 'This is enumWithDescription2',
},
],
multiSelect: false,
},
dateFormatConfig: 'yyyy-MM-dd',
dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss',
timeFormatConfig: 'HH:mm:ss',
@ -227,6 +357,19 @@ export const CUSTOM_PROPERTIES_ENTITIES = {
values: ['enum1', 'enum2', 'enum3'],
multiSelect: false,
},
enumWithDescriptionConfig: {
values: [
{
key: 'enumWithDescription1',
description: 'This is enumWithDescription1',
},
{
key: 'enumWithDescription2',
description: 'This is enumWithDescription2',
},
],
multiSelect: false,
},
dateFormatConfig: 'yyyy-MM-dd',
dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss',
timeFormatConfig: 'HH:mm:ss',
@ -244,6 +387,19 @@ export const CUSTOM_PROPERTIES_ENTITIES = {
values: ['enum1', 'enum2', 'enum3'],
multiSelect: false,
},
enumWithDescriptionConfig: {
values: [
{
key: 'enumWithDescription1',
description: 'This is enumWithDescription1',
},
{
key: 'enumWithDescription2',
description: 'This is enumWithDescription2',
},
],
multiSelect: false,
},
dateFormatConfig: 'yyyy-MM-dd',
dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss',
timeFormatConfig: 'HH:mm:ss',
@ -262,6 +418,19 @@ export const CUSTOM_PROPERTIES_ENTITIES = {
values: ['enum1', 'enum2', 'enum3'],
multiSelect: false,
},
enumWithDescriptionConfig: {
values: [
{
key: 'enumWithDescription1',
description: 'This is enumWithDescription1',
},
{
key: 'enumWithDescription2',
description: 'This is enumWithDescription2',
},
],
multiSelect: false,
},
dateFormatConfig: 'yyyy-MM-dd',
dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss',
timeFormatConfig: 'HH:mm:ss',
@ -279,6 +448,19 @@ export const CUSTOM_PROPERTIES_ENTITIES = {
values: ['enum1', 'enum2', 'enum3'],
multiSelect: false,
},
enumWithDescriptionConfig: {
values: [
{
key: 'enumWithDescription1',
description: 'This is enumWithDescription1',
},
{
key: 'enumWithDescription2',
description: 'This is enumWithDescription2',
},
],
multiSelect: false,
},
dateFormatConfig: 'yyyy-MM-dd',
dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss',
timeFormatConfig: 'HH:mm:ss',
@ -296,6 +478,19 @@ export const CUSTOM_PROPERTIES_ENTITIES = {
values: ['enum1', 'enum2', 'enum3'],
multiSelect: false,
},
enumWithDescriptionConfig: {
values: [
{
key: 'enumWithDescription1',
description: 'This is enumWithDescription1',
},
{
key: 'enumWithDescription2',
description: 'This is enumWithDescription2',
},
],
multiSelect: false,
},
dateFormatConfig: 'yyyy-MM-dd',
dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss',
timeFormatConfig: 'HH:mm:ss',

View File

@ -60,12 +60,24 @@ test('CustomProperty Dashboard Filter', async ({ page }) => {
await test.step('Add Custom Property in Dashboard', async () => {
await dashboardEntity.visitEntityPage(page);
const container = page.locator(
`[data-testid="custom-property-${propertyName}-card"]`
);
await page.getByTestId('custom_properties').click();
await page
.getByRole('row', { name: `${propertyName} No data` })
.locator('svg')
.click();
await expect(
page.locator(
`[data-testid="custom-property-${propertyName}-card"] [data-testid="property-name"]`
)
).toHaveText(propertyName);
const editButton = page.locator(
`[data-testid="custom-property-${propertyName}-card"] [data-testid="edit-icon"]`
);
await editButton.scrollIntoViewIfNeeded();
await editButton.click({ force: true });
await page.getByTestId('value-input').fill(propertyValue);
@ -75,9 +87,7 @@ test('CustomProperty Dashboard Filter', async ({ page }) => {
await saveResponse;
expect(
page.getByLabel('Custom Properties').getByTestId('value')
).toContainText(propertyValue);
await expect(container.getByTestId('value')).toContainText(propertyValue);
});
await test.step(

View File

@ -45,13 +45,46 @@ test.describe('Custom properties with custom property config', () => {
enumConfig: entity.enumConfig,
});
await editCreatedProperty(page, propertyName);
await editCreatedProperty(page, propertyName, 'Enum');
await deleteCreatedProperty(page, propertyName);
});
});
});
test.describe(
'Add update and delete Enum With Descriptions custom properties',
() => {
Object.values(CUSTOM_PROPERTIES_ENTITIES).forEach(async (entity) => {
const propertyName = `pwcustomproperty${entity.name}test${uuid()}`;
test(`Add Enum With Descriptions custom property for ${entity.name}`, async ({
page,
}) => {
test.slow(true);
await settingClick(page, entity.entityApiType, true);
await addCustomPropertiesForEntity({
page,
propertyName,
customPropertyData: entity,
customType: 'Enum With Descriptions',
enumWithDescriptionConfig: entity.enumWithDescriptionConfig,
});
await editCreatedProperty(
page,
propertyName,
'Enum With Descriptions'
);
await deleteCreatedProperty(page, propertyName);
});
});
}
);
test.describe(
'Add update and delete Entity Reference custom properties',
() => {
@ -73,7 +106,7 @@ test.describe('Custom properties with custom property config', () => {
entityReferenceConfig: entity.entityReferenceConfig,
});
await editCreatedProperty(page, propertyName);
await editCreatedProperty(page, propertyName, 'Entity Reference');
await deleteCreatedProperty(page, propertyName);
});
@ -102,7 +135,11 @@ test.describe('Custom properties with custom property config', () => {
entityReferenceConfig: entity.entityReferenceConfig,
});
await editCreatedProperty(page, propertyName);
await editCreatedProperty(
page,
propertyName,
'Entity Reference List'
);
await deleteCreatedProperty(page, propertyName);
});

View File

@ -44,6 +44,7 @@ export enum CustomPropertyTypeByName {
TIME_CP = 'time-cp',
DATE_CP = 'date-cp',
DATE_TIME_CP = 'dateTime-cp',
ENUM_WITH_DESCRIPTION = 'enumWithDescriptions',
}
export interface CustomProperty {
@ -66,12 +67,18 @@ export const setValueForProperty = async (data: {
const { page, propertyName, value, propertyType, endpoint } = data;
await page.click('[data-testid="custom_properties"]');
await expect(page.getByRole('cell', { name: propertyName })).toContainText(
propertyName
const container = page.locator(
`[data-testid="custom-property-${propertyName}-card"]`
);
await expect(
page.locator(
`[data-testid="custom-property-${propertyName}-card"] [data-testid="property-name"]`
)
).toHaveText(propertyName);
const editButton = page.locator(
`[data-row-key="${propertyName}"] [data-testid="edit-icon"]`
`[data-testid="custom-property-${propertyName}-card"] [data-testid="edit-icon"]`
);
await editButton.scrollIntoViewIfNeeded();
await editButton.click({ force: true });
@ -96,14 +103,14 @@ export const setValueForProperty = async (data: {
case 'email':
await page.locator('[data-testid="email-input"]').isVisible();
await page.locator('[data-testid="email-input"]').fill(value);
await page.locator('[data-testid="inline-save-btn"]').click();
await container.locator('[data-testid="inline-save-btn"]').click();
break;
case 'duration':
await page.locator('[data-testid="duration-input"]').isVisible();
await page.locator('[data-testid="duration-input"]').fill(value);
await page.locator('[data-testid="inline-save-btn"]').click();
await container.locator('[data-testid="inline-save-btn"]').click();
break;
@ -112,21 +119,30 @@ export const setValueForProperty = async (data: {
await page.fill('#enumValues', value);
await page.press('#enumValues', 'Enter');
await clickOutside(page);
await page.click('[data-testid="inline-save-btn"]');
await container.locator('[data-testid="inline-save-btn"]').click();
break;
case 'enumWithDescriptions':
await page.click('#enumWithDescriptionValues');
await page.fill('#enumWithDescriptionValues', value, { force: true });
await page.press('#enumWithDescriptionValues', 'Enter');
await clickOutside(page);
await container.locator('[data-testid="inline-save-btn"]').click();
break;
case 'sqlQuery':
await page.locator("pre[role='presentation']").last().click();
await page.keyboard.type(value);
await page.locator('[data-testid="inline-save-btn"]').click();
await container.locator('[data-testid="inline-save-btn"]').click();
break;
case 'timestamp':
await page.locator('[data-testid="timestamp-input"]').isVisible();
await page.locator('[data-testid="timestamp-input"]').fill(value);
await page.locator('[data-testid="inline-save-btn"]').click();
await container.locator('[data-testid="inline-save-btn"]').click();
break;
@ -136,7 +152,7 @@ export const setValueForProperty = async (data: {
await page.locator('[data-testid="start-input"]').fill(startValue);
await page.locator('[data-testid="end-input"]').isVisible();
await page.locator('[data-testid="end-input"]').fill(endValue);
await page.locator('[data-testid="inline-save-btn"]').click();
await container.locator('[data-testid="inline-save-btn"]').click();
break;
}
@ -146,7 +162,7 @@ export const setValueForProperty = async (data: {
await page.locator('[data-testid="time-picker"]').click();
await page.locator('[data-testid="time-picker"]').fill(value);
await page.getByRole('button', { name: 'OK', exact: true }).click();
await page.locator('[data-testid="inline-save-btn"]').click();
await container.locator('[data-testid="inline-save-btn"]').click();
break;
}
@ -161,7 +177,7 @@ export const setValueForProperty = async (data: {
} else {
await page.getByText('Today', { exact: true }).click();
}
await page.locator('[data-testid="inline-save-btn"]').click();
await container.locator('[data-testid="inline-save-btn"]').click();
break;
}
@ -171,7 +187,7 @@ export const setValueForProperty = async (data: {
case 'number':
await page.locator('[data-testid="value-input"]').isVisible();
await page.locator('[data-testid="value-input"]').fill(value);
await page.locator('[data-testid="inline-save-btn"]').click();
await container.locator('[data-testid="inline-save-btn"]').click();
break;
@ -191,7 +207,9 @@ export const setValueForProperty = async (data: {
await page.locator(`[data-testid="${val}"]`).click();
}
await page.locator('[data-testid="inline-save-btn"]').click();
await clickOutside(page);
await container.locator('[data-testid="inline-save-btn"]').click();
break;
}
@ -208,23 +226,43 @@ export const validateValueForProperty = async (data: {
const { page, propertyName, value, propertyType } = data;
await page.click('[data-testid="custom_properties"]');
const container = page.locator(
`[data-testid="custom-property-${propertyName}-card"]`
);
const toggleBtnVisibility = await container
.locator(`[data-testid="toggle-${propertyName}"]`)
.isVisible();
if (toggleBtnVisibility) {
await container.locator(`[data-testid="toggle-${propertyName}"]`).click();
}
if (propertyType === 'enum') {
await expect(
page.getByLabel('Custom Properties').getByTestId('enum-value')
).toContainText(value);
await expect(container.getByTestId('enum-value')).toContainText(value);
} else if (propertyType === 'timeInterval') {
const [startValue, endValue] = value.split(',');
await expect(
page.getByLabel('Custom Properties').getByTestId('time-interval-value')
).toContainText(startValue);
await expect(
page.getByLabel('Custom Properties').getByTestId('time-interval-value')
).toContainText(endValue);
await expect(container.getByTestId('time-interval-value')).toContainText(
startValue
);
await expect(container.getByTestId('time-interval-value')).toContainText(
endValue
);
} else if (propertyType === 'sqlQuery') {
await expect(container.locator('.CodeMirror-scroll')).toContainText(value);
} else if (propertyType === 'enumWithDescriptions') {
await expect(
page.getByLabel('Custom Properties').locator('.CodeMirror-scroll')
).toContainText(value);
container.locator('[data-testid="enum-with-description-table"]')
).toBeVisible();
await expect(
container
.locator('[data-testid="enum-with-description-table"]')
.getByText(value, {
exact: true,
})
).toBeVisible();
} else if (
![
'entityReference',
@ -233,9 +271,7 @@ export const validateValueForProperty = async (data: {
'dateTime-cp',
].includes(propertyType)
) {
await expect(page.getByRole('row', { name: propertyName })).toContainText(
value.replace(/\*|_/gi, '')
);
await expect(container).toContainText(value.replace(/\*|_/gi, ''));
}
};
@ -280,6 +316,11 @@ export const getPropertyValues = (
value: 'small',
newValue: 'medium',
};
case 'enumWithDescriptions':
return {
value: 'enumWithDescription1',
newValue: 'enumWithDescription2',
};
case 'sqlQuery':
return {
value: 'Select * from table',
@ -403,6 +444,25 @@ export const createCustomPropertyForEntity = async (
},
}
: {}),
...(item.name === 'enumWithDescriptions'
? {
customPropertyConfig: {
config: {
multiSelect: true,
values: [
{
key: 'enumWithDescription1',
description: 'This is enumWithDescription1',
},
{
key: 'enumWithDescription2',
description: 'This is enumWithDescription2',
},
],
},
},
}
: {}),
...(['entityReference', 'entityReferenceList'].includes(item.name)
? {
customPropertyConfig: {
@ -465,12 +525,17 @@ export const addCustomPropertiesForEntity = async ({
enumConfig,
formatConfig,
entityReferenceConfig,
enumWithDescriptionConfig,
}: {
page: Page;
propertyName: string;
customPropertyData: { description: string };
customType: string;
enumConfig?: { values: string[]; multiSelect: boolean };
enumWithDescriptionConfig?: {
values: { key: string; description: string }[];
multiSelect: boolean;
};
formatConfig?: string;
entityReferenceConfig?: string[];
}) => {
@ -550,6 +615,27 @@ export const addCustomPropertiesForEntity = async ({
await page.click('#root\\/multiSelect');
}
}
// Enum With Description configuration
if (customType === 'Enum With Descriptions' && enumWithDescriptionConfig) {
for await (const [
index,
val,
] of enumWithDescriptionConfig.values.entries()) {
await page.locator('[data-testid="add-enum-description-config"]').click();
await page.locator(`#key-${index}`).fill(val.key);
await page.locator(descriptionBox).nth(index).fill(val.description);
}
await clickOutside(page);
if (enumWithDescriptionConfig.multiSelect) {
await page.click('#root\\/multiSelect');
}
await page
.locator(descriptionBox)
.nth(2)
.fill(customPropertyData.description);
}
// Entity reference configuration
if (
@ -577,7 +663,9 @@ export const addCustomPropertiesForEntity = async ({
}
// Description
await page.fill(descriptionBox, customPropertyData.description);
if (customType !== 'Enum With Descriptions') {
await page.fill(descriptionBox, customPropertyData.description);
}
const createPropertyPromise = page.waitForResponse(
'/api/v1/metadata/types/name/*?fields=customProperties'
@ -612,10 +700,22 @@ export const editCreatedProperty = async (
).toContainText('["enum1","enum2","enum3"]');
}
if (type === 'Enum With Descriptions') {
await expect(
page
.getByRole('row', {
name: `${propertyName} enumWithDescriptions enumWithDescription1`,
})
.getByTestId('enum-with-description-config')
).toBeVisible();
}
await editButton.click();
await page.locator(descriptionBox).fill('');
await page.locator(descriptionBox).fill('This is new description');
if (type !== 'Enum With Descriptions') {
await page.locator(descriptionBox).fill('');
await page.locator(descriptionBox).fill('This is new description');
}
if (type === 'Enum') {
await page.click('#root\\/customPropertyConfig');
@ -623,6 +723,10 @@ export const editCreatedProperty = async (
await page.press('#root\\/customPropertyConfig', 'Enter');
await clickOutside(page);
}
if (type === 'Enum With Descriptions') {
await page.locator(descriptionBox).nth(0).fill('');
await page.locator(descriptionBox).nth(0).fill('This is new description');
}
if (ENTITY_REFERENCE_PROPERTIES.includes(type ?? '')) {
await page.click('#root\\/customPropertyConfig');
@ -655,6 +759,17 @@ export const editCreatedProperty = async (
)
).toContainText('["enum1","enum2","enum3","updatedValue"]');
}
if (type === 'Enum With Descriptions') {
await expect(
page
.getByRole('row', {
name: `${propertyName} enumWithDescriptions enumWithDescription1`,
})
.getByTestId('enum-with-description-config')
).toBeVisible();
}
if (ENTITY_REFERENCE_PROPERTIES.includes(type ?? '')) {
await expect(
page.locator(

View File

@ -11,7 +11,8 @@
* limitations under the License.
*/
import { Button, Col, Form, Row } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { Button, Col, Form, Input, Row } from 'antd';
import { AxiosError } from 'axios';
import { t } from 'i18next';
import { isUndefined, map, omit, omitBy, startCase } from 'lodash';
@ -23,8 +24,10 @@ import React, {
useState,
} from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { ReactComponent as DeleteIcon } from '../../../../assets/svg/ic-delete.svg';
import {
ENTITY_REFERENCE_OPTIONS,
ENUM_WITH_DESCRIPTION,
PROPERTY_TYPES_WITH_ENTITY_REFERENCE,
PROPERTY_TYPES_WITH_FORMAT,
SUPPORTED_FORMAT_MAP,
@ -38,6 +41,7 @@ import {
import { EntityType } from '../../../../enums/entity.enum';
import { ServiceCategory } from '../../../../enums/service.enum';
import { Category, Type } from '../../../../generated/entity/type';
import { EnumWithDescriptionsConfig } from '../../../../generated/type/customProperties/enumWithDescriptionsConfig';
import { CustomProperty } from '../../../../generated/type/customProperty';
import {
FieldProp,
@ -54,6 +58,7 @@ import { getSettingOptionByEntityType } from '../../../../utils/GlobalSettingsUt
import { getSettingPath } from '../../../../utils/RouterUtils';
import { showErrorToast } from '../../../../utils/ToastUtils';
import ResizablePanels from '../../../common/ResizablePanels/ResizablePanels';
import RichTextEditor from '../../../common/RichTextEditor/RichTextEditor';
import ServiceDocPanel from '../../../common/ServiceDocPanel/ServiceDocPanel';
import TitleBreadcrumb from '../../../common/TitleBreadcrumb/TitleBreadcrumb.component';
@ -107,6 +112,7 @@ const AddCustomProperty = () => {
hasFormatConfig,
hasEntityReferenceConfig,
watchedOption,
hasEnumWithDescriptionConfig,
} = useMemo(() => {
const watchedOption = propertyTypeOptions.find(
(option) => option.value === watchedPropertyType
@ -115,6 +121,9 @@ const AddCustomProperty = () => {
const hasEnumConfig = watchedOptionKey === 'enum';
const hasEnumWithDescriptionConfig =
watchedOptionKey === ENUM_WITH_DESCRIPTION;
const hasFormatConfig =
PROPERTY_TYPES_WITH_FORMAT.includes(watchedOptionKey);
@ -126,6 +135,7 @@ const AddCustomProperty = () => {
hasFormatConfig,
hasEntityReferenceConfig,
watchedOption,
hasEnumWithDescriptionConfig,
};
}, [watchedPropertyType, propertyTypeOptions]);
@ -166,6 +176,7 @@ const AddCustomProperty = () => {
formatConfig: string;
entityReferenceConfig: string[];
multiSelect?: boolean;
enumWithDescriptionsConfig?: EnumWithDescriptionsConfig['values'];
}
) => {
if (isUndefined(typeDetail)) {
@ -197,6 +208,15 @@ const AddCustomProperty = () => {
};
}
if (hasEnumWithDescriptionConfig) {
customPropertyConfig = {
config: {
multiSelect: Boolean(data?.multiSelect),
values: data.enumWithDescriptionsConfig,
},
};
}
const payload = omitBy(
{
...omit(data, [
@ -204,6 +224,7 @@ const AddCustomProperty = () => {
'formatConfig',
'entityReferenceConfig',
'enumConfig',
'enumWithDescriptionsConfig',
]),
propertyType: {
id: data.propertyType,
@ -393,6 +414,95 @@ const AddCustomProperty = () => {
hasEntityReferenceConfig &&
generateFormFields([entityReferenceConfigField])
}
{hasEnumWithDescriptionConfig && (
<>
<Form.List name="enumWithDescriptionsConfig">
{(fields, { add, remove }) => (
<>
<Form.Item
className="form-item-horizontal"
colon={false}
label={t('label.property')}>
<Button
data-testid="add-enum-description-config"
icon={
<PlusOutlined
style={{ color: 'white', fontSize: '12px' }}
/>
}
size="small"
type="primary"
onClick={() => {
add();
}}
/>
</Form.Item>
{fields.map((field, index) => (
<Row gutter={[8, 0]} key={field.key}>
<Col span={23}>
<Row gutter={[8, 0]}>
<Col span={24}>
<Form.Item
name={[field.name, 'key']}
rules={[
{
required: true,
message: `${t(
'message.field-text-is-required',
{
fieldText: t('label.key'),
}
)}`,
},
]}>
<Input
id={`key-${index}`}
placeholder={t('label.key')}
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
name={[field.name, 'description']}
rules={[
{
required: true,
message: `${t(
'message.field-text-is-required',
{
fieldText: t('label.description'),
}
)}`,
},
]}
trigger="onTextChange"
valuePropName="initialValue">
<RichTextEditor height="200px" />
</Form.Item>
</Col>
</Row>
</Col>
<Col span={1}>
<Button
data-testid={`remove-enum-description-config-${index}`}
icon={<DeleteIcon width={16} />}
size="small"
type="text"
onClick={() => {
remove(field.name);
}}
/>
</Col>
</Row>
))}
</>
)}
</Form.List>
{generateFormFields([multiSelectField])}
</>
)}
{generateFormFields([descriptionField])}
<Row justify="end">
<Col>

View File

@ -10,17 +10,19 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Button, Space, Tooltip, Typography } from 'antd';
import { Button, Space, Tag, Tooltip, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { isArray, isEmpty, isString, isUndefined } from 'lodash';
import React, { FC, Fragment, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as IconEdit } from '../../../assets/svg/edit-new.svg';
import { ReactComponent as IconDelete } from '../../../assets/svg/ic-delete.svg';
import { ENUM_WITH_DESCRIPTION } from '../../../constants/CustomProperty.constants';
import { ADD_CUSTOM_PROPERTIES_DOCS } from '../../../constants/docs.constants';
import { NO_PERMISSION_FOR_ACTION } from '../../../constants/HelperTextUtil';
import { TABLE_SCROLL_VALUE } from '../../../constants/Table.constants';
import { ERROR_PLACEHOLDER_TYPE, OPERATION } from '../../../enums/common.enum';
import { EnumWithDescriptionsConfig } from '../../../generated/type/customProperties/enumWithDescriptionsConfig';
import { CustomProperty } from '../../../generated/type/customProperty';
import { columnSorter, getEntityName } from '../../../utils/EntityUtils';
import ErrorPlaceHolder from '../../common/ErrorWithPlaceholder/ErrorPlaceHolder';
@ -68,7 +70,9 @@ export const CustomPropertyTable: FC<CustomPropertyTableProp> = ({
const updatedProperties = customProperties.map((property) => {
if (property.name === selectedProperty.name) {
const config = data.customPropertyConfig;
const isEnumType = selectedProperty.propertyType.name === 'enum';
const isEnumType =
selectedProperty.propertyType.name === 'enum' ||
selectedProperty.propertyType.name === ENUM_WITH_DESCRIPTION;
return {
...property,
@ -81,7 +85,7 @@ export const CustomPropertyTable: FC<CustomPropertyTableProp> = ({
multiSelect: Boolean(data?.multiSelect),
values: config,
}
: config,
: (config as string[]),
},
}
: {}),
@ -116,7 +120,7 @@ export const CustomPropertyTable: FC<CustomPropertyTableProp> = ({
title: t('label.type'),
dataIndex: 'propertyType',
key: 'propertyType',
render: (text) => getEntityName(text),
render: (propertyType) => getEntityName(propertyType),
},
{
title: t('label.config'),
@ -140,6 +144,41 @@ export const CustomPropertyTable: FC<CustomPropertyTableProp> = ({
// If config is an object, then it is a enum config
if (!isString(config) && !isArray(config)) {
if (record.propertyType.name === ENUM_WITH_DESCRIPTION) {
const values =
(config?.values as EnumWithDescriptionsConfig['values']) ?? [];
return (
<div
className="w-full d-flex gap-2 flex-column"
data-testid="enum-with-description-config">
<div className="w-full d-flex gap-2 flex-column">
{values.map((value) => (
<Tooltip
key={value.key}
title={value.description}
trigger="hover">
<Tag
style={{
width: 'max-content',
margin: '0px',
border: 'none',
padding: '4px',
background: 'rgba(0, 0, 0, 0.03)',
}}>
{value.key}
</Tag>
</Tooltip>
))}
</div>
<Typography.Text>
{t('label.multi-select')}:{' '}
{config?.multiSelect ? t('label.yes') : t('label.no')}
</Typography.Text>
</div>
);
}
return (
<div
className="w-full d-flex gap-2 flex-column"

View File

@ -10,17 +10,30 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Form, Modal, Typography } from 'antd';
import { isUndefined, uniq } from 'lodash';
import { InfoCircleOutlined, PlusOutlined } from '@ant-design/icons';
import {
Button,
Col,
Form,
Input,
Modal,
Row,
Tooltip,
Typography,
} from 'antd';
import { get, isUndefined, uniq } from 'lodash';
import React, { FC, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ReactComponent as DeleteIcon } from '../../../../assets/svg/ic-delete.svg';
import {
ENTITY_REFERENCE_OPTIONS,
ENUM_WITH_DESCRIPTION,
PROPERTY_TYPES_WITH_ENTITY_REFERENCE,
} from '../../../../constants/CustomProperty.constants';
import {
CustomProperty,
EnumConfig,
ValueClass,
} from '../../../../generated/type/customProperty';
import {
FieldProp,
@ -28,10 +41,11 @@ import {
FormItemLayout,
} from '../../../../interface/FormUtils.interface';
import { generateFormFields } from '../../../../utils/formUtils';
import RichTextEditor from '../../../common/RichTextEditor/RichTextEditor';
export interface FormData {
description: string;
customPropertyConfig: string[];
customPropertyConfig: string[] | ValueClass[];
multiSelect?: boolean;
}
@ -58,15 +72,21 @@ const EditCustomPropertyModal: FC<EditCustomPropertyModalProps> = ({
setIsSaving(false);
};
const { hasEnumConfig, hasEntityReferenceConfig } = useMemo(() => {
const {
hasEnumConfig,
hasEntityReferenceConfig,
hasEnumWithDescriptionConfig,
} = useMemo(() => {
const propertyName = customProperty.propertyType.name ?? '';
const hasEnumConfig = propertyName === 'enum';
const hasEnumWithDescriptionConfig = propertyName === ENUM_WITH_DESCRIPTION;
const hasEntityReferenceConfig =
PROPERTY_TYPES_WITH_ENTITY_REFERENCE.includes(propertyName);
return {
hasEnumConfig,
hasEntityReferenceConfig,
hasEnumWithDescriptionConfig,
};
}, [customProperty]);
@ -155,7 +175,7 @@ const EditCustomPropertyModal: FC<EditCustomPropertyModalProps> = ({
};
const initialValues = useMemo(() => {
if (hasEnumConfig) {
if (hasEnumConfig || hasEnumWithDescriptionConfig) {
const enumConfig = customProperty.customPropertyConfig
?.config as EnumConfig;
@ -170,7 +190,7 @@ const EditCustomPropertyModal: FC<EditCustomPropertyModalProps> = ({
description: customProperty.description,
customPropertyConfig: customProperty.customPropertyConfig?.config,
};
}, [customProperty, hasEnumConfig]);
}, [customProperty, hasEnumConfig, hasEnumWithDescriptionConfig]);
const note = (
<Typography.Text
@ -205,7 +225,7 @@ const EditCustomPropertyModal: FC<EditCustomPropertyModalProps> = ({
})}
</Typography.Text>
}
width={750}
width={800}
onCancel={onCancel}>
<Form
form={form}
@ -224,6 +244,125 @@ const EditCustomPropertyModal: FC<EditCustomPropertyModalProps> = ({
</>
)}
{hasEnumWithDescriptionConfig && (
<>
<Form.List name="customPropertyConfig">
{(fields, { add, remove }) => {
const config =
(initialValues?.customPropertyConfig as ValueClass[]) ??
[];
return (
<>
<Form.Item
className="form-item-horizontal"
colon={false}
label={
<div className="d-flex gap-2 items-center">
<span>{t('label.property')}</span>
<Tooltip
title={t(
'message.enum-with-description-update-note'
)}>
<InfoCircleOutlined
className="m-x-xss"
style={{ color: '#C4C4C4' }}
/>
</Tooltip>
</div>
}>
<Button
data-testid="add-enum-description-config"
icon={
<PlusOutlined
style={{ color: 'white', fontSize: '12px' }}
/>
}
size="small"
type="primary"
onClick={() => {
add();
}}
/>
</Form.Item>
{fields.map((field, index) => {
const isExisting = Boolean(get(config, index, false));
return (
<Row
className="m-t-md"
gutter={[8, 0]}
key={field.key}>
<Col span={23}>
<Row gutter={[8, 0]}>
<Col span={24}>
<Form.Item
name={[field.name, 'key']}
rules={[
{
required: true,
message: `${t(
'message.field-text-is-required',
{
fieldText: t('label.key'),
}
)}`,
},
]}>
<Input
disabled={isExisting}
id={`key-${index}`}
placeholder={t('label.key')}
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
name={[field.name, 'description']}
rules={[
{
required: true,
message: `${t(
'message.field-text-is-required',
{
fieldText: t('label.description'),
}
)}`,
},
]}
trigger="onTextChange"
valuePropName="initialValue">
<RichTextEditor height="200px" />
</Form.Item>
</Col>
</Row>
</Col>
{!isExisting && (
<Col span={1}>
<Button
data-testid={`remove-enum-description-config-${index}`}
icon={<DeleteIcon width={16} />}
size="small"
type="text"
onClick={() => {
remove(field.name);
}}
/>
</Col>
)}
</Row>
);
})}
</>
);
}}
</Form.List>
{generateFormFields([multiSelectField])}
</>
)}
{hasEntityReferenceConfig && (
<>
{generateFormFields([entityReferenceConfigField])}

View File

@ -153,17 +153,13 @@ describe('Test CustomProperty Table Component', () => {
<CustomPropertyTable {...mockProp} entityType={EntityType.TABLE} />
);
});
const table = await screen.findByTestId('custom-properties-table');
const table = await screen.findByTestId('custom-properties-card');
expect(table).toBeInTheDocument();
const propertyName = await screen.findByText('label.name');
const propertyValue = await screen.findByText('label.value');
const rows = await screen.findAllByRole('row');
const propertyValue = await screen.findByText('PropertyValue');
expect(propertyName).toBeInTheDocument();
expect(propertyValue).toBeInTheDocument();
expect(rows).toHaveLength(mockCustomProperties.length + 1);
});
it('Should render no data placeholder if custom properties list is empty', async () => {
@ -222,10 +218,9 @@ describe('Test CustomProperty Table Component', () => {
<CustomPropertyTable {...mockProp} entityType={EntityType.TABLE} />
);
});
const tableRowTitle = await screen.findByText('xName');
const tableRowValue = await screen.findByText('PropertyValue');
expect(tableRowTitle).toBeInTheDocument();
expect(tableRowValue).toBeInTheDocument();
});
});

View File

@ -11,11 +11,16 @@
* limitations under the License.
*/
import { Skeleton, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { Col, Divider, Row, Skeleton, Typography } from 'antd';
import { AxiosError } from 'axios';
import { isEmpty, isUndefined } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, {
Fragment,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { CUSTOM_PROPERTIES_DOCS } from '../../../constants/docs.constants';
@ -28,11 +33,9 @@ import {
import { ERROR_PLACEHOLDER_TYPE } from '../../../enums/common.enum';
import { EntityTabs, EntityType } from '../../../enums/entity.enum';
import { ChangeDescription, Type } from '../../../generated/entity/type';
import { CustomProperty } from '../../../generated/type/customProperty';
import { getTypeByFQN } from '../../../rest/metadataTypeAPI';
import { Transi18next } from '../../../utils/CommonUtils';
import entityUtilClassBase from '../../../utils/EntityUtilClassBase';
import { columnSorter, getEntityName } from '../../../utils/EntityUtils';
import {
getChangedEntityNewValue,
getDiffByFieldName,
@ -40,7 +43,6 @@ import {
} from '../../../utils/EntityVersionUtils';
import { showErrorToast } from '../../../utils/ToastUtils';
import ErrorPlaceHolder from '../ErrorWithPlaceholder/ErrorPlaceHolder';
import Table from '../Table/Table';
import {
CustomPropertyProps,
ExtentionEntities,
@ -146,44 +148,6 @@ export const CustomPropertyTable = <T extends ExtentionEntitiesKeys>({
return { extensionObject: entityDetails?.extension };
}, [isVersionView, entityDetails?.extension]);
const tableColumn: ColumnsType<CustomProperty> = useMemo(() => {
return [
{
title: t('label.name'),
dataIndex: 'name',
key: 'name',
ellipsis: true,
width: isRenderedInRightPanel ? 150 : 400,
render: (_, record) => getEntityName(record),
sorter: columnSorter,
},
{
title: t('label.value'),
dataIndex: 'value',
key: 'value',
render: (_, record) => (
<PropertyValue
extension={extensionObject.extensionObject}
hasEditPermissions={hasEditAccess}
isRenderedInRightPanel={isRenderedInRightPanel}
isVersionView={isVersionView}
property={record}
versionDataKeys={extensionObject.addedKeysList}
onExtensionUpdate={onExtensionUpdate}
/>
),
},
];
}, [
entityDetails,
entityDetails?.extension,
hasEditAccess,
extensionObject,
isVersionView,
onExtensionUpdate,
isRenderedInRightPanel,
]);
const viewAllBtn = useMemo(() => {
const customProp = entityTypeDetail.customProperties ?? [];
@ -212,6 +176,14 @@ export const CustomPropertyTable = <T extends ExtentionEntitiesKeys>({
maxDataCap,
]);
const dataSource = useMemo(() => {
const customProperties = entityTypeDetail?.customProperties ?? [];
return Array.isArray(customProperties)
? customProperties.slice(0, maxDataCap)
: [];
}, [maxDataCap, entityTypeDetail?.customProperties]);
useEffect(() => {
if (typePermission?.ViewAll || typePermission?.ViewBasic) {
fetchTypeDetail();
@ -277,21 +249,44 @@ export const CustomPropertyTable = <T extends ExtentionEntitiesKeys>({
</Typography.Text>
{viewAllBtn}
</div>
<Table
bordered
resizableColumns
columns={tableColumn}
data-testid="custom-properties-table"
dataSource={entityTypeDetail?.customProperties?.slice(
0,
maxDataCap
)}
loading={entityTypeDetailLoading}
pagination={false}
rowKey="name"
scroll={isRenderedInRightPanel ? { x: true } : undefined}
size="small"
/>
{isRenderedInRightPanel ? (
<>
{dataSource.map((record, index) => (
<Fragment key={record.name}>
<PropertyValue
extension={extensionObject.extensionObject}
hasEditPermissions={hasEditAccess}
isRenderedInRightPanel={isRenderedInRightPanel}
isVersionView={isVersionView}
key={record.name}
property={record}
versionDataKeys={extensionObject.addedKeysList}
onExtensionUpdate={onExtensionUpdate}
/>
{index !== dataSource.length - 1 && (
<Divider className="m-y-md" />
)}
</Fragment>
))}
</>
) : (
<Row data-testid="custom-properties-card" gutter={[16, 16]}>
{dataSource.map((record) => (
<Col key={record.name} span={8}>
<PropertyValue
extension={extensionObject.extensionObject}
hasEditPermissions={hasEditAccess}
isRenderedInRightPanel={isRenderedInRightPanel}
isVersionView={isVersionView}
property={record}
versionDataKeys={extensionObject.addedKeysList}
onExtensionUpdate={onExtensionUpdate}
/>
</Col>
))}
</Row>
)}
</>
)}
</>

View File

@ -116,10 +116,10 @@ describe('Test PropertyValue Component', () => {
/>
);
const valueElement = await screen.findByTestId('RichTextPreviewer');
const valueElement = await screen.findAllByTestId('RichTextPreviewer');
const iconElement = await screen.findByTestId('edit-icon');
expect(valueElement).toBeInTheDocument();
expect(valueElement).toHaveLength(2);
expect(iconElement).toBeInTheDocument();
await act(async () => {
@ -146,7 +146,7 @@ describe('Test PropertyValue Component', () => {
const iconElement = await screen.findByTestId('edit-icon');
expect(await screen.findByTestId('enum-value')).toHaveTextContent(
'enumValue1, enumValue2'
'enumValue1enumValue2'
);
await act(async () => {

View File

@ -11,17 +11,23 @@
* limitations under the License.
*/
import Icon from '@ant-design/icons';
import Icon, { DownOutlined, UpOutlined } from '@ant-design/icons';
import {
Button,
Card,
Col,
DatePicker,
Divider,
Form,
Input,
Row,
Select,
Tag,
TimePicker,
Tooltip,
Typography,
} from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { AxiosError } from 'axios';
import { t } from 'i18next';
import {
@ -35,7 +41,14 @@ import {
toUpper,
} from 'lodash';
import moment, { Moment } from 'moment';
import React, { CSSProperties, FC, Fragment, useState } from 'react';
import React, {
CSSProperties,
FC,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Link } from 'react-router-dom';
import { ReactComponent as EditIconComponent } from '../../../assets/svg/edit-new.svg';
import {
@ -43,11 +56,12 @@ import {
ICON_DIMENSION,
VALIDATION_MESSAGES,
} from '../../../constants/constants';
import { ENUM_WITH_DESCRIPTION } from '../../../constants/CustomProperty.constants';
import { TIMESTAMP_UNIX_IN_MILLISECONDS_REGEX } from '../../../constants/regex.constants';
import { CSMode } from '../../../enums/codemirror.enum';
import { SearchIndex } from '../../../enums/search.enum';
import { EntityReference } from '../../../generated/entity/type';
import { EnumConfig } from '../../../generated/type/customProperty';
import { EnumConfig, ValueClass } from '../../../generated/type/customProperty';
import entityUtilClassBase from '../../../utils/EntityUtilClassBase';
import { getEntityName } from '../../../utils/EntityUtils';
import searchClassBase from '../../../utils/SearchClassBase';
@ -59,6 +73,7 @@ import { ModalWithMarkdownEditor } from '../../Modals/ModalWithMarkdownEditor/Mo
import InlineEdit from '../InlineEdit/InlineEdit.component';
import ProfilePicture from '../ProfilePicture/ProfilePicture';
import RichTextEditorPreviewer from '../RichTextEditor/RichTextEditorPreviewer';
import Table from '../Table/Table';
import {
PropertyValueProps,
PropertyValueType,
@ -76,28 +91,55 @@ export const PropertyValue: FC<PropertyValueProps> = ({
property,
isRenderedInRightPanel = false,
}) => {
const propertyName = property.name;
const propertyType = property.propertyType;
const { propertyName, propertyType, value } = useMemo(() => {
const propertyName = property.name;
const propertyType = property.propertyType;
const value = extension?.[propertyName];
const value = extension?.[propertyName];
return {
propertyName,
propertyType,
value,
};
}, [property, extension]);
const [showInput, setShowInput] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isOverflowing, setIsOverflowing] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const onShowInput = () => setShowInput(true);
const onHideInput = () => setShowInput(false);
const findOptionReference = (
item: DataAssetOption | string,
options: DataAssetOption[]
) => {
if (typeof item === 'string') {
const option = options.find((option) => option.value === item);
return option?.reference;
}
return item?.reference;
};
const onInputSave = async (updatedValue: PropertyValueType) => {
const isEnum = propertyType.name === 'enum';
const isEnumWithDescription = propertyType.name === ENUM_WITH_DESCRIPTION;
const isArrayType = isArray(updatedValue);
const enumValue = isArrayType ? updatedValue : [updatedValue];
const propertyValue = isEnum
? (enumValue as string[]).filter(Boolean)
: updatedValue;
const propertyValue =
isEnum || isEnumWithDescription
? (enumValue as string[]).filter(Boolean)
: updatedValue;
try {
// Omit undefined and empty values
@ -225,6 +267,60 @@ export const PropertyValue: FC<PropertyValueProps> = ({
);
}
case ENUM_WITH_DESCRIPTION: {
const enumConfig = property.customPropertyConfig?.config as EnumConfig;
const isMultiSelect = Boolean(enumConfig?.multiSelect);
const values = (enumConfig?.values as ValueClass[]) ?? [];
const options = values.map((option) => ({
label: (
<Tooltip title={option.description}>
<span>{option.key}</span>
</Tooltip>
),
value: option.key,
}));
const initialValues = {
enumWithDescriptionValues: (isArray(value) ? value : [value]).filter(
Boolean
),
};
return (
<InlineEdit
isLoading={isLoading}
saveButtonProps={{
disabled: isLoading,
htmlType: 'submit',
form: 'enum-with-description-form',
}}
onCancel={onHideInput}
onSave={noop}>
<Form
id="enum-with-description-form"
initialValues={initialValues}
layout="vertical"
onFinish={(values: {
enumWithDescriptionValues: string | string[];
}) => onInputSave(values.enumWithDescriptionValues)}>
<Form.Item name="enumWithDescriptionValues" style={commonStyle}>
<Select
allowClear
data-testid="enum-with-description-select"
disabled={isLoading}
mode={isMultiSelect ? 'multiple' : undefined}
options={options}
placeholder={t('label.enum-value-plural')}
/>
</Form.Item>
</Form>
</InlineEdit>
);
}
case 'date-cp':
case 'dateTime-cp': {
// Default format is 'yyyy-mm-dd'
@ -609,13 +705,22 @@ export const PropertyValue: FC<PropertyValueProps> = ({
onFinish={(values: {
entityReference: DataAssetOption | DataAssetOption[];
}) => {
if (isArray(values.entityReference)) {
onInputSave(
values.entityReference.map((item) => item.reference)
);
} else {
onInputSave(values?.entityReference?.reference);
const { entityReference } = values;
if (Array.isArray(entityReference)) {
const references = entityReference
.map((item) => findOptionReference(item, initialOptions))
.filter(Boolean) as EntityReference[];
onInputSave(references);
return;
}
const reference = findOptionReference(
entityReference,
initialOptions
);
onInputSave(reference as EntityReference);
}}>
<Form.Item name="entityReference" style={commonStyle}>
<DataAssetAsyncSelectList
@ -656,11 +761,63 @@ export const PropertyValue: FC<PropertyValueProps> = ({
case 'enum':
return (
<Typography.Text className="break-all" data-testid="enum-value">
{isArray(value) ? value.join(', ') : value}
</Typography.Text>
<>
{isArray(value) ? (
<div
className="w-full d-flex gap-2 flex-wrap"
data-testid="enum-value">
{value.map((val) => (
<Tooltip key={val} title={val} trigger="hover">
<Tag className="enum-key-tag">{val}</Tag>
</Tooltip>
))}
</div>
) : (
<Tooltip key={value} title={value} trigger="hover">
<Tag className="enum-key-tag" data-testid="enum-value">
{value}
</Tag>
</Tooltip>
)}
</>
);
case ENUM_WITH_DESCRIPTION: {
const enumWithDescriptionValues = (value as ValueClass[]) ?? [];
const columns: ColumnsType<ValueClass> = [
{
title: 'Key',
dataIndex: 'key',
key: 'key',
render: (key: string) => <Typography>{key}</Typography>,
},
{
title: 'Description',
dataIndex: 'description',
key: 'description',
render: (description: string) => (
<RichTextEditorPreviewer markdown={description || ''} />
),
},
];
return (
<Table
bordered
resizableColumns
className="w-full"
columns={columns}
data-testid="enum-with-description-table"
dataSource={enumWithDescriptionValues}
pagination={false}
rowKey="name"
scroll={isRenderedInRightPanel ? { x: true } : undefined}
size="small"
/>
);
}
case 'sqlQuery':
return (
<SchemaEditor
@ -690,7 +847,7 @@ export const PropertyValue: FC<PropertyValueProps> = ({
item.fullyQualifiedName as string
)}>
<Button
className="entity-button flex-center p-0 m--ml-1"
className="entity-button flex-center p-0"
icon={
<div className="entity-button-icon m-r-xs">
{['user', 'team'].includes(item.type) ? (
@ -699,7 +856,7 @@ export const PropertyValue: FC<PropertyValueProps> = ({
isTeam={item.type === 'team'}
name={item.name ?? ''}
type="circle"
width="18"
width="24"
/>
) : (
searchClassBase.getEntityIcon(item.type)
@ -708,7 +865,7 @@ export const PropertyValue: FC<PropertyValueProps> = ({
}
type="text">
<Typography.Text
className="text-left text-xs"
className="text-left text-lg"
ellipsis={{ tooltip: true }}>
{getEntityName(item)}
</Typography.Text>
@ -739,18 +896,18 @@ export const PropertyValue: FC<PropertyValueProps> = ({
item.fullyQualifiedName as string
)}>
<Button
className="entity-button flex-center p-0 m--ml-1"
className="entity-button flex-center p-0"
icon={
<div
className="entity-button-icon m-r-xs"
style={{ width: '18px', display: 'flex' }}>
style={{ width: '24px', display: 'flex' }}>
{['user', 'team'].includes(item.type) ? (
<ProfilePicture
className="d-flex"
isTeam={item.type === 'team'}
name={item.name ?? ''}
type="circle"
width="18"
width="24"
/>
) : (
searchClassBase.getEntityIcon(item.type)
@ -759,7 +916,7 @@ export const PropertyValue: FC<PropertyValueProps> = ({
}
type="text">
<Typography.Text
className="text-left text-xs"
className="text-left text-lg"
data-testid="entityReference-value-name"
ellipsis={{ tooltip: true }}>
{getEntityName(item)}
@ -780,9 +937,17 @@ export const PropertyValue: FC<PropertyValueProps> = ({
<Typography.Text
className="break-all"
data-testid="time-interval-value">
{`StartTime: ${timeInterval.start}`}
<br />
{`EndTime: ${timeInterval.end}`}
<span>
<Typography.Text className="text-xs">{`StartTime: `}</Typography.Text>
<Typography.Text className="text-sm font-medium text-grey-body">
{timeInterval.start}
</Typography.Text>
<Divider className="self-center" type="vertical" />
<Typography.Text className="text-xs">{`EndTime: `}</Typography.Text>
<Typography.Text className="text-sm font-medium text-grey-body">
{timeInterval.end}
</Typography.Text>
</span>
</Typography.Text>
);
}
@ -798,7 +963,9 @@ export const PropertyValue: FC<PropertyValueProps> = ({
case 'duration':
default:
return (
<Typography.Text className="break-all" data-testid="value">
<Typography.Text
className="break-all text-xl font-semibold text-grey-body"
data-testid="value">
{value}
</Typography.Text>
);
@ -817,15 +984,34 @@ export const PropertyValue: FC<PropertyValueProps> = ({
);
};
return (
<div>
{showInput ? (
getPropertyInput()
) : (
<Fragment>
<div className="d-flex gap-2 items-center">
{getValueElement()}
{hasEditPermissions && (
const toggleExpand = () => {
setIsExpanded(!isExpanded);
};
useEffect(() => {
if (!contentRef.current || !property) {
return;
}
const isMarkdownWithValue = propertyType.name === 'markdown' && value;
const isOverflowing =
(contentRef.current.scrollHeight > 30 || isMarkdownWithValue) &&
propertyType.name !== 'entityReference';
setIsOverflowing(isOverflowing);
}, [property, extension, contentRef, value]);
const customPropertyElement = (
<Row gutter={[0, 16]}>
<Col span={24}>
<Row gutter={[0, 2]}>
<Col className="d-flex justify-between w-full" span={24}>
<Typography.Text
className="text-md text-grey-body"
data-testid="property-name">
{getEntityName(property)}
</Typography.Text>
{hasEditPermissions && !showInput && (
<Tooltip
placement="left"
title={t('label.edit-entity', { entity: propertyName })}>
@ -839,9 +1025,57 @@ export const PropertyValue: FC<PropertyValueProps> = ({
/>
</Tooltip>
)}
</div>
</Fragment>
)}
</div>
</Col>
<Col span={24}>
<RichTextEditorPreviewer
className="text-grey-muted"
markdown={property.description || ''}
/>
</Col>
</Row>
</Col>
<Col span={24}>
<Row gutter={[6, 0]}>
<Col
ref={contentRef}
span={22}
style={{
height: isExpanded || showInput ? 'auto' : '30px',
overflow: isExpanded ? 'visible' : 'hidden',
}}>
{showInput ? getPropertyInput() : getValueElement()}
</Col>
{isOverflowing && !showInput && (
<Col span={2}>
<Button
className="custom-property-value-toggle-btn"
data-testid={`toggle-${propertyName}`}
size="small"
type="text"
onClick={toggleExpand}>
{isExpanded ? <UpOutlined /> : <DownOutlined />}
</Button>
</Col>
)}
</Row>
</Col>
</Row>
);
if (isRenderedInRightPanel) {
return (
<div data-testid="custom-property-right-panel-card">
{customPropertyElement}
</div>
);
}
return (
<Card
className="w-full"
data-testid={`custom-property-${propertyName}-card`}>
{customPropertyElement}
</Card>
);
};

View File

@ -10,6 +10,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@btn-bg-color: #00000005;
@enum-tag-bg-color: #00000008;
.entity-reference-list-item:last-child {
border-bottom: none;
}
@ -21,8 +25,27 @@
align-items: center;
justify-content: center;
svg {
height: 18px;
width: 18px;
height: 24px;
width: 24px;
}
}
}
.ant-btn-text.custom-property-value-toggle-btn {
border-radius: 100%;
background: @btn-bg-color;
border-color: transparent;
height: 30px;
width: 30px;
.anticon {
vertical-align: middle;
}
}
.enum-key-tag {
width: max-content;
margin: 0px;
border: none;
padding: 4px;
background: @enum-tag-bg-color;
}

View File

@ -126,3 +126,18 @@ export const SUPPORTED_FORMAT_MAP = {
'dateTime-cp': SUPPORTED_DATE_TIME_FORMATS,
'time-cp': SUPPORTED_TIME_FORMATS,
};
export const ENUM_WITH_DESCRIPTION = 'enumWithDescriptions';
export const INLINE_PROPERTY_TYPES = [
'date-cp',
'dateTime-cp',
'duration',
'email',
'entityReference',
'integer',
'number',
'string',
'time-cp',
'timestamp',
];

View File

@ -619,6 +619,7 @@
"june": "Juni",
"jwt-token-expiry-time": "JWT token expiry time",
"jwt-uppercase": "JWT",
"key": "Key",
"keyword-lowercase-plural": "Schlüsselwörter",
"kill": "Beenden",
"kpi-display-name": "KPI",
@ -1498,6 +1499,7 @@
"entity-size-in-between": "{{entity}} Größe muss zwischen {{min}} und {{max}} liegen.",
"entity-size-must-be-between-2-and-64": "{{entity}} Größe muss zwischen 2 und 64 liegen.",
"entity-transfer-message": "Klicken Sie auf Bestätigen, wenn Sie {{entity}} von <0>{{from}}</0> unter <0>{{to}}</0> {{entity}} verschieben möchten.",
"enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.",
"error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.",
"error-team-transfer-message": "You cannot move to this team as Team Type {{dragTeam}} can't be {{dropTeam}} children",
"error-while-fetching-access-token": "Fehler beim Abrufen des Zugriffstokens.",

View File

@ -619,6 +619,7 @@
"june": "June",
"jwt-token-expiry-time": "JWT token expiry time",
"jwt-uppercase": "JWT",
"key": "Key",
"keyword-lowercase-plural": "keywords",
"kill": "Kill",
"kpi-display-name": "KPI Display Name",
@ -1498,6 +1499,7 @@
"entity-size-in-between": "{{entity}} size must be between {{min}} and {{max}}",
"entity-size-must-be-between-2-and-64": "{{entity}} size must be between 2 and 64",
"entity-transfer-message": "Click on Confirm if youd like to move <0>{{from}}</0> {{entity}} under <0>{{to}}</0> {{entity}}.",
"enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.",
"error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.",
"error-team-transfer-message": "You cannot move to this team as Team Type {{dragTeam}} can't be {{dropTeam}} children",
"error-while-fetching-access-token": "Error while fetching access token.",
@ -1839,7 +1841,7 @@
"update-displayName-entity": "Update Display Name for the {{entity}}.",
"update-profiler-settings": "Update profiler setting.",
"update-tag-message": "Request to update tags for",
"updating-existing-not-possible-can-add-new-values": "Updating existing values is not possible,only the addition of new values is allowed.",
"updating-existing-not-possible-can-add-new-values": "Updating existing values is not possible, only the addition of new values is allowed.",
"upload-file": "Upload File",
"upstream-depth-message": "Please select a value for upstream depth",
"upstream-depth-tooltip": "Display up to 3 nodes of upstream lineage to identify the source (parent levels).",

View File

@ -619,6 +619,7 @@
"june": "Junio",
"jwt-token-expiry-time": "Tiempo de caducidad del token JWT",
"jwt-uppercase": "JWT",
"key": "Key",
"keyword-lowercase-plural": "palabras clave",
"kill": "Eliminar",
"kpi-display-name": "Nombre para mostrar del KPI",
@ -1498,6 +1499,7 @@
"entity-size-in-between": "El tamaño de {{entity}} debe estar entre {{min}} y {{max}}",
"entity-size-must-be-between-2-and-64": "El tamaño de {{entity}} debe estar entre 2 y 64",
"entity-transfer-message": "Haga clic en Confirmar si desea mover <0>{{from}}</0> {{entity}} debajo de <0>{{to}}</0> {{entity}}.",
"enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.",
"error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.",
"error-team-transfer-message": "No puede mover a este equipo ya que el Tipo de equipo {{dragTeam}} no puede ser hijo de {{dropTeam}}",
"error-while-fetching-access-token": "Error al obtener el token de acceso.",

View File

@ -619,6 +619,7 @@
"june": "Juin",
"jwt-token-expiry-time": "délai d'expiration du jeton JWT",
"jwt-uppercase": "JWT",
"key": "Key",
"keyword-lowercase-plural": "mots-clés",
"kill": "Arrêter",
"kpi-display-name": "KPI",
@ -1498,6 +1499,7 @@
"entity-size-in-between": "{{entity}} taille doit être de {{min}} et {{max}}",
"entity-size-must-be-between-2-and-64": "{{entity}} taille doit être comprise entre 2 et 64",
"entity-transfer-message": "Cliquer sur Confirmer si vous souhaitez déplacer <0>{{from}}</0> {{entity}} sous <0>{{to}}</0> {{entity}}.",
"enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.",
"error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.",
"error-team-transfer-message": "Vous ne pouvez pas rejoindre cette équipe car le type d'équipe {{dragTeam}} ne peut être enfant de {{dropTeam}}",
"error-while-fetching-access-token": "Erreur pendant la récupération du jeton d'accès.",

View File

@ -619,6 +619,7 @@
"june": "יוני",
"jwt-token-expiry-time": "זמן פג תוקף של אסימון JWT",
"jwt-uppercase": "JWT",
"key": "Key",
"keyword-lowercase-plural": "מילות מפתח",
"kill": "הרוג",
"kpi-display-name": "שם תצוגת KPI",
@ -1498,6 +1499,7 @@
"entity-size-in-between": "{{entity}} יכול להיות בגודל בין {{min}} ל-{{max}}",
"entity-size-must-be-between-2-and-64": "{{entity}} יכול להיות בגודל בין 2 ל-64",
"entity-transfer-message": "לחץ על אישור אם ברצונך להעביר <0>{{from}}</0> {{entity}} מתחת ל-<0>{{to}}</0> {{entity}}.",
"enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.",
"error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.",
"error-team-transfer-message": "You cannot move to this team as Team Type {{dragTeam}} can't be {{dropTeam}} children",
"error-while-fetching-access-token": "שגיאה בעת קבלת טוקן גישה.",

View File

@ -619,6 +619,7 @@
"june": "6月",
"jwt-token-expiry-time": "JWT token expiry time",
"jwt-uppercase": "JWT",
"key": "Key",
"keyword-lowercase-plural": "キーワード",
"kill": "終了",
"kpi-display-name": "KPI表示名",
@ -1498,6 +1499,7 @@
"entity-size-in-between": "{{entity}}のサイズは{{min}}以上{{max}}以下にしてください",
"entity-size-must-be-between-2-and-64": "{{entity}}のサイズは2以上64以下",
"entity-transfer-message": "Click on Confirm if youd like to move <0>{{from}}</0> {{entity}} under <0>{{to}}</0> {{entity}}.",
"enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.",
"error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.",
"error-team-transfer-message": "You cannot move to this team as Team Type {{dragTeam}} can't be {{dropTeam}} children",
"error-while-fetching-access-token": "アクセストークンの取得中にエラーが発生しました。",

View File

@ -619,6 +619,7 @@
"june": "juni",
"jwt-token-expiry-time": "Vervaltijd van JWT-token",
"jwt-uppercase": "JWT",
"key": "Key",
"keyword-lowercase-plural": "trefwoorden",
"kill": "Stoppen",
"kpi-display-name": "Weergavenaam van KPI",
@ -1498,6 +1499,7 @@
"entity-size-in-between": "{{entity}} grootte moet tussen {{min}} en {{max}} liggen",
"entity-size-must-be-between-2-and-64": "{{entity}} grootte moet tussen 2 en 64 liggen",
"entity-transfer-message": "Klik op Bevestigen als je <0>{{from}}</0> {{entity}} wilt verplaatsen naar <0>{{to}}</0> {{entity}}.",
"enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.",
"error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.",
"error-team-transfer-message": "You cannot move to this team as Team Type {{dragTeam}} can't be {{dropTeam}} children",
"error-while-fetching-access-token": "Fout bij het ophalen van toegangstoken.",

View File

@ -619,6 +619,7 @@
"june": "Junho",
"jwt-token-expiry-time": "Tempo de expiração do token JWT",
"jwt-uppercase": "JWT",
"key": "Key",
"keyword-lowercase-plural": "palavras-chave",
"kill": "Finalizar",
"kpi-display-name": "Nome de Exibição do KPI",
@ -1498,6 +1499,7 @@
"entity-size-in-between": "O tamanho de {{entity}} deve ser entre {{min}} e {{max}}",
"entity-size-must-be-between-2-and-64": "O tamanho de {{entity}} deve ser entre 2 e 64",
"entity-transfer-message": "Clique em Confirmar se deseja mover <0>{{from}}</0> {{entity}} para <0>{{to}}</0> {{entity}}.",
"enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.",
"error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.",
"error-team-transfer-message": "You cannot move to this team as Team Type {{dragTeam}} can't be {{dropTeam}} children",
"error-while-fetching-access-token": "Erro ao buscar token de acesso.",

View File

@ -619,6 +619,7 @@
"june": "Июнь",
"jwt-token-expiry-time": "JWT token expiry time",
"jwt-uppercase": "JWT",
"key": "Key",
"keyword-lowercase-plural": "ключевые слова",
"kill": "Уничтожить",
"kpi-display-name": "Отображаемое имя KPI",
@ -1498,6 +1499,7 @@
"entity-size-in-between": "Размер {{entity}} должен быть между {{min}} и {{max}}",
"entity-size-must-be-between-2-and-64": "Размер {{entity}} должен быть от 2 до 64",
"entity-transfer-message": "Нажмите «Подтвердить», если вы хотите переместить <0>{{from}}</0> {{entity}} в <0>{{to}}</0> {{entity}}.",
"enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.",
"error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.",
"error-team-transfer-message": "You cannot move to this team as Team Type {{dragTeam}} can't be {{dropTeam}} children",
"error-while-fetching-access-token": "Ошибка при получении токена доступа.",

View File

@ -619,6 +619,7 @@
"june": "六月",
"jwt-token-expiry-time": "JWT 令牌到期时间",
"jwt-uppercase": "JWT",
"key": "Key",
"keyword-lowercase-plural": "关键词",
"kill": "终止",
"kpi-display-name": "KPI 显示名称",
@ -1498,6 +1499,7 @@
"entity-size-in-between": "{{entity}}大小须介于{{min}}和{{max}}之间",
"entity-size-must-be-between-2-and-64": "{{entity}}大小必须介于2和64之间",
"entity-transfer-message": "如果您想将<0>{{from}}</0> {{entity}} 移动到<0>{{to}}</0> {{entity}}中, 请单击确认",
"enum-with-description-update-note": "Updating existing value keys is not allowed; only the description can be edited. However, adding new values is allowed.",
"error-self-signup-disabled": "Self-signup is currently disabled. To proceed, please reach out to your administrator for further assistance or to request access.",
"error-team-transfer-message": "由于团队类型{{dragTeam}}不能是{{dropTeam}}的子团队, 因此您无法移动到此团队",
"error-while-fetching-access-token": "获取访问令牌时出现错误",

View File

@ -535,7 +535,7 @@ const APICollectionPage: FunctionComponent = () => {
);
const handleExtensionUpdate = async (apiCollectionData: APICollection) => {
await saveUpdatedAPICollectionData({
const response = await saveUpdatedAPICollectionData({
...apiCollection,
extension: apiCollectionData.extension,
});
@ -546,7 +546,7 @@ const APICollectionPage: FunctionComponent = () => {
return {
...prev,
extension: apiCollectionData.extension,
extension: response.extension,
};
});
};

View File

@ -555,7 +555,7 @@ const DatabaseSchemaPage: FunctionComponent = () => {
);
const handleExtensionUpdate = async (schema: DatabaseSchema) => {
await saveUpdatedDatabaseSchemaData({
const response = await saveUpdatedDatabaseSchemaData({
...databaseSchema,
extension: schema.extension,
});
@ -566,7 +566,7 @@ const DatabaseSchemaPage: FunctionComponent = () => {
return {
...prev,
extension: schema.extension,
extension: response.extension,
};
});
};

View File

@ -477,21 +477,22 @@ const StoredProcedurePage = () => {
const onExtensionUpdate = useCallback(
async (updatedData: StoredProcedure) => {
storedProcedure &&
(await saveUpdatedStoredProceduresData({
if (storedProcedure) {
const response = await saveUpdatedStoredProceduresData({
...storedProcedure,
extension: updatedData.extension,
}));
setStoredProcedure((prev) => {
if (!prev) {
return prev;
}
});
setStoredProcedure((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
extension: updatedData.extension,
};
});
return {
...prev,
extension: response.extension,
};
});
}
},
[saveUpdatedStoredProceduresData, storedProcedure]
);