From 6e99fe7bda7faacf21d68af6d45e1fd1a2375eae Mon Sep 17 00:00:00 2001 From: Sachin Chaurasiya Date: Mon, 15 Jul 2024 11:37:38 +0530 Subject: [PATCH] fix(#15683): backend validation error for the following property types (#16881) * fix(#15683): backend validation error for the following property types * feat: Update supported date and datetime formats * test: add e2e playwright test for custom properties * chore: remove cypress test for custom properties * Add date, dateTime, time backend support for custom property * fix: update custom property display names in AddCustomProperty component * feat: Update supported date and time formats * chore: Update supported date and time formats * feat: Update date and time formats in CustomProperty * test: add playwright test * Update supported date and time formats * feat: Add tests for custom properties in Playwright in 2 parts * Update supported date and time formats * Update supported date and time formats * chore: Update date format to uppercase in PropertyValue component * feat: Add support for custom date format in PropertyValue component * Update supported date and time formats * test: add playwright test for time, dateTime, timeInterval and date properties * Update supported time formats * chore: Add focus to time and date pickers before filling values * chore: Add focus to time and date pickers before filling values * Remove date, dateTime, time from type_entity --------- Co-authored-by: sonikashah --- .../native/1.5.0/mysql/schemaChanges.sql | 4 + .../native/1.5.0/postgres/schemaChanges.sql | 4 + .../openmetadata/service/TypeRegistry.java | 26 + .../exception/CatalogExceptionMessage.java | 5 + .../service/jdbi3/EntityRepository.java | 35 + .../service/jdbi3/TypeRepository.java | 40 ++ .../resources/json/schema/type/basic.json | 18 +- .../ui/cypress/common/Utils/CustomProperty.ts | 654 ------------------ .../constants/CustomProperty.constant.ts | 32 - .../ui/cypress/constants/constants.ts | 199 ------ .../e2e/Pages/Customproperties.spec.ts | 577 --------------- .../ui/playwright/constant/customProperty.ts | 210 ++++++ .../e2e/Pages/Customproperties-part1.spec.ts | 69 ++ .../e2e/Pages/Customproperties-part2.spec.ts | 186 +++++ .../ui/playwright/utils/customProperty.ts | 317 ++++++++- .../en-US/OpenMetadata/CustomProperty.md | 28 +- .../AddCustomProperty.test.tsx | 12 +- .../AddCustomProperty/AddCustomProperty.tsx | 74 +- .../PropertyValue.test.tsx | 6 +- .../CustomPropertyTable/PropertyValue.tsx | 24 +- .../src/constants/CustomProperty.constants.ts | 41 +- 21 files changed, 1028 insertions(+), 1533 deletions(-) delete mode 100644 openmetadata-ui/src/main/resources/ui/cypress/common/Utils/CustomProperty.ts delete mode 100644 openmetadata-ui/src/main/resources/ui/cypress/constants/CustomProperty.constant.ts delete mode 100644 openmetadata-ui/src/main/resources/ui/cypress/e2e/Pages/Customproperties.spec.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Customproperties-part1.spec.ts create mode 100644 openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Customproperties-part2.spec.ts diff --git a/bootstrap/sql/migrations/native/1.5.0/mysql/schemaChanges.sql b/bootstrap/sql/migrations/native/1.5.0/mysql/schemaChanges.sql index adcd400cbfc..9f4484ef33b 100644 --- a/bootstrap/sql/migrations/native/1.5.0/mysql/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/1.5.0/mysql/schemaChanges.sql @@ -75,3 +75,7 @@ CREATE TABLE IF NOT EXISTS api_endpoint_entity ( UNIQUE (fqnHash), INDEX (name) ); + +-- Remove date, dateTime, time from type_entity, as they are no more om-field-types, instead we have date-cp, time-cp, dateTime-cp as om-field-types +DELETE FROM type_entity +WHERE name IN ('date', 'dateTime', 'time'); diff --git a/bootstrap/sql/migrations/native/1.5.0/postgres/schemaChanges.sql b/bootstrap/sql/migrations/native/1.5.0/postgres/schemaChanges.sql index 260ed74eaae..fcfbeee1f30 100644 --- a/bootstrap/sql/migrations/native/1.5.0/postgres/schemaChanges.sql +++ b/bootstrap/sql/migrations/native/1.5.0/postgres/schemaChanges.sql @@ -69,3 +69,7 @@ CREATE TABLE IF NOT EXISTS api_endpoint_entity ( PRIMARY KEY (id), UNIQUE (fqnHash) ); + +-- Remove date, dateTime, time from type_entity, as they are no more om-field-types, instead we have date-cp, time-cp, dateTime-cp as om-field-types +DELETE FROM type_entity +WHERE name IN ('date', 'dateTime', 'time'); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/TypeRegistry.java b/openmetadata-service/src/main/java/org/openmetadata/service/TypeRegistry.java index 3498a419e0e..ff01df62db3 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/TypeRegistry.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/TypeRegistry.java @@ -102,4 +102,30 @@ public class TypeRegistry { public static String getPropertyName(String propertyFQN) { return FullyQualifiedName.split(propertyFQN)[2]; } + + public static String getCustomPropertyType(String entityType, String propertyName) { + Type type = TypeRegistry.TYPES.get(entityType); + if (type != null && type.getCustomProperties() != null) { + for (CustomProperty property : type.getCustomProperties()) { + if (property.getName().equals(propertyName)) { + return property.getPropertyType().getName(); + } + } + } + return null; + } + + public static String getCustomPropertyConfig(String entityType, String propertyName) { + Type type = TypeRegistry.TYPES.get(entityType); + if (type != null && type.getCustomProperties() != null) { + for (CustomProperty property : type.getCustomProperties()) { + if (property.getName().equals(propertyName) + && property.getCustomPropertyConfig() != null + && property.getCustomPropertyConfig().getConfig() != null) { + return property.getCustomPropertyConfig().getConfig().toString(); + } + } + } + return null; + } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java b/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java index d180ddbc46f..1263269beb6 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/exception/CatalogExceptionMessage.java @@ -237,6 +237,11 @@ public final class CatalogExceptionMessage { return String.format("Unknown custom field %s", fieldName); } + public static String dateTimeValidationError(String fieldName, String format) { + return String.format( + "Custom field %s value is not as per defined format %s", fieldName, format); + } + public static String jsonValidationError(String fieldName, String validationMessages) { return String.format("Custom field %s has invalid JSON %s", fieldName, validationMessages); } 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 25ec576c597..005785b82a9 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 @@ -77,11 +77,17 @@ import com.networknt.schema.JsonSchema; import com.networknt.schema.ValidationMessage; import java.io.IOException; import java.net.URI; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.TemporalAccessor; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Map.Entry; import java.util.Objects; @@ -1340,6 +1346,35 @@ public abstract class EntityRepository { if (jsonSchema == null) { throw new IllegalArgumentException(CatalogExceptionMessage.unknownCustomField(fieldName)); } + 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)); + } + } catch (DateTimeParseException e) { + throw new IllegalArgumentException( + CatalogExceptionMessage.dateTimeValidationError( + fieldName, TypeRegistry.getCustomPropertyConfig(entityType, fieldName))); + } Set validationMessages = jsonSchema.validate(fieldValue); if (!validationMessages.isEmpty()) { throw new IllegalArgumentException( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TypeRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TypeRepository.java index bce5765a243..5f381be4a90 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TypeRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TypeRepository.java @@ -22,9 +22,11 @@ import static org.openmetadata.service.Entity.FIELD_DESCRIPTION; import static org.openmetadata.service.util.EntityUtil.customFieldMatch; import static org.openmetadata.service.util.EntityUtil.getCustomField; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.UUID; import javax.ws.rs.core.UriInfo; import lombok.extern.slf4j.Slf4j; @@ -185,10 +187,48 @@ public class TypeRepository extends EntityRepository { throw new IllegalArgumentException("Enum Custom Property Type must have EnumConfig."); } } + case "date-cp" -> validateDateFormat( + customProperty.getCustomPropertyConfig(), getDateTokens(), "Invalid date format"); + case "dateTime-cp" -> validateDateFormat( + customProperty.getCustomPropertyConfig(), getDateTimeTokens(), "Invalid dateTime format"); + case "time-cp" -> validateDateFormat( + customProperty.getCustomPropertyConfig(), getTimeTokens(), "Invalid time format"); case "int", "string" -> {} } } + private void validateDateFormat( + CustomPropertyConfig config, Set validTokens, String errorMessage) { + if (config != null) { + String format = String.valueOf(config.getConfig()); + for (char c : format.toCharArray()) { + if (Character.isLetter(c) && !validTokens.contains(c)) { + throw new IllegalArgumentException(errorMessage + ": " + format); + } + } + try { + DateTimeFormatter.ofPattern(format); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(errorMessage + ": " + format, e); + } + } else { + throw new IllegalArgumentException(errorMessage + " must have Config populated with format."); + } + } + + private Set getDateTokens() { + return Set.of('y', 'M', 'd', 'E', 'D', 'W', 'w'); + } + + private Set getDateTimeTokens() { + return Set.of( + 'y', 'M', 'd', 'E', 'D', 'W', 'w', 'H', 'h', 'm', 's', 'a', 'T', 'X', 'Z', '+', '-', 'S'); + } + + private Set getTimeTokens() { + return Set.of('H', 'h', 'm', 's', 'a', 'S'); + } + /** Handles entity updated from PUT and POST operation. */ public class TypeUpdater extends EntityUpdater { public TypeUpdater(Type original, Type updated, Operation operation) { diff --git a/openmetadata-spec/src/main/resources/json/schema/type/basic.json b/openmetadata-spec/src/main/resources/json/schema/type/basic.json index 1477df6e3f5..4c2528391da 100644 --- a/openmetadata-spec/src/main/resources/json/schema/type/basic.json +++ b/openmetadata-spec/src/main/resources/json/schema/type/basic.json @@ -68,23 +68,35 @@ "type": "string" }, "date": { - "$comment" : "@om-field-type", "description": "Date in ISO 8601 format in UTC. Example - '2018-11-13'.", "type": "string", "format": "date" }, "dateTime": { - "$comment" : "@om-field-type", "description": "Date and time in ISO 8601 format. Example - '2018-11-13T20:20:39+00:00'.", "type": "string", "format": "date-time" }, "time": { - "$comment" : "@om-field-type", "description": "time in ISO 8601 format. Example - '20:20:39+00:00'.", "type": "string", "format": "time" }, + "date-cp": { + "$comment" : "@om-field-type", + "description": "Date as defined in custom property.", + "type": "string" + }, + "dateTime-cp": { + "$comment" : "@om-field-type", + "description": "Date and time as defined in custom property.", + "type": "string" + }, + "time-cp": { + "$comment" : "@om-field-type", + "description": "Time as defined in custom property.", + "type": "string" + }, "enum": { "$comment" : "@om-field-type", "description": "List of values in Enum.", diff --git a/openmetadata-ui/src/main/resources/ui/cypress/common/Utils/CustomProperty.ts b/openmetadata-ui/src/main/resources/ui/cypress/common/Utils/CustomProperty.ts deleted file mode 100644 index 356e5d52da4..00000000000 --- a/openmetadata-ui/src/main/resources/ui/cypress/common/Utils/CustomProperty.ts +++ /dev/null @@ -1,654 +0,0 @@ -/* - * Copyright 2023 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 { - CUSTOM_PROPERTY_INVALID_NAMES, - CUSTOM_PROPERTY_NAME_VALIDATION_ERROR, -} from '../../constants/constants'; -import { ENTITY_REFERENCE_PROPERTIES } from '../../constants/CustomProperty.constant'; -import { EntityType, ENTITY_PATH } from '../../constants/Entity.interface'; -import { - descriptionBox, - interceptURL, - uuid, - verifyResponseStatusCode, -} from '../common'; -import { getToken } from './LocalStorage'; - -export enum CustomPropertyType { - STRING = 'String', - INTEGER = 'Integer', - MARKDOWN = 'Markdown', -} -export enum CustomPropertyTypeByName { - STRING = 'string', - INTEGER = 'integer', - MARKDOWN = 'markdown', - NUMBER = 'number', - DURATION = 'duration', - EMAIL = 'email', - ENUM = 'enum', - SQL_QUERY = 'sqlQuery', - TIMESTAMP = 'timestamp', - ENTITY_REFERENCE = 'entityReference', - ENTITY_REFERENCE_LIST = 'entityReferenceList', -} - -export interface CustomProperty { - name: string; - type: CustomPropertyType; - description: string; - propertyType: { - name: string; - type: string; - }; -} - -export const generateCustomProperty = (type: CustomPropertyType) => ({ - name: `cypress${type.toLowerCase()}${Date.now()}`, - type, - description: `${type} cypress Property`, -}); - -export const getPropertyValues = (type: string) => { - switch (type) { - case 'integer': - return { - value: '123', - newValue: '456', - }; - case 'string': - return { - value: 'string value', - newValue: 'new string value', - }; - case 'markdown': - return { - value: '**Bold statement**', - newValue: '__Italic statement__', - }; - - case 'number': - return { - value: '123', - newValue: '456', - }; - case 'duration': - return { - value: 'PT1H', - newValue: 'PT2H', - }; - case 'email': - return { - value: 'john@gamil.com', - newValue: 'user@getcollate.io', - }; - case 'enum': - return { - value: 'small', - newValue: 'medium', - }; - case 'sqlQuery': - return { - value: 'Select * from table', - newValue: 'Select * from table where id = 1', - }; - - case 'timestamp': - return { - value: '1710831125922', - newValue: '1710831125923', - }; - case 'entityReference': - return { - value: 'Adam Matthews', - newValue: 'Amber Green', - }; - - case 'entityReferenceList': - return { - value: 'Aaron Johnson,Organization', - newValue: 'Aaron Warren', - }; - - default: - return { - value: '', - newValue: '', - }; - } -}; - -export const deleteCustomPropertyForEntity = ({ - property, - type, -}: { - property: CustomProperty; - type: EntityType; -}) => { - interceptURL('GET', `/api/v1/metadata/types/name/*`, 'getEntity'); - interceptURL('PATCH', `/api/v1/metadata/types/*`, 'patchEntity'); - // Selecting the entity - cy.settingClick(type, true); - - verifyResponseStatusCode('@getEntity', 200); - - cy.get( - `[data-row-key="${property.name}"] [data-testid="delete-button"]` - ).click(); - - cy.get('[data-testid="modal-header"]').should('contain', property.name); - - cy.get('[data-testid="save-button"]').click(); - - verifyResponseStatusCode('@patchEntity', 200); -}; - -export const setValueForProperty = ( - propertyName: string, - value: string, - propertyType: string -) => { - cy.get('[data-testid="custom_properties"]').click(); - - cy.get('tbody').should('contain', propertyName); - - // Adding value for the custom property - - // Navigating through the created custom property for adding value - cy.get(`[data-row-key="${propertyName}"]`) - .find('[data-testid="edit-icon"]') - .scrollIntoView() - .as('editbutton'); - - cy.get('@editbutton').should('be.visible').click({ force: true }); - - interceptURL('PATCH', `/api/v1/*/*`, 'patchEntity'); - // Checking for value text box or markdown box - - switch (propertyType) { - case 'markdown': - cy.get('.toastui-editor-md-container > .toastui-editor > .ProseMirror') - .should('be.visible') - .clear() - .type(value); - cy.get('[data-testid="save"]').click(); - - break; - - case 'email': - cy.get('[data-testid="email-input"]') - .should('be.visible') - .clear() - .type(value); - cy.get('[data-testid="inline-save-btn"]').click(); - - break; - - case 'duration': - cy.get('[data-testid="duration-input"]') - .should('be.visible') - .clear() - .type(value); - cy.get('[data-testid="inline-save-btn"]').click(); - - break; - - case 'enum': - cy.get('#enumValues').click().type(`${value}{enter}`); - cy.clickOutside(); - cy.get('[data-testid="inline-save-btn"]').click(); - - break; - - case 'sqlQuery': - cy.get("pre[role='presentation']").last().click().type(value); - cy.get('[data-testid="inline-save-btn"]').click(); - - break; - - case 'timestamp': - cy.get('[data-testid="timestamp-input"]') - .should('be.visible') - .clear() - .type(value); - cy.get('[data-testid="inline-save-btn"]').click(); - - break; - - case 'timeInterval': { - const [startValue, endValue] = value.split(','); - cy.get('[data-testid="start-input"]') - .should('be.visible') - .clear() - .type(startValue); - cy.get('[data-testid="end-input"]') - .should('be.visible') - .clear() - .type(endValue); - cy.get('[data-testid="inline-save-btn"]').click(); - - break; - } - - case 'string': - case 'integer': - case 'number': - cy.get('[data-testid="value-input"]') - .should('be.visible') - .clear() - .type(value); - cy.get('[data-testid="inline-save-btn"]').click(); - - break; - - case 'entityReference': - case 'entityReferenceList': { - const refValues = value.split(','); - - refValues.forEach((val) => { - interceptURL( - 'GET', - `/api/v1/search/query?q=*${encodeURIComponent(val)}*`, - 'searchEntityReference' - ); - cy.get('#entityReference').clear().type(`${val}`); - cy.wait('@searchEntityReference'); - cy.get(`[data-testid="${val}"]`).click(); - }); - - cy.get('[data-testid="inline-save-btn"]').click(); - - break; - } - - default: - break; - } - - verifyResponseStatusCode('@patchEntity', 200); - - if (propertyType === 'enum') { - cy.get('[data-testid="enum-value"]').should('contain', value); - } else if (propertyType === 'timeInterval') { - const [startValue, endValue] = value.split(','); - cy.get('[data-testid="time-interval-value"]').should('contain', startValue); - cy.get('[data-testid="time-interval-value"]').should('contain', endValue); - } else if (propertyType === 'sqlQuery') { - cy.get('.CodeMirror-scroll').should('contain', value); - } else if ( - ['entityReference', 'entityReferenceList'].includes(propertyType) - ) { - // do nothing - } else { - cy.get(`[data-row-key="${propertyName}"]`).should( - 'contain', - value.replace(/\*|_/gi, '') - ); - } -}; -export const validateValueForProperty = ( - propertyName: string, - value: string, - propertyType: string -) => { - cy.get('.ant-tabs-tab').first().click(); - cy.get( - '[data-testid="entity-right-panel"] [data-testid="custom-properties-table"]', - { - timeout: 10000, - } - ).scrollIntoView(); - - if (propertyType === 'enum') { - cy.get('[data-testid="enum-value"]').should('contain', value); - } else if (propertyType === 'timeInterval') { - const [startValue, endValue] = value.split(','); - cy.get('[data-testid="time-interval-value"]').should('contain', startValue); - cy.get('[data-testid="time-interval-value"]').should('contain', endValue); - } else if (propertyType === 'sqlQuery') { - cy.get('.CodeMirror-scroll').should('contain', value); - } else if ( - ['entityReference', 'entityReferenceList'].includes(propertyType) - ) { - // do nothing - } else { - cy.get(`[data-row-key="${propertyName}"]`).should( - 'contain', - value.replace(/\*|_/gi, '') - ); - } -}; -export const generateCustomProperties = () => { - return { - name: `cyCustomProperty${uuid()}`, - description: `cyCustomProperty${uuid()}`, - }; -}; -export const verifyCustomPropertyRows = () => { - cy.get('[data-testid="custom_properties"]').click(); - cy.get('.ant-table-row').should('have.length.gte', 10); - cy.get('.ant-tabs-tab').first().click(); - cy.get( - '[data-testid="entity-right-panel"] [data-testid="custom-properties-table"]', - { - timeout: 10000, - } - ).scrollIntoView(); - cy.get( - '[data-testid="entity-right-panel"] [data-testid="custom-properties-table"] tbody tr' - ).should('have.length', 6); -}; - -export const deleteCustomProperties = ( - tableSchemaId: string, - token: string -) => { - cy.request({ - method: 'PATCH', - url: `/api/v1/metadata/types/${tableSchemaId}`, - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json-patch+json', - }, - body: [ - { - op: 'remove', - path: '/customProperties', - }, - ], - }); -}; - -export const customPropertiesArray = Array(10) - .fill(null) - .map(() => generateCustomProperties()); - -export const addCustomPropertiesForEntity = ({ - propertyName, - customPropertyData, - customType, - enumConfig, - formatConfig, - entityReferenceConfig, -}: { - propertyName: string; - customPropertyData: { description: string }; - customType: string; - enumConfig?: { values: string[]; multiSelect: boolean }; - formatConfig?: string; - entityReferenceConfig?: string[]; -}) => { - // eslint-disable-next-line max-len - const longDescription = `Lorem ipsum dolor sit amet consectetur adipisicing elit. Dolore neque fuga reprehenderit placeat, sint doloremque quo expedita consequatur fugiat maxime maiores voluptate eum quis quas dignissimos cumque perspiciatis optio dolorem blanditiis iure natus commodi dolor quam. Voluptatem excepturi aut, at ullam aliquid repudiandae distinctio ipsam voluptates tenetur a. Sit, illum.`; - // Add Custom property for selected entity - cy.get('[data-testid="add-field-button"]').click(); - - // validation should work - cy.get('[data-testid="create-button"]').scrollIntoView().click(); - - cy.get('#name_help').should('contain', 'Name is required'); - cy.get('#propertyType_help').should('contain', 'Property Type is required'); - - cy.get('#description_help').should('contain', 'Description is required'); - - // capital case validation - cy.get('[data-testid="name"]') - .scrollIntoView() - .type(CUSTOM_PROPERTY_INVALID_NAMES.CAPITAL_CASE); - cy.get('[role="alert"]').should( - 'contain', - CUSTOM_PROPERTY_NAME_VALIDATION_ERROR - ); - - // with underscore validation - cy.get('[data-testid="name"]') - .clear() - .type(CUSTOM_PROPERTY_INVALID_NAMES.WITH_UNDERSCORE); - cy.get('[role="alert"]').should( - 'contain', - CUSTOM_PROPERTY_NAME_VALIDATION_ERROR - ); - - // with space validation - cy.get('[data-testid="name"]') - .clear() - .type(CUSTOM_PROPERTY_INVALID_NAMES.WITH_SPACE); - cy.get('[role="alert"]').should( - 'contain', - CUSTOM_PROPERTY_NAME_VALIDATION_ERROR - ); - - // with dots validation - cy.get('[data-testid="name"]') - .clear() - .type(CUSTOM_PROPERTY_INVALID_NAMES.WITH_DOTS); - cy.get('[role="alert"]').should( - 'contain', - CUSTOM_PROPERTY_NAME_VALIDATION_ERROR - ); - - // should allow name in another languages - cy.get('[data-testid="name"]').clear().type('汝らヴェディア'); - // should not throw the validation error - cy.get('#name_help').should('not.exist'); - - cy.get('[data-testid="name"]').clear().type(propertyName); - - cy.get(`#root\\/propertyType`).clear().type(customType); - cy.get(`[title="${customType}"]`).click(); - - if (customType === 'Enum') { - enumConfig.values.forEach((val) => { - cy.get('#root\\/enumConfig').type(`${val}{enter}`); - }); - - cy.clickOutside(); - - if (enumConfig.multiSelect) { - cy.get('#root\\/multiSelect').scrollIntoView().click(); - } - } - if (ENTITY_REFERENCE_PROPERTIES.includes(customType)) { - entityReferenceConfig.forEach((val) => { - cy.get('#root\\/entityReferenceConfig').click().type(`${val}`); - cy.get(`[title="${val}"]`).click(); - }); - - cy.clickOutside(); - } - - if (['Date', 'Date Time'].includes(customType)) { - cy.get('#root\\/formatConfig').clear().type('invalid-format'); - cy.get('[role="alert"]').should('contain', 'Format is invalid'); - - cy.get('#root\\/formatConfig').clear().type(formatConfig); - } - - cy.get(descriptionBox) - .clear() - .type(`${customPropertyData.description} ${longDescription}`); - - // Check if the property got added - cy.intercept('/api/v1/metadata/types/name/*?fields=customProperties').as( - 'customProperties' - ); - cy.get('[data-testid="create-button"]').scrollIntoView().click(); - - cy.wait('@customProperties'); - cy.get('.ant-table-row').should('contain', propertyName); - - // Navigating to home page - cy.clickOnLogo(); -}; - -export const editCreatedProperty = (propertyName: string, type?: string) => { - // Fetching for edit button - cy.get(`[data-row-key="${propertyName}"]`) - .find('[data-testid="edit-button"]') - .as('editButton'); - - if (type === 'Enum') { - cy.get(`[data-row-key="${propertyName}"]`) - .find('[data-testid="enum-config"]') - .should('contain', '["enum1","enum2","enum3"]'); - } - - cy.get('@editButton').click(); - - cy.get(descriptionBox).clear().type('This is new description'); - - if (type === 'Enum') { - cy.get('#root\\/customPropertyConfig').type(`updatedValue{enter}`); - - cy.clickOutside(); - } - - if (ENTITY_REFERENCE_PROPERTIES.includes(type)) { - cy.get('#root\\/customPropertyConfig').click().type(`Table{enter}`); - - cy.clickOutside(); - } - - interceptURL('PATCH', '/api/v1/metadata/types/*', 'checkPatchForDescription'); - - cy.get('button[type="submit"]').scrollIntoView().click(); - - /** - * @link https://docs.cypress.io/guides/references/configuration#Timeouts - * default responseTimeout is 30000ms which is not enough for the patch request - * so we need to increase the responseTimeout to 70000ms for AUT environment in PATCH request - */ - cy.wait('@checkPatchForDescription', { responseTimeout: 70000 }); - - cy.get('.ant-modal-wrap').should('not.exist'); - - // Fetching for updated descriptions for the created custom property - cy.get(`[data-row-key="${propertyName}"]`) - .find('[data-testid="viewer-container"]') - .should('contain', 'This is new description'); - - if (type === 'Enum') { - cy.get(`[data-row-key="${propertyName}"]`) - .find('[data-testid="enum-config"]') - .should('contain', '["enum1","enum2","enum3","updatedValue"]'); - } - if (ENTITY_REFERENCE_PROPERTIES.includes(type)) { - cy.get(`[data-row-key="${propertyName}"]`) - .find(`[data-testid="${propertyName}-config"]`) - .should('contain', '["user","team","table"]'); - } -}; - -export const deleteCreatedProperty = (propertyName: string) => { - // Fetching for delete button - cy.get(`[data-row-key="${propertyName}"]`) - .scrollIntoView() - .find('[data-testid="delete-button"]') - .click(); - - // Checking property name is present on the delete pop-up - cy.get('[data-testid="body-text"]').should('contain', propertyName); - - cy.get('[data-testid="save-button"]').should('be.visible').click(); -}; - -export const createCustomPropertyForEntity = (prop: string) => { - return cy.getAllLocalStorage().then((data) => { - const token = getToken(data); - - // fetch the available property types - return cy - .request({ - method: 'GET', - url: `/api/v1/metadata/types?category=field&limit=20`, - headers: { Authorization: `Bearer ${token}` }, - }) - .then(({ body }) => { - const propertyList = body.data.filter((item) => - Object.values(CustomPropertyTypeByName).includes(item.name) - ); - - // fetch the entity details for which the custom property needs to be added - return cy - .request({ - method: 'GET', - url: `/api/v1/metadata/types/name/${ENTITY_PATH[prop]}`, - headers: { Authorization: `Bearer ${token}` }, - }) - .then(({ body }) => { - const entityId = body.id; - - // Add the custom property for the entity - propertyList.forEach((item) => { - return cy - .request({ - method: 'PUT', - url: `/api/v1/metadata/types/${entityId}`, - headers: { Authorization: `Bearer ${token}` }, - body: { - name: `cyCustomProperty${uuid()}`, - description: `cyCustomProperty${uuid()}`, - propertyType: { - id: item.id ?? '', - type: 'type', - }, - ...(item.name === 'enum' - ? { - customPropertyConfig: { - config: { - multiSelect: true, - values: ['small', 'medium', 'large'], - }, - }, - } - : {}), - ...(['entityReference', 'entityReferenceList'].includes( - item.name - ) - ? { - customPropertyConfig: { - config: ['user', 'team'], - }, - } - : {}), - }, - }) - .then(({ body }) => { - return body.customProperties.reduce( - (prev, curr) => { - const propertyTypeName = curr.propertyType.name; - - return { - ...prev, - [propertyTypeName]: { - ...getPropertyValues(propertyTypeName), - property: curr, - }, - }; - }, - {} as Record< - string, - { - value: string; - newValue: string; - property: CustomProperty; - } - > - ); - }); - }); - }); - }); - }); -}; diff --git a/openmetadata-ui/src/main/resources/ui/cypress/constants/CustomProperty.constant.ts b/openmetadata-ui/src/main/resources/ui/cypress/constants/CustomProperty.constant.ts deleted file mode 100644 index e766529da6e..00000000000 --- a/openmetadata-ui/src/main/resources/ui/cypress/constants/CustomProperty.constant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2024 Collate. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import { EntityType } from './Entity.interface'; - -export const CustomPropertySupportedEntityList = [ - EntityType.Database, - EntityType.DatabaseSchema, - EntityType.Table, - EntityType.StoreProcedure, - EntityType.Topic, - EntityType.Dashboard, - EntityType.Pipeline, - EntityType.Container, - EntityType.MlModel, - EntityType.GlossaryTerm, - EntityType.SearchIndex, -]; - -export const ENTITY_REFERENCE_PROPERTIES = [ - 'Entity Reference', - 'Entity Reference List', -]; diff --git a/openmetadata-ui/src/main/resources/ui/cypress/constants/constants.ts b/openmetadata-ui/src/main/resources/ui/cypress/constants/constants.ts index f2e4a42be64..41b052a85dc 100644 --- a/openmetadata-ui/src/main/resources/ui/cypress/constants/constants.ts +++ b/openmetadata-ui/src/main/resources/ui/cypress/constants/constants.ts @@ -337,195 +337,6 @@ export const ENTITY_SERVICE_TYPE = { Search: 'Search', }; -export const ENTITIES = { - entity_container: { - name: 'container', - description: 'This is Container custom property', - integerValue: '14', - stringValue: 'This is string propery', - markdownValue: 'This is markdown value', - enumConfig: { - values: ['enum1', 'enum2', 'enum3'], - multiSelect: false, - }, - dateFormatConfig: 'yyyy-mm-dd', - dateTimeFormatConfig: 'yyyy-mm-dd hh:mm:ss', - entityReferenceConfig: ['User', 'Team'], - entityObj: {}, - entityApiType: 'containers', - }, - - entity_dashboard: { - name: 'dashboard', - description: 'This is Dashboard custom property', - integerValue: '14', - stringValue: 'This is string propery', - markdownValue: 'This is markdown value', - enumConfig: { - values: ['enum1', 'enum2', 'enum3'], - multiSelect: false, - }, - dateFormatConfig: 'yyyy-mm-dd', - dateTimeFormatConfig: 'yyyy-mm-dd hh:mm:ss', - entityReferenceConfig: ['User', 'Team'], - entityObj: SEARCH_ENTITY_DASHBOARD.dashboard_1, - entityApiType: 'dashboards', - }, - - entity_database: { - name: 'database', - description: 'This is Database custom property', - integerValue: '14', - stringValue: 'This is string propery', - markdownValue: 'This is markdown value', - enumConfig: { - values: ['enum1', 'enum2', 'enum3'], - multiSelect: false, - }, - dateFormatConfig: 'yyyy-mm-dd', - dateTimeFormatConfig: 'yyyy-mm-dd hh:mm:ss', - entityReferenceConfig: ['User', 'Team'], - entityObj: {}, - entityApiType: 'databases', - }, - - entity_databaseSchema: { - name: 'databaseSchema', - description: 'This is Database Schema custom property', - integerValue: '14', - stringValue: 'This is string propery', - markdownValue: 'This is markdown value', - enumConfig: { - values: ['enum1', 'enum2', 'enum3'], - multiSelect: false, - }, - dateFormatConfig: 'yyyy-mm-dd', - dateTimeFormatConfig: 'yyyy-mm-dd hh:mm:ss', - entityReferenceConfig: ['User', 'Team'], - entityObj: {}, - entityApiType: 'databaseSchemas', - }, - - entity_glossaryTerm: { - name: 'glossaryTerm', - description: 'This is Glossary Term custom property', - integerValue: '14', - stringValue: 'This is string propery', - markdownValue: 'This is markdown value', - enumConfig: { - values: ['enum1', 'enum2', 'enum3'], - multiSelect: false, - }, - dateFormatConfig: 'yyyy-mm-dd', - dateTimeFormatConfig: 'yyyy-mm-dd hh:mm:ss', - entityReferenceConfig: ['User', 'Team'], - entityObj: {}, - entityApiType: 'glossaryTerm', - }, - - entity_mlmodel: { - name: 'mlmodel', - description: 'This is ML Model custom property', - integerValue: '14', - stringValue: 'This is string propery', - markdownValue: 'This is markdown value', - enumConfig: { - values: ['enum1', 'enum2', 'enum3'], - multiSelect: false, - }, - dateFormatConfig: 'yyyy-mm-dd', - dateTimeFormatConfig: 'yyyy-mm-dd hh:mm:ss', - entityReferenceConfig: ['User', 'Team'], - entityObj: {}, - entityApiType: 'mlmodels', - }, - - entity_pipeline: { - name: 'pipeline', - description: 'This is Pipeline custom property', - integerValue: '78', - stringValue: 'This is string propery', - markdownValue: 'This is markdown value', - enumConfig: { - values: ['enum1', 'enum2', 'enum3'], - multiSelect: true, - }, - dateFormatConfig: 'yyyy-mm-dd', - dateTimeFormatConfig: 'yyyy-mm-dd hh:mm:ss', - entityReferenceConfig: ['User', 'Team'], - entityObj: SEARCH_ENTITY_PIPELINE.pipeline_1, - entityApiType: 'pipelines', - }, - - entity_searchIndex: { - name: 'searchIndex', - description: 'This is Search Index custom property', - integerValue: '14', - stringValue: 'This is string propery', - markdownValue: 'This is markdown value', - enumConfig: { - values: ['enum1', 'enum2', 'enum3'], - multiSelect: false, - }, - dateFormatConfig: 'yyyy-mm-dd', - dateTimeFormatConfig: 'yyyy-mm-dd hh:mm:ss', - entityReferenceConfig: ['User', 'Team'], - entityObj: {}, - entityApiType: 'searchIndexes', - }, - - entity_storedProcedure: { - name: 'storedProcedure', - description: 'This is Stored Procedure custom property', - integerValue: '14', - stringValue: 'This is string propery', - markdownValue: 'This is markdown value', - enumConfig: { - values: ['enum1', 'enum2', 'enum3'], - multiSelect: false, - }, - dateFormatConfig: 'yyyy-mm-dd', - dateTimeFormatConfig: 'yyyy-mm-dd hh:mm:ss', - entityReferenceConfig: ['User', 'Team'], - entityObj: {}, - entityApiType: 'storedProcedures', - }, - - entity_table: { - name: 'table', - description: 'This is Table custom property', - integerValue: '45', - stringValue: 'This is string propery', - markdownValue: 'This is markdown value', - enumConfig: { - values: ['enum1', 'enum2', 'enum3'], - multiSelect: false, - }, - dateFormatConfig: 'yyyy-mm-dd', - dateTimeFormatConfig: 'yyyy-mm-dd hh:mm:ss', - entityReferenceConfig: ['User', 'Team'], - entityObj: SEARCH_ENTITY_TABLE.table_1, - entityApiType: 'tables', - }, - - entity_topic: { - name: 'topic', - description: 'This is Topic custom property', - integerValue: '23', - stringValue: 'This is string propery', - markdownValue: 'This is markdown value', - enumConfig: { - values: ['enum1', 'enum2', 'enum3'], - multiSelect: false, - }, - dateFormatConfig: 'yyyy-mm-dd', - dateTimeFormatConfig: 'yyyy-mm-dd hh:mm:ss', - entityReferenceConfig: ['User', 'Team'], - entityObj: SEARCH_ENTITY_TOPIC.topic_1, - entityApiType: 'topics', - }, -}; - export const LOGIN = { username: 'admin@openmetadata.org', password: 'admin', @@ -594,16 +405,6 @@ export const DESTINATION = { }, }; -export const CUSTOM_PROPERTY_INVALID_NAMES = { - CAPITAL_CASE: 'CapitalCase', - WITH_UNDERSCORE: 'with_underscore', - WITH_DOTS: 'with.', - WITH_SPACE: 'with ', -}; - -export const CUSTOM_PROPERTY_NAME_VALIDATION_ERROR = - 'Name must start with lower case with no space, underscore, or dots.'; - export const TAG_INVALID_NAMES = { MIN_LENGTH: 'c', MAX_LENGTH: 'a87439625b1c2d3e4f5061728394a5b6c7d8e90a1b2c3d4e5f67890ab', diff --git a/openmetadata-ui/src/main/resources/ui/cypress/e2e/Pages/Customproperties.spec.ts b/openmetadata-ui/src/main/resources/ui/cypress/e2e/Pages/Customproperties.spec.ts deleted file mode 100644 index b1da8afab43..00000000000 --- a/openmetadata-ui/src/main/resources/ui/cypress/e2e/Pages/Customproperties.spec.ts +++ /dev/null @@ -1,577 +0,0 @@ -/* - * Copyright 2022 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 { lowerCase } from 'lodash'; -import { interceptURL, verifyResponseStatusCode } from '../../common/common'; -import { - createGlossary, - createGlossaryTerms, - deleteGlossary, -} from '../../common/GlossaryUtils'; -import { - addCustomPropertiesForEntity, - CustomProperty, - CustomPropertyType, - deleteCreatedProperty, - deleteCustomPropertyForEntity, - editCreatedProperty, - generateCustomProperty, - setValueForProperty, - validateValueForProperty, -} from '../../common/Utils/CustomProperty'; -import { visitEntityDetailsPage } from '../../common/Utils/Entity'; -import { updateJWTTokenExpiryTime } from '../../common/Utils/Login'; -import { ENTITIES, JWT_EXPIRY_TIME_MAP, uuid } from '../../constants/constants'; -import { EntityType, SidebarItem } from '../../constants/Entity.interface'; -import { GLOSSARY_1 } from '../../constants/glossary.constant'; - -const glossaryTerm = { - name: 'glossaryTerm', - description: 'This is Glossary Term custom property', - integerValue: '45', - stringValue: 'This is string property', - markdownValue: 'This is markdown value', - entityApiType: 'glossaryTerm', -}; - -const customPropertyValue = { - Integer: { - value: '123', - newValue: '456', - property: generateCustomProperty(CustomPropertyType.INTEGER), - }, - String: { - value: '123', - newValue: '456', - property: generateCustomProperty(CustomPropertyType.STRING), - }, - Markdown: { - value: '**Bold statement**', - newValue: '__Italic statement__', - property: generateCustomProperty(CustomPropertyType.MARKDOWN), - }, -}; - -describe('Custom Properties should work properly', { tags: 'Settings' }, () => { - before(() => { - cy.login(); - updateJWTTokenExpiryTime(JWT_EXPIRY_TIME_MAP['2 hours']); - }); - - after(() => { - cy.login(); - updateJWTTokenExpiryTime(JWT_EXPIRY_TIME_MAP['1 hour']); - }); - - beforeEach(() => { - cy.login(); - }); - - afterEach(() => { - cy.logout(); - }); - - [ - 'Integer', - 'String', - 'Markdown', - 'Duration', - 'Email', - 'Number', - 'Sql Query', - // 'Time', - // 'Time Interval', - 'Timestamp', - ].forEach((type) => { - describe(`Add update and delete ${type} custom properties`, () => { - Object.values(ENTITIES).forEach((entity) => { - const propertyName = `addcyentity${entity.name}test${uuid()}`; - - it(`Add/Update/Delete ${type} custom property for ${entity.name} Entities`, () => { - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - - // Getting the property - addCustomPropertiesForEntity({ - propertyName, - customPropertyData: entity, - customType: type, - }); - - // Navigating back to custom properties page - cy.settingClick(entity.entityApiType, true); - verifyResponseStatusCode('@getEntity', 200); - - // `Edit created property for ${entity.name} entity` - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - editCreatedProperty(propertyName); - - // `Delete created property for ${entity.name} entity` - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - deleteCreatedProperty(propertyName); - }); - }); - }); - }); - - describe('Add update and delete Enum custom properties', () => { - Object.values(ENTITIES).forEach((entity) => { - const propertyName = `addcyentity${entity.name}test${uuid()}`; - - it(`Add/Update/Delete Enum custom property for ${entity.name} Entities`, () => { - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - - addCustomPropertiesForEntity({ - propertyName, - customPropertyData: entity, - customType: 'Enum', - enumConfig: entity.enumConfig, - }); - - // Navigating back to custom properties page - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - - // `Edit created property for ${entity.name} entity` - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - editCreatedProperty(propertyName, 'Enum'); - - // `Delete created property for ${entity.name} entity` - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - deleteCreatedProperty(propertyName); - }); - }); - }); - - describe('Add update and delete Entity Reference custom properties', () => { - Object.values(ENTITIES).forEach((entity) => { - const propertyName = `addcyentity${entity.name}test${uuid()}`; - - it(`Add/Update/Delete Entity Reference custom property for ${entity.name} Entities`, () => { - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - - addCustomPropertiesForEntity({ - propertyName, - customPropertyData: entity, - customType: 'Entity Reference', - entityReferenceConfig: entity.entityReferenceConfig, - }); - - // Navigating back to custom properties page - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - - // `Edit created property for ${entity.name} entity` - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - editCreatedProperty(propertyName, 'Entity Reference'); - - // `Delete created property for ${entity.name} entity` - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - deleteCreatedProperty(propertyName); - }); - }); - }); - - describe('Add update and delete Entity Reference List custom properties', () => { - Object.values(ENTITIES).forEach((entity) => { - const propertyName = `addcyentity${entity.name}test${uuid()}`; - - it(`Add/Update/Delete Entity Reference List custom property for ${entity.name} Entities`, () => { - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - - addCustomPropertiesForEntity({ - propertyName, - customPropertyData: entity, - customType: 'Entity Reference List', - entityReferenceConfig: entity.entityReferenceConfig, - }); - - // Navigating back to custom properties page - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - - // `Edit created property for ${entity.name} entity` - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - editCreatedProperty(propertyName, 'Entity Reference List'); - - // `Delete created property for ${entity.name} entity` - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - deleteCreatedProperty(propertyName); - }); - }); - }); - - // eslint-disable-next-line jest/no-disabled-tests - describe.skip('Add update and delete Date custom properties', () => { - Object.values(ENTITIES).forEach((entity) => { - const propertyName = `addcyentity${entity.name}test${uuid()}`; - - it(`Add Date custom property for ${entity.name} Entities`, () => { - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - - addCustomPropertiesForEntity({ - propertyName, - customPropertyData: entity, - customType: 'Date', - formatConfig: entity.dateFormatConfig, - }); - - // Navigating back to custom properties page - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - }); - - it(`Edit created property for ${entity.name} entity`, () => { - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - editCreatedProperty(propertyName); - }); - - it(`Delete created property for ${entity.name} entity`, () => { - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - deleteCreatedProperty(propertyName); - }); - }); - }); - - // eslint-disable-next-line jest/no-disabled-tests - describe.skip('Add update and delete DateTime custom properties', () => { - Object.values(ENTITIES).forEach((entity) => { - const propertyName = `addcyentity${entity.name}test${uuid()}`; - - it(`Add DateTime custom property for ${entity.name} Entities`, () => { - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - - addCustomPropertiesForEntity({ - propertyName, - customPropertyData: entity, - customType: 'Date Time', - formatConfig: entity.dateTimeFormatConfig, - }); - - // Navigating back to custom properties page - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - }); - - it(`Edit created property for ${entity.name} entity`, () => { - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - editCreatedProperty(propertyName); - }); - - it(`Delete created property for ${entity.name} entity`, () => { - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${entity.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(entity.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - deleteCreatedProperty(propertyName); - }); - }); - }); - - describe('Custom properties for glossary and glossary terms', () => { - const propertyName = `addcyentity${glossaryTerm.name}test${uuid()}`; - const properties = Object.values(CustomPropertyType).join(', '); - - it('test custom properties in advanced search modal', () => { - cy.settingClick(glossaryTerm.entityApiType, true); - - addCustomPropertiesForEntity({ - propertyName, - customPropertyData: glossaryTerm, - customType: 'Integer', - }); - - // Navigating to explore page - cy.sidebarClick(SidebarItem.EXPLORE); - interceptURL( - 'GET', - `/api/v1/metadata/types/name/glossaryTerm*`, - 'getEntity' - ); - cy.get( - `[data-testid=${Cypress.$.escapeSelector('glossary terms-tab')}]` - ).click(); - - cy.get('[data-testid="advance-search-button"]').click(); - verifyResponseStatusCode('@getEntity', 200); - - // Click on field dropdown - cy.get('.rule--field > .ant-select > .ant-select-selector').eq(0).click(); - - // Select custom property fields - cy.get(`[title="Custom Properties"]`).eq(0).click(); - - // Click on field dropdown - cy.get('.rule--field > .ant-select > .ant-select-selector').eq(0).click(); - - // Verify field exists - cy.get(`[title="${propertyName}"]`).should('be.visible'); - - cy.get('[data-testid="cancel-btn"]').click(); - }); - - it(`Delete created property for glossary term entity`, () => { - interceptURL( - 'GET', - `/api/v1/metadata/types/name/${glossaryTerm.name}*`, - 'getEntity' - ); - - // Selecting the entity - cy.settingClick(glossaryTerm.entityApiType, true); - - verifyResponseStatusCode('@getEntity', 200); - deleteCreatedProperty(propertyName); - }); - - // TODO: Need to fix this for mysql due to data issue @Sachin-chaurasiya - // eslint-disable-next-line jest/no-disabled-tests - it.skip(`Add update and delete ${properties} custom properties for glossary term `, () => { - interceptURL('GET', '/api/v1/glossaryTerms*', 'getGlossaryTerms'); - interceptURL('GET', '/api/v1/glossaries?fields=*', 'fetchGlossaries'); - - cy.sidebarClick(SidebarItem.GLOSSARY); - const glossary = GLOSSARY_1; - glossary.terms = [GLOSSARY_1.terms[0]]; - - createGlossary(GLOSSARY_1, false); - createGlossaryTerms(glossary); - - cy.settingClick(glossaryTerm.entityApiType, true); - - Object.values(CustomPropertyType).forEach((type) => { - addCustomPropertiesForEntity({ - propertyName: lowerCase(type), - customPropertyData: glossaryTerm, - customType: type, - }); - - cy.settingClick(glossaryTerm.entityApiType, true); - }); - - visitEntityDetailsPage({ - term: glossary.terms[0].name, - serviceName: glossary.terms[0].fullyQualifiedName, - entity: 'glossaryTerms' as EntityType, - dataTestId: `${glossary.name}-${glossary.terms[0].name}`, - }); - - // set custom property value - Object.values(CustomPropertyType).forEach((type) => { - setValueForProperty( - lowerCase(type), - customPropertyValue[type].value, - lowerCase(type) - ); - validateValueForProperty( - lowerCase(type), - customPropertyValue[type].value, - lowerCase(type) - ); - }); - - // update custom property value - Object.values(CustomPropertyType).forEach((type) => { - setValueForProperty( - lowerCase(type), - customPropertyValue[type].newValue, - lowerCase(type) - ); - validateValueForProperty( - lowerCase(type), - customPropertyValue[type].newValue, - lowerCase(type) - ); - }); - - // delete custom properties - Object.values(CustomPropertyType).forEach((customPropertyType) => { - const type = glossaryTerm.entityApiType as EntityType; - const property = customPropertyValue[customPropertyType].property ?? {}; - - deleteCustomPropertyForEntity({ - property: { - ...property, - name: lowerCase(customPropertyType), - } as CustomProperty, - type, - }); - }); - - // delete glossary and glossary term - cy.sidebarClick(SidebarItem.GLOSSARY); - deleteGlossary(glossary.name); - }); - }); -}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/constant/customProperty.ts b/openmetadata-ui/src/main/resources/ui/playwright/constant/customProperty.ts index cbc9c4827bf..8aca2d61269 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/constant/customProperty.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/constant/customProperty.ts @@ -32,3 +32,213 @@ export const ENTITY_REFERENCE_PROPERTIES = [ 'Entity Reference', 'Entity Reference List', ]; + +export const CUSTOM_PROPERTIES_ENTITIES = { + entity_container: { + name: 'container', + description: 'This is Container custom property', + integerValue: '14', + stringValue: 'This is string propery', + markdownValue: 'This is markdown value', + enumConfig: { + values: ['enum1', 'enum2', 'enum3'], + multiSelect: false, + }, + dateFormatConfig: 'yyyy-MM-dd', + dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss', + timeFormatConfig: 'HH:mm:ss', + entityReferenceConfig: ['User', 'Team'], + entityObj: {}, + entityApiType: 'containers', + }, + + entity_dashboard: { + name: 'dashboard', + description: 'This is Dashboard custom property', + integerValue: '14', + stringValue: 'This is string propery', + markdownValue: 'This is markdown value', + enumConfig: { + values: ['enum1', 'enum2', 'enum3'], + multiSelect: false, + }, + dateFormatConfig: 'yyyy-MM-dd', + dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss', + timeFormatConfig: 'HH:mm:ss', + entityReferenceConfig: ['User', 'Team'], + entityObj: {}, + entityApiType: 'dashboards', + }, + + entity_database: { + name: 'database', + description: 'This is Database custom property', + integerValue: '14', + stringValue: 'This is string propery', + markdownValue: 'This is markdown value', + enumConfig: { + values: ['enum1', 'enum2', 'enum3'], + multiSelect: false, + }, + dateFormatConfig: 'yyyy-MM-dd', + dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss', + timeFormatConfig: 'HH:mm:ss', + entityReferenceConfig: ['User', 'Team'], + entityObj: {}, + entityApiType: 'databases', + }, + + entity_databaseSchema: { + name: 'databaseSchema', + description: 'This is Database Schema custom property', + integerValue: '14', + stringValue: 'This is string propery', + markdownValue: 'This is markdown value', + enumConfig: { + values: ['enum1', 'enum2', 'enum3'], + multiSelect: false, + }, + dateFormatConfig: 'yyyy-MM-dd', + dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss', + timeFormatConfig: 'HH:mm:ss', + entityReferenceConfig: ['User', 'Team'], + entityObj: {}, + entityApiType: 'databaseSchemas', + }, + + entity_glossaryTerm: { + name: 'glossaryTerm', + description: 'This is Glossary Term custom property', + integerValue: '14', + stringValue: 'This is string propery', + markdownValue: 'This is markdown value', + enumConfig: { + values: ['enum1', 'enum2', 'enum3'], + multiSelect: false, + }, + dateFormatConfig: 'yyyy-MM-dd', + dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss', + timeFormatConfig: 'HH:mm:ss', + entityReferenceConfig: ['User', 'Team'], + entityObj: {}, + entityApiType: 'glossaryTerm', + }, + + entity_mlmodel: { + name: 'mlmodel', + description: 'This is ML Model custom property', + integerValue: '14', + stringValue: 'This is string propery', + markdownValue: 'This is markdown value', + enumConfig: { + values: ['enum1', 'enum2', 'enum3'], + multiSelect: false, + }, + dateFormatConfig: 'yyyy-MM-dd', + dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss', + timeFormatConfig: 'HH:mm:ss', + entityReferenceConfig: ['User', 'Team'], + entityObj: {}, + entityApiType: 'mlmodels', + }, + + entity_pipeline: { + name: 'pipeline', + description: 'This is Pipeline custom property', + integerValue: '78', + stringValue: 'This is string propery', + markdownValue: 'This is markdown value', + enumConfig: { + values: ['enum1', 'enum2', 'enum3'], + multiSelect: true, + }, + dateFormatConfig: 'yyyy-MM-dd', + dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss', + timeFormatConfig: 'HH:mm:ss', + entityReferenceConfig: ['User', 'Team'], + entityObj: {}, + entityApiType: 'pipelines', + }, + + entity_searchIndex: { + name: 'searchIndex', + description: 'This is Search Index custom property', + integerValue: '14', + stringValue: 'This is string propery', + markdownValue: 'This is markdown value', + enumConfig: { + values: ['enum1', 'enum2', 'enum3'], + multiSelect: false, + }, + dateFormatConfig: 'yyyy-MM-dd', + dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss', + timeFormatConfig: 'HH:mm:ss', + entityReferenceConfig: ['User', 'Team'], + entityObj: {}, + entityApiType: 'searchIndexes', + }, + + entity_storedProcedure: { + name: 'storedProcedure', + description: 'This is Stored Procedure custom property', + integerValue: '14', + stringValue: 'This is string propery', + markdownValue: 'This is markdown value', + enumConfig: { + values: ['enum1', 'enum2', 'enum3'], + multiSelect: false, + }, + dateFormatConfig: 'yyyy-MM-dd', + dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss', + timeFormatConfig: 'HH:mm:ss', + entityReferenceConfig: ['User', 'Team'], + entityObj: {}, + entityApiType: 'storedProcedures', + }, + + entity_table: { + name: 'table', + description: 'This is Table custom property', + integerValue: '45', + stringValue: 'This is string propery', + markdownValue: 'This is markdown value', + enumConfig: { + values: ['enum1', 'enum2', 'enum3'], + multiSelect: false, + }, + dateFormatConfig: 'yyyy-MM-dd', + dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss', + timeFormatConfig: 'HH:mm:ss', + entityReferenceConfig: ['User', 'Team'], + entityObj: {}, + entityApiType: 'tables', + }, + + entity_topic: { + name: 'topic', + description: 'This is Topic custom property', + integerValue: '23', + stringValue: 'This is string propery', + markdownValue: 'This is markdown value', + enumConfig: { + values: ['enum1', 'enum2', 'enum3'], + multiSelect: false, + }, + dateFormatConfig: 'yyyy-MM-dd', + dateTimeFormatConfig: 'yyyy-MM-dd HH:mm:ss', + timeFormatConfig: 'HH:mm:ss', + entityReferenceConfig: ['User', 'Team'], + entityObj: {}, + entityApiType: 'topics', + }, +}; + +export const CUSTOM_PROPERTY_INVALID_NAMES = { + CAPITAL_CASE: 'CapitalCase', + WITH_UNDERSCORE: 'with_underscore', + WITH_DOTS: 'with.', + WITH_SPACE: 'with ', +}; + +export const CUSTOM_PROPERTY_NAME_VALIDATION_ERROR = + 'Name must start with lower case with no space, underscore, or dots.'; diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Customproperties-part1.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Customproperties-part1.spec.ts new file mode 100644 index 00000000000..497bb33fe7f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Customproperties-part1.spec.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2024 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import test from '@playwright/test'; +import { CUSTOM_PROPERTIES_ENTITIES } from '../../constant/customProperty'; +import { redirectToHomePage, uuid } from '../../utils/common'; +import { + addCustomPropertiesForEntity, + deleteCreatedProperty, + editCreatedProperty, +} from '../../utils/customProperty'; +import { settingClick } from '../../utils/sidebar'; + +const propertiesList = [ + 'Integer', + 'String', + 'Markdown', + 'Duration', + 'Email', + 'Number', + 'Sql Query', + 'Time Interval', + 'Timestamp', +]; + +// use the admin user to login +test.use({ storageState: 'playwright/.auth/admin.json' }); + +test.describe('Custom properties without custom property config', () => { + test.beforeEach('Visit Home Page', async ({ page }) => { + await redirectToHomePage(page); + }); + + propertiesList.forEach((property) => { + test.describe(`Add update and delete ${property} custom properties`, () => { + Object.values(CUSTOM_PROPERTIES_ENTITIES).forEach(async (entity) => { + const propertyName = `pwcustomproperty${entity.name}test${uuid()}`; + + test(`Add ${property} custom property for ${entity.name}`, async ({ + page, + }) => { + test.slow(true); + + await settingClick(page, entity.entityApiType, true); + + await addCustomPropertiesForEntity({ + page, + propertyName, + customPropertyData: entity, + customType: property, + }); + + await editCreatedProperty(page, propertyName); + + await deleteCreatedProperty(page, propertyName); + }); + }); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Customproperties-part2.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Customproperties-part2.spec.ts new file mode 100644 index 00000000000..48be7ecd4be --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Pages/Customproperties-part2.spec.ts @@ -0,0 +1,186 @@ +/* + * Copyright 2024 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import test from '@playwright/test'; +import { CUSTOM_PROPERTIES_ENTITIES } from '../../constant/customProperty'; +import { redirectToHomePage, uuid } from '../../utils/common'; +import { + addCustomPropertiesForEntity, + deleteCreatedProperty, + editCreatedProperty, +} from '../../utils/customProperty'; +import { settingClick } from '../../utils/sidebar'; + +// use the admin user to login +test.use({ storageState: 'playwright/.auth/admin.json' }); + +test.describe('Custom properties with custom property config', () => { + test.beforeEach('Visit Home Page', async ({ page }) => { + await redirectToHomePage(page); + }); + + test.describe('Add update and delete Enum custom properties', () => { + Object.values(CUSTOM_PROPERTIES_ENTITIES).forEach(async (entity) => { + const propertyName = `pwcustomproperty${entity.name}test${uuid()}`; + + test(`Add Enum custom property for ${entity.name}`, async ({ page }) => { + test.slow(true); + + await settingClick(page, entity.entityApiType, true); + + await addCustomPropertiesForEntity({ + page, + propertyName, + customPropertyData: entity, + customType: 'Enum', + enumConfig: entity.enumConfig, + }); + + await editCreatedProperty(page, propertyName); + + await deleteCreatedProperty(page, propertyName); + }); + }); + }); + + test.describe( + 'Add update and delete Entity Reference custom properties', + () => { + Object.values(CUSTOM_PROPERTIES_ENTITIES).forEach(async (entity) => { + const propertyName = `pwcustomproperty${entity.name}test${uuid()}`; + + test(`Add Entity Reference custom property for ${entity.name}`, async ({ + page, + }) => { + test.slow(true); + + await settingClick(page, entity.entityApiType, true); + + await addCustomPropertiesForEntity({ + page, + propertyName, + customPropertyData: entity, + customType: 'Entity Reference', + entityReferenceConfig: entity.entityReferenceConfig, + }); + + await editCreatedProperty(page, propertyName); + + await deleteCreatedProperty(page, propertyName); + }); + }); + } + ); + + test.describe( + 'Add update and delete Entity Reference List custom properties', + () => { + Object.values(CUSTOM_PROPERTIES_ENTITIES).forEach(async (entity) => { + const propertyName = `pwcustomproperty${entity.name}test${uuid()}`; + + test(`Add Entity Reference list custom property for ${entity.name}`, async ({ + page, + }) => { + test.slow(true); + + await settingClick(page, entity.entityApiType, true); + + await addCustomPropertiesForEntity({ + page, + propertyName, + customPropertyData: entity, + customType: 'Entity Reference List', + entityReferenceConfig: entity.entityReferenceConfig, + }); + + await editCreatedProperty(page, propertyName); + + await deleteCreatedProperty(page, propertyName); + }); + }); + } + ); + + test.describe('Add update and delete Date custom properties', () => { + Object.values(CUSTOM_PROPERTIES_ENTITIES).forEach(async (entity) => { + const propertyName = `pwcustomproperty${entity.name}test${uuid()}`; + + test(`Add Date custom property for ${entity.name}`, async ({ page }) => { + test.slow(true); + + await settingClick(page, entity.entityApiType, true); + + await addCustomPropertiesForEntity({ + page, + propertyName, + customPropertyData: entity, + customType: 'Date', + formatConfig: entity.dateFormatConfig, + }); + + await editCreatedProperty(page, propertyName); + + await deleteCreatedProperty(page, propertyName); + }); + }); + }); + + test.describe('Add update and delete Time custom properties', () => { + Object.values(CUSTOM_PROPERTIES_ENTITIES).forEach(async (entity) => { + const propertyName = `pwcustomproperty${entity.name}test${uuid()}`; + + test(`Add Time custom property for ${entity.name}`, async ({ page }) => { + test.slow(true); + + await settingClick(page, entity.entityApiType, true); + + await addCustomPropertiesForEntity({ + page, + propertyName, + customPropertyData: entity, + customType: 'Time', + formatConfig: entity.timeFormatConfig, + }); + + await editCreatedProperty(page, propertyName); + + await deleteCreatedProperty(page, propertyName); + }); + }); + }); + + test.describe('Add update and delete DateTime custom properties', () => { + Object.values(CUSTOM_PROPERTIES_ENTITIES).forEach(async (entity) => { + const propertyName = `pwcustomproperty${entity.name}test${uuid()}`; + + test(`Add DateTime custom property for ${entity.name}`, async ({ + page, + }) => { + test.slow(true); + + await settingClick(page, entity.entityApiType, true); + + await addCustomPropertiesForEntity({ + page, + propertyName, + customPropertyData: entity, + customType: 'Date Time', + formatConfig: entity.dateTimeFormatConfig, + }); + + await editCreatedProperty(page, propertyName); + + await deleteCreatedProperty(page, propertyName); + }); + }); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/playwright/utils/customProperty.ts b/openmetadata-ui/src/main/resources/ui/playwright/utils/customProperty.ts index 834dc5ed78c..32e7b5a35b7 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/utils/customProperty.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/utils/customProperty.ts @@ -11,12 +11,17 @@ * limitations under the License. */ import { APIRequestContext, expect, Page } from '@playwright/test'; +import { + CUSTOM_PROPERTY_INVALID_NAMES, + CUSTOM_PROPERTY_NAME_VALIDATION_ERROR, + ENTITY_REFERENCE_PROPERTIES, +} from '../constant/customProperty'; import { EntityTypeEndpoint, ENTITY_PATH, } from '../support/entity/Entity.interface'; import { UserClass } from '../support/user/UserClass'; -import { uuid } from './common'; +import { descriptionBox, uuid } from './common'; export enum CustomPropertyType { STRING = 'String', @@ -35,6 +40,10 @@ export enum CustomPropertyTypeByName { TIMESTAMP = 'timestamp', ENTITY_REFERENCE = 'entityReference', ENTITY_REFERENCE_LIST = 'entityReferenceList', + TIME_INTERVAL = 'timeInterval', + TIME_CP = 'time-cp', + DATE_CP = 'date-cp', + DATE_TIME_CP = 'dateTime-cp', } export interface CustomProperty { @@ -132,6 +141,31 @@ export const setValueForProperty = async (data: { break; } + case 'time-cp': { + await page.locator('[data-testid="time-picker"]').isVisible(); + 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(); + + break; + } + + case 'date-cp': + case 'dateTime-cp': { + await page.locator('[data-testid="date-time-picker"]').isVisible(); + await page.locator('[data-testid="date-time-picker"]').click(); + await page.locator('[data-testid="date-time-picker"]').fill(value); + if (propertyType === 'dateTime-cp') { + await page.getByText('Now', { exact: true }).click(); + } else { + await page.getByText('Today', { exact: true }).click(); + } + await page.locator('[data-testid="inline-save-btn"]').click(); + + break; + } + case 'string': case 'integer': case 'number': @@ -191,7 +225,12 @@ export const validateValueForProperty = async (data: { page.getByLabel('Custom Properties').locator('.CodeMirror-scroll') ).toContainText(value); } else if ( - !['entityReference', 'entityReferenceList'].includes(propertyType) + ![ + 'entityReference', + 'entityReferenceList', + 'date-cp', + 'dateTime-cp', + ].includes(propertyType) ) { await expect(page.getByRole('row', { name: propertyName })).toContainText( value.replace(/\*|_/gi, '') @@ -263,6 +302,30 @@ export const getPropertyValues = ( newValue: users.user4, }; + case 'timeInterval': + return { + value: '1710831125922,1710831125924', + newValue: '1710831125924,1710831125922', + }; + + case 'time-cp': + return { + value: '15:35:59', + newValue: '17:35:59', + }; + + case 'date-cp': + return { + value: '2024-07-09', + newValue: '2025-07-09', + }; + + case 'dateTime-cp': + return { + value: '2024-07-09 15:07:59', + newValue: '2025-07-09 15:07:59', + }; + default: return { value: '', @@ -346,6 +409,30 @@ export const createCustomPropertyForEntity = async ( }, } : {}), + + ...(item.name === 'time-cp' + ? { + customPropertyConfig: { + config: 'HH:mm:ss', + }, + } + : {}), + + ...(item.name === 'date-cp' + ? { + customPropertyConfig: { + config: 'yyyy-MM-dd', + }, + } + : {}), + + ...(item.name === 'dateTime-cp' + ? { + customPropertyConfig: { + config: 'yyyy-MM-dd HH:mm:ss', + }, + } + : {}), }, } ); @@ -368,3 +455,229 @@ export const createCustomPropertyForEntity = async ( return { customProperties, cleanupUser }; }; + +export const addCustomPropertiesForEntity = async ({ + page, + propertyName, + customPropertyData, + customType, + enumConfig, + formatConfig, + entityReferenceConfig, +}: { + page: Page; + propertyName: string; + customPropertyData: { description: string }; + customType: string; + enumConfig?: { values: string[]; multiSelect: boolean }; + formatConfig?: string; + entityReferenceConfig?: string[]; +}) => { + // Add Custom property for selected entity + await page.click('[data-testid="add-field-button"]'); + + // Trigger validation + await page.click('[data-testid="create-button"]'); + + await expect(page.locator('#name_help')).toContainText('Name is required'); + await expect(page.locator('#propertyType_help')).toContainText( + 'Property Type is required' + ); + await expect(page.locator('#description_help')).toContainText( + 'Description is required' + ); + + // Validation checks + await page.fill( + '[data-testid="name"]', + CUSTOM_PROPERTY_INVALID_NAMES.CAPITAL_CASE + ); + + await expect(page.locator('#name_help')).toContainText( + CUSTOM_PROPERTY_NAME_VALIDATION_ERROR + ); + + await page.fill( + '[data-testid="name"]', + CUSTOM_PROPERTY_INVALID_NAMES.WITH_UNDERSCORE + ); + + await expect(page.locator('#name_help')).toContainText( + CUSTOM_PROPERTY_NAME_VALIDATION_ERROR + ); + + await page.fill( + '[data-testid="name"]', + CUSTOM_PROPERTY_INVALID_NAMES.WITH_SPACE + ); + + await expect(page.locator('#name_help')).toContainText( + CUSTOM_PROPERTY_NAME_VALIDATION_ERROR + ); + + await page.fill( + '[data-testid="name"]', + CUSTOM_PROPERTY_INVALID_NAMES.WITH_DOTS + ); + + await expect(page.locator('#name_help')).toContainText( + CUSTOM_PROPERTY_NAME_VALIDATION_ERROR + ); + + // Name in another language + await page.fill('[data-testid="name"]', '汝らヴェディア'); + + await expect(page.locator('#name_help')).not.toBeVisible(); + + // Correct name + await page.fill('[data-testid="name"]', propertyName); + + // Select custom type + await page.locator('[id="root\\/propertyType"]').fill(customType); + await page.getByTitle(`${customType}`, { exact: true }).click(); + + // Enum configuration + if (customType === 'Enum' && enumConfig) { + for (const val of enumConfig.values) { + await page.fill('#root\\/enumConfig', `${val}{Enter}`); + } + + if (enumConfig.multiSelect) { + await page.click('#root\\/multiSelect'); + } + } + + // Entity reference configuration + if ( + ENTITY_REFERENCE_PROPERTIES.includes(customType) && + entityReferenceConfig + ) { + for (const val of entityReferenceConfig) { + await page.click('#root\\/entityReferenceConfig'); + await page.fill('#root\\/entityReferenceConfig', val); + await page.click(`[title="${val}"]`); + } + } + + // Format configuration + if (['Date', 'Date Time', 'Time'].includes(customType)) { + await page.fill('#root\\/formatConfig', 'invalid-format'); + + await expect(page.locator('#formatConfig_help')).toContainText( + 'Format is invalid' + ); + + if (formatConfig) { + await page.fill('#root\\/formatConfig', formatConfig); + } + } + + // Description + await page.fill(descriptionBox, customPropertyData.description); + + const createPropertyPromise = page.waitForResponse( + '/api/v1/metadata/types/name/*?fields=customProperties' + ); + + await page.click('[data-testid="create-button"]'); + + const response = await createPropertyPromise; + + expect(response.status()).toBe(200); + + await expect( + page.getByRole('row', { name: new RegExp(propertyName, 'i') }) + ).toBeVisible(); +}; + +export const editCreatedProperty = async ( + page: Page, + propertyName: string, + type?: string +) => { + // Fetching for edit button + const editButton = page.locator( + `[data-row-key="${propertyName}"] [data-testid="edit-button"]` + ); + + if (type === 'Enum') { + await expect( + page.locator( + `[data-row-key="${propertyName}"] [data-testid="enum-config"]` + ) + ).toContainText('["enum1","enum2","enum3"]'); + } + + await editButton.click(); + + await page.locator(descriptionBox).fill(''); + await page.locator(descriptionBox).fill('This is new description'); + + if (type === 'Enum') { + await page + .locator('#root\\/customPropertyConfig') + .fill(`updatedValue{Enter}`); + await page.click('body'); // Equivalent to clicking outside + } + + if (ENTITY_REFERENCE_PROPERTIES.includes(type ?? '')) { + await page.locator('#root\\/customPropertyConfig').click(); + await page.locator('#root\\/customPropertyConfig').fill(`Table{Enter}`); + await page.click('body'); // Equivalent to clicking outside + } + + const patchRequest = page.waitForResponse('/api/v1/metadata/types/*'); + + await page.locator('button[type="submit"]').click(); + + const response = await patchRequest; + + expect(response.status()).toBe(200); + + await expect(page.locator('.ant-modal-wrap')).not.toBeVisible(); + + // Fetching for updated descriptions for the created custom property + await expect( + page.locator( + `[data-row-key="${propertyName}"] [data-testid="viewer-container"]` + ) + ).toContainText('This is new description'); + + if (type === 'Enum') { + await expect( + page.locator( + `[data-row-key="${propertyName}"] [data-testid="enum-config"]` + ) + ).toContainText('["enum1","enum2","enum3","updatedValue"]'); + } + if (ENTITY_REFERENCE_PROPERTIES.includes(type ?? '')) { + await expect( + page.locator( + `[data-row-key="${propertyName}"] [data-testid="${propertyName}-config"]` + ) + ).toContainText('["user","team","table"]'); + } +}; + +export const deleteCreatedProperty = async ( + page: Page, + propertyName: string +) => { + // Fetching for delete button + await page + .locator(`[data-row-key="${propertyName}"]`) + .scrollIntoViewIfNeeded(); + await page + .locator(`[data-row-key="${propertyName}"] [data-testid="delete-button"]`) + .click(); + + // Checking property name is present on the delete pop-up + await expect(page.locator('[data-testid="body-text"]')).toContainText( + propertyName + ); + + // Ensure the save button is visible before clicking + await expect(page.locator('[data-testid="save-button"]')).toBeVisible(); + + await page.locator('[data-testid="save-button"]').click(); +}; diff --git a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/OpenMetadata/CustomProperty.md b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/OpenMetadata/CustomProperty.md index 374ee735b61..98b2208bc03 100644 --- a/openmetadata-ui/src/main/resources/ui/public/locales/en-US/OpenMetadata/CustomProperty.md +++ b/openmetadata-ui/src/main/resources/ui/public/locales/en-US/OpenMetadata/CustomProperty.md @@ -35,9 +35,35 @@ $$ $$section ### Format $(id="formatConfig") -To specify a format for the `date` or `dateTime` type, example you can use the following pattern: `dd-mm-yyyy` or `dd-mm-yyyy HH:mm:ss`. +To specify a format for the `date` or `dateTime` type, example you can use the following pattern: `dd-MM-yyyy` or `dd-MM-yyyy HH:mm:ss`. $$ +**Supported Date formats** + +- `yyyy-MM-dd` +- `dd MMM yyyy` +- `MM/dd/yyyy` +- `dd/MM/yyyy` +- `dd-MM-yyyy` +- `yyyyDDD` +- `d MMMM yyyy` + +**Supported DateTime formats** + +- `MMM dd HH:mm:ss yyyy` +- `yyyy-MM-dd HH:mm:ss` +- `MM/dd/yyyy HH:mm:ss` +- `dd/MM/yyyy HH:mm:ss` +- `dd-MM-yyyy HH:mm:ss` +- `yyyy-MM-dd HH:mm:ss.SSS` +- `yyyy-MM-dd HH:mm:ss.SSSSSS` +- `dd MMMM yyyy HH:mm:ss` + +**Supported Time formats** + +- `HH:mm:ss` + + $$section ### Entity Reference type $(id="entityReferenceConfig") diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.test.tsx index 08a9dfe5a05..4a0e4f85dd2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.test.tsx @@ -21,9 +21,9 @@ const mockGoBack = jest.fn(); const mockPropertyTypes = [ { id: '153a0c07-6480-404e-990b-555a42c8a7b5', - name: 'date', - fullyQualifiedName: 'date', - displayName: 'date', + name: 'date-cp', + fullyQualifiedName: 'date-cp', + displayName: 'date-cp', description: '"Date in ISO 8601 format in UTC. Example - \'2018-11-13\'."', category: 'field', nameSpace: 'basic', @@ -35,9 +35,9 @@ const mockPropertyTypes = [ }, { id: '6ce245d8-80c0-4641-9b60-32cf03ca79a2', - name: 'dateTime', - fullyQualifiedName: 'dateTime', - displayName: 'dateTime', + name: 'dateTime-cp', + fullyQualifiedName: 'dateTime-cp', + displayName: 'dateTime-cp', description: '"Date and time in ISO 8601 format. Example - \'2018-11-13T20:20:39+00:00\'."', category: 'field', diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.tsx index 9f3aa1c4607..37e0773edf4 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Settings/CustomProperty/AddCustomProperty/AddCustomProperty.tsx @@ -14,7 +14,7 @@ import { Button, Col, Form, Row } from 'antd'; import { AxiosError } from 'axios'; import { t } from 'i18next'; -import { isUndefined, omit, omitBy, startCase } from 'lodash'; +import { isUndefined, map, omit, omitBy, startCase } from 'lodash'; import React, { FocusEvent, useCallback, @@ -24,10 +24,10 @@ import React, { } from 'react'; import { useHistory, useParams } from 'react-router-dom'; import { - DISABLED_PROPERTY_TYPES, ENTITY_REFERENCE_OPTIONS, PROPERTY_TYPES_WITH_ENTITY_REFERENCE, PROPERTY_TYPES_WITH_FORMAT, + SUPPORTED_FORMAT_MAP, } from '../../../../constants/CustomProperty.constants'; import { GlobalSettingsMenuCategory } from '../../../../constants/GlobalSettings.constants'; import { CUSTOM_PROPERTY_NAME_REGEX } from '../../../../constants/regex.constants'; @@ -49,7 +49,6 @@ import { getTypeByFQN, getTypeListByCategory, } from '../../../../rest/metadataTypeAPI'; -import { isValidDateFormat } from '../../../../utils/date-time/DateTimeUtils'; import { generateFormFields } from '../../../../utils/formUtils'; import { getSettingOptionByEntityType } from '../../../../utils/GlobalSettingsUtils'; import { getSettingPath } from '../../../../utils/RouterUtils'; @@ -58,8 +57,6 @@ import ResizablePanels from '../../../common/ResizablePanels/ResizablePanels'; import ServiceDocPanel from '../../../common/ServiceDocPanel/ServiceDocPanel'; import TitleBreadcrumb from '../../../common/TitleBreadcrumb/TitleBreadcrumb.component'; -type PropertyType = { key: string; label: string; value: string | undefined }; - const AddCustomProperty = () => { const [form] = Form.useForm(); const { entityType } = useParams<{ entityType: EntityType }>(); @@ -97,43 +94,40 @@ const AddCustomProperty = () => { ); const propertyTypeOptions = useMemo(() => { - return propertyTypes.reduce((acc: PropertyType[], type) => { - if (DISABLED_PROPERTY_TYPES.includes(type.name)) { - return acc; - } - - return [ - ...acc, - { - key: type.name, - label: startCase(type.displayName ?? type.name), - value: type.id, - }, - ]; - }, []); + return map(propertyTypes, (type) => ({ + key: type.name, + // Remove -cp from the name and convert to start case + label: startCase((type.displayName ?? type.name).replace(/-cp/g, '')), + value: type.id, + })); }, [propertyTypes]); - const { hasEnumConfig, hasFormatConfig, hasEntityReferenceConfig } = - useMemo(() => { - const watchedOption = propertyTypeOptions.find( - (option) => option.value === watchedPropertyType - ); - const watchedOptionKey = watchedOption?.key ?? ''; + const { + hasEnumConfig, + hasFormatConfig, + hasEntityReferenceConfig, + watchedOption, + } = useMemo(() => { + const watchedOption = propertyTypeOptions.find( + (option) => option.value === watchedPropertyType + ); + const watchedOptionKey = watchedOption?.key ?? ''; - const hasEnumConfig = watchedOptionKey === 'enum'; + const hasEnumConfig = watchedOptionKey === 'enum'; - const hasFormatConfig = - PROPERTY_TYPES_WITH_FORMAT.includes(watchedOptionKey); + const hasFormatConfig = + PROPERTY_TYPES_WITH_FORMAT.includes(watchedOptionKey); - const hasEntityReferenceConfig = - PROPERTY_TYPES_WITH_ENTITY_REFERENCE.includes(watchedOptionKey); + const hasEntityReferenceConfig = + PROPERTY_TYPES_WITH_ENTITY_REFERENCE.includes(watchedOptionKey); - return { - hasEnumConfig, - hasFormatConfig, - hasEntityReferenceConfig, - }; - }, [watchedPropertyType, propertyTypeOptions]); + return { + hasEnumConfig, + hasFormatConfig, + hasEntityReferenceConfig, + watchedOption, + }; + }, [watchedPropertyType, propertyTypeOptions]); const fetchPropertyType = async () => { try { @@ -337,7 +331,13 @@ const AddCustomProperty = () => { rules: [ { validator: (_, value) => { - if (!isValidDateFormat(value)) { + const propertyName = watchedOption?.key ?? ''; + const supportedFormats = + SUPPORTED_FORMAT_MAP[ + propertyName as keyof typeof SUPPORTED_FORMAT_MAP + ]; + + if (!supportedFormats.includes(value)) { return Promise.reject( t('label.field-invalid', { field: t('label.format'), diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyValue.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyValue.test.tsx index 53f21c6c2ab..887bf23637f 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyValue.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/CustomPropertyTable/PropertyValue.test.tsx @@ -160,7 +160,7 @@ describe('Test PropertyValue Component', () => { const extension = { yNumber: '20-03-2024' }; const propertyType = { ...mockData.property.propertyType, - name: 'date', + name: 'date-cp', }; render( { }; const propertyType = { ...mockData.property.propertyType, - name: 'dateTime', + name: 'dateTime-cp', }; render( { }; const propertyType = { ...mockData.property.propertyType, - name: 'time', + name: 'time-cp', }; render( = ({ ); } - case 'date': - case 'dateTime': { + case 'date-cp': + case 'dateTime-cp': { // Default format is 'yyyy-mm-dd' - const format = - toUpper(property.customPropertyConfig?.config as string) ?? - 'yyyy-mm-dd'; + const format = toUpper( + (property.customPropertyConfig?.config as string) ?? 'yyyy-mm-dd' + ); const initialValues = { dateTimeValue: value ? moment(value, format) : undefined, @@ -263,7 +263,7 @@ export const PropertyValue: FC = ({ data-testid="date-time-picker" disabled={isLoading} format={format} - showTime={propertyType.name === 'dateTime'} + showTime={propertyType.name === 'dateTime-cp'} style={{ width: '250px' }} /> @@ -272,8 +272,9 @@ export const PropertyValue: FC = ({ ); } - case 'time': { - const format = 'HH:mm:ss'; + case 'time-cp': { + const format = + (property.customPropertyConfig?.config as string) ?? 'HH:mm:ss'; const initialValues = { time: value ? moment(value, format) : undefined, }; @@ -303,6 +304,7 @@ export const PropertyValue: FC = ({ allowClear data-testid="time-picker" disabled={isLoading} + format={format} style={{ width: '250px' }} /> @@ -788,9 +790,9 @@ export const PropertyValue: FC = ({ case 'string': case 'integer': case 'number': - case 'date': - case 'dateTime': - case 'time': + case 'date-cp': + case 'dateTime-cp': + case 'time-cp': case 'email': case 'timestamp': case 'duration': diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/CustomProperty.constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/CustomProperty.constants.ts index 6b9f91576af..efbe1bec3e2 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/CustomProperty.constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/CustomProperty.constants.ts @@ -10,14 +10,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export const PROPERTY_TYPES_WITH_FORMAT = ['date', 'dateTime']; - -export const DISABLED_PROPERTY_TYPES = [ - 'time', - 'dateTime', - 'timeInterval', - 'date', -]; +export const PROPERTY_TYPES_WITH_FORMAT = ['date-cp', 'dateTime-cp', 'time-cp']; export const PROPERTY_TYPES_WITH_ENTITY_REFERENCE = [ 'entityReference', @@ -101,3 +94,35 @@ export const ENTITY_REFERENCE_OPTIONS = [ label: 'Team', }, ]; + +// supported date formats on backend +export const SUPPORTED_DATE_FORMATS = [ + 'yyyy-MM-dd', + 'dd MMM yyyy', + 'MM/dd/yyyy', + 'dd/MM/yyyy', + 'dd-MM-yyyy', + 'yyyyDDD', + 'd MMMM yyyy', +]; + +// supported date time formats on backend +export const SUPPORTED_DATE_TIME_FORMATS = [ + 'MMM dd HH:mm:ss yyyy', + 'yyyy-MM-dd HH:mm:ss', + 'MM/dd/yyyy HH:mm:ss', + 'dd/MM/yyyy HH:mm:ss', + 'dd-MM-yyyy HH:mm:ss', + 'yyyy-MM-dd HH:mm:ss.SSS', + 'yyyy-MM-dd HH:mm:ss.SSSSSS', + 'dd MMMM yyyy HH:mm:ss', +]; + +// supported time formats on backend +export const SUPPORTED_TIME_FORMATS = ['HH:mm:ss']; + +export const SUPPORTED_FORMAT_MAP = { + 'date-cp': SUPPORTED_DATE_FORMATS, + 'dateTime-cp': SUPPORTED_DATE_TIME_FORMATS, + 'time-cp': SUPPORTED_TIME_FORMATS, +};