Add entityReferences and entityReferenceList as custom properties (#15448)

* Add entityReferences and entityReferenceList as custom properties

* ui: allow to add custom properties for all available property types.

* make entity type as an array

* Fix label typo and import order in AddCustomProperty component

* Remove unnecessary property types with format

* Refactor CustomPropertyTable.tsx to handle array config and enum config

* Add entity reference options and update custom property form

* Add EntityReference and EntityReferenceList as a custom property

* add validation for date format

* Add date and dateTime input support to PropertyValue component

* Fix import order and add search functionality to type dropdown

* Fix custom property cypress tests

* add input for number, email, timestamp, timeInterval, duration, time, sqlQuery,

* add input support for entityReference and entityReferenceList

* Add placeholders for email, timestamp, start time, end time, and duration inputs

* Refactor PropertyValue component to use destructuring for timeInterval object

* Add minWidth style to PropertyValue component and include SQL query editor

* Add entityReference and entityReferenceList as a generic types and not reference to the existing types

* Remove services from entity reference types list

* handle property values for different property type

* Update ExtensionTable to handle object values in CustomPropertyTable

* Add entity reference list rendering and styling

* Fix file paths in complexTypes.json

* Add regex constant for UNIX timestamp in milliseconds and update language files

* Refactor custom property configuration and add new options

* Fix import order and update property value handling

* add unit tests for different input types for different property type

* add unit test for different property type values

* fix cypress tests

* add cypress test for CRUD for different types

* add cypress test for entity ref and entity ref list property type

* add cypress tests for all the 11 entities which support custom properties

* fix cypress test for createCustomPropertyForEntity

* Add new interfaces and types for CustomPropertyTable

* add cypress test for custom property to create via APIs for entities

* add cypress tests for string,integer, markdown, number, duration, email

* add cypress test for enum property for all entities

* add cypress test for sqlQuery, timestamp and timeInterval property

* fix cypress test for sqlQuery input

* fix flaky cypress test for sqlQuery input

* address comments

* Refactor import statements and update ENTITY_PATH enum

* Update import statement in CustomProperty.ts

* change TimeInterval to Time Interval

---------

Co-authored-by: Sachin Chaurasiya <sachinchaurasiyachotey87@gmail.com>
This commit is contained in:
Sriharsha Chintalapani 2024-03-25 22:32:44 -07:00 committed by GitHub
parent 891e0bf893
commit 7a3a271f26
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 2472 additions and 487 deletions

View File

@ -386,6 +386,18 @@
<version>3.25.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.skyscreamer</groupId>
<artifactId>jsonassert</artifactId>
<version>1.5.1</version>
<exclusions>
<exclusion>
<groupId>com.vaadin.external.google</groupId>
<artifactId>android-json</artifactId>
</exclusion>
</exclusions>
<scope>test</scope>
</dependency>
<!-- JSON-P: Java API for JSON Processing (JSR 374) -->
<dependency>
<groupId>javax.json</groupId>

View File

@ -53,6 +53,7 @@ import org.openmetadata.service.util.EntityUtil;
import org.openmetadata.service.util.JsonUtils;
import org.openmetadata.service.util.TestUtils;
import org.openmetadata.service.util.TestUtils.UpdateType;
import org.skyscreamer.jsonassert.JSONAssert;
@Slf4j
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@ -364,6 +365,14 @@ public class TypeResourceTest extends EntityResourceTest<Type, CreateType> {
assertEquals(expected.getSchema(), patched.getSchema());
assertEquals(expected.getCategory(), patched.getCategory());
assertEquals(expected.getNameSpace(), patched.getNameSpace());
try {
JSONAssert.assertEquals(
JsonUtils.pojoToJson(expected.getCustomProperties()),
JsonUtils.pojoToJson(patched.getCustomProperties()),
false);
} catch (Exception e) {
throw new IllegalStateException(e.getMessage());
}
}
@Override
@ -380,6 +389,11 @@ public class TypeResourceTest extends EntityResourceTest<Type, CreateType> {
} else if (fieldName.contains("customPropertyConfig")) {
String expectedStr = JsonUtils.pojoToJson(expected);
String actualStr = JsonUtils.pojoToJson(actual);
try {
JSONAssert.assertEquals(expectedStr, actualStr, false);
} catch (Exception e) {
throw new IllegalStateException(e.getMessage());
}
} else {
assertCommonFieldChange(fieldName, expected, actual);
}

View File

@ -0,0 +1,98 @@
{
"$id": "https://open-metadata.org/schema/type/customProperties/complexTypes.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Basic",
"description": "This schema defines custom properties complex types.",
"definitions": {
"entityReference": {
"$comment": "@om-field-type",
"description": "Entity Reference for Custom Property.",
"properties": {
"id": {
"description": "Unique identifier that identifies an entity instance.",
"$ref": "../basic.json#/definitions/uuid"
},
"type": {
"description": "Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, `dashboardService`...",
"type": "string"
},
"name": {
"description": "Name of the entity instance.",
"type": "string"
},
"fullyQualifiedName": {
"description": "Fully qualified name of the entity instance. For entities such as tables, databases fullyQualifiedName is returned in this field. For entities that don't have name hierarchy such as `user` and `team` this will be same as the `name` field.",
"type": "string"
},
"description": {
"description": "Optional description of entity.",
"$ref": "../basic.json#/definitions/markdown"
},
"displayName": {
"description": "Display Name that identifies this entity.",
"type": "string"
},
"deleted": {
"description": "If true the entity referred to has been soft-deleted.",
"type": "boolean"
},
"inherited": {
"description": "If true the relationship indicated by this entity reference is inherited from the parent entity.",
"type": "boolean"
},
"href": {
"description": "Link to the entity resource.",
"$ref": "../basic.json#/definitions/href"
}
}
},
"entityReferenceList": {
"$comment": "@om-field-type",
"description": "Entity Reference List for Custom Property.",
"type": "array",
"properties": {
"items": {
"type": "object",
"properties": {
"id": {
"description": "Unique identifier that identifies an entity instance.",
"$ref": "../basic.json#/definitions/uuid"
},
"type": {
"description": "Entity type/class name - Examples: `database`, `table`, `metrics`, `databaseService`, `dashboardService`...",
"type": "string"
},
"name": {
"description": "Name of the entity instance.",
"type": "string"
},
"fullyQualifiedName": {
"description": "Fully qualified name of the entity instance. For entities such as tables, databases fullyQualifiedName is returned in this field. For entities that don't have name hierarchy such as `user` and `team` this will be same as the `name` field.",
"type": "string"
},
"description": {
"description": "Optional description of entity.",
"$ref": "../basic.json#/definitions/markdown"
},
"displayName": {
"description": "Display Name that identifies this entity.",
"type": "string"
},
"deleted": {
"description": "If true the entity referred to has been soft-deleted.",
"type": "boolean"
},
"inherited": {
"description": "If true the relationship indicated by this entity reference is inherited from the parent entity.",
"type": "boolean"
},
"href": {
"description": "Link to the entity resource.",
"$ref": "../basic.json#/definitions/href"
}
}
}
}
}
}
}

View File

@ -12,7 +12,10 @@
},
"entityTypes": {
"description": "Applies to Entity References. Entity Types can be used to restrict what type of entities can be configured for a entity reference.",
"type": "string"
"type": "array",
"items": {
"type": "string"
}
},
"customPropertyConfig": {
"type": "object",

View File

@ -13,7 +13,7 @@
import { uuid } from '../../constants/constants';
import { CustomPropertySupportedEntityList } from '../../constants/CustomProperty.constant';
import { EntityType } from '../../constants/Entity.interface';
import { EntityType, ENTITY_PATH } from '../../constants/Entity.interface';
import {
createAnnouncement as createAnnouncementUtil,
createInactiveAnnouncement as createInactiveAnnouncementUtil,
@ -22,9 +22,7 @@ import {
import {
createCustomPropertyForEntity,
CustomProperty,
CustomPropertyType,
deleteCustomPropertyForEntity,
generateCustomProperty,
deleteCustomProperties,
setValueForProperty,
validateValueForProperty,
} from '../Utils/CustomProperty';
@ -136,12 +134,8 @@ class EntityClass {
endPoint: EntityType;
protected name: string;
intergerPropertyDetails: CustomProperty;
stringPropertyDetails: CustomProperty;
markdownPropertyDetails: CustomProperty;
customPropertyValue: Record<
CustomPropertyType,
string,
{ value: string; newValue: string; property: CustomProperty }
>;
@ -153,34 +147,6 @@ class EntityClass {
this.entityName = entityName;
this.entityDetails = entityDetails;
this.endPoint = endPoint;
this.intergerPropertyDetails = generateCustomProperty(
CustomPropertyType.INTEGER
);
this.stringPropertyDetails = generateCustomProperty(
CustomPropertyType.STRING
);
this.markdownPropertyDetails = generateCustomProperty(
CustomPropertyType.MARKDOWN
);
this.customPropertyValue = {
Integer: {
value: '123',
newValue: '456',
property: this.intergerPropertyDetails,
},
String: {
value: '123',
newValue: '456',
property: this.stringPropertyDetails,
},
Markdown: {
value: '**Bold statement**',
newValue: '__Italic statement__',
property: this.markdownPropertyDetails,
},
};
}
public getName() {
@ -204,19 +170,15 @@ class EntityClass {
// Create custom property only for supported entities
if (CustomPropertySupportedEntityList.includes(this.endPoint)) {
createCustomPropertyForEntity({
property: this.intergerPropertyDetails,
type: this.endPoint,
});
createCustomPropertyForEntity({
property: this.stringPropertyDetails,
type: this.endPoint,
});
createCustomPropertyForEntity({
property: this.markdownPropertyDetails,
type: this.endPoint,
createCustomPropertyForEntity(this.endPoint).then((data) => {
this.customPropertyValue = data as unknown as Record<
string,
{
value: string;
newValue: string;
property: CustomProperty;
}
>;
});
}
}
@ -280,17 +242,15 @@ class EntityClass {
cleanup() {
// Delete custom property only for supported entities
if (CustomPropertySupportedEntityList.includes(this.endPoint)) {
deleteCustomPropertyForEntity({
property: this.intergerPropertyDetails,
type: this.endPoint,
});
deleteCustomPropertyForEntity({
property: this.stringPropertyDetails,
type: this.endPoint,
});
deleteCustomPropertyForEntity({
property: this.markdownPropertyDetails,
type: this.endPoint,
cy.getAllLocalStorage().then((data) => {
const token = getToken(data);
cy.request({
method: 'GET',
url: `/api/v1/metadata/types/name/${ENTITY_PATH[this.endPoint]}`,
headers: { Authorization: `Bearer ${token}` },
}).then(({ body }) => {
deleteCustomProperties(body.id, token);
});
});
}
}
@ -520,13 +480,29 @@ class EntityClass {
// Custom property
setCustomProperty(propertydetails: CustomProperty, value: string) {
setValueForProperty(propertydetails.name, value);
validateValueForProperty(propertydetails.name, value);
setValueForProperty(
propertydetails.name,
value,
propertydetails.propertyType.name
);
validateValueForProperty(
propertydetails.name,
value,
propertydetails.propertyType.name
);
}
updateCustomProperty(propertydetails: CustomProperty, value: string) {
setValueForProperty(propertydetails.name, value);
validateValueForProperty(propertydetails.name, value);
setValueForProperty(
propertydetails.name,
value,
propertydetails.propertyType.name
);
validateValueForProperty(
propertydetails.name,
value,
propertydetails.propertyType.name
);
}
}

View File

@ -15,24 +15,42 @@ import {
CUSTOM_PROPERTY_INVALID_NAMES,
CUSTOM_PROPERTY_NAME_VALIDATION_ERROR,
} from '../../constants/constants';
import { EntityType } from '../../constants/Entity.interface';
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',
TIME_INTERVAL = 'timeInterval',
}
export interface CustomProperty {
name: string;
type: CustomPropertyType;
description: string;
propertyType: {
name: string;
type: string;
};
}
export const generateCustomProperty = (type: CustomPropertyType) => ({
@ -41,40 +59,67 @@ export const generateCustomProperty = (type: CustomPropertyType) => ({
description: `${type} cypress Property`,
});
export const createCustomPropertyForEntity = ({
property,
type,
}: {
property: CustomProperty;
type: EntityType;
}) => {
interceptURL('GET', `/api/v1/metadata/types/name/*`, 'getEntity');
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__',
};
// Selecting the entity
cy.settingClick(type, true);
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',
};
verifyResponseStatusCode('@getEntity', 200);
case 'timestamp':
return {
value: '1710831125922',
newValue: '1710831125923',
};
case 'timeInterval':
return {
value: '1710831125922,1710831125923',
newValue: '1710831125923,1710831125924',
};
// Add Custom property for selected entity
cy.get('[data-testid="add-field-button"]').click();
cy.get('[data-testid="name"]').clear().type(property.name);
cy.get('[data-testid="propertyType"]').click();
cy.get(`[title="${property.type}"]`).click();
cy.get('.toastui-editor-md-container > .toastui-editor > .ProseMirror')
.clear()
.type(property.description);
// 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', property.name);
default:
return {
value: '',
newValue: '',
};
}
};
export const deleteCustomPropertyForEntity = ({
@ -102,7 +147,11 @@ export const deleteCustomPropertyForEntity = ({
verifyResponseStatusCode('@patchEntity', 200);
};
export const setValueForProperty = (propertyName, value: string) => {
export const setValueForProperty = (
propertyName: string,
value: string,
propertyType: string
) => {
cy.get('[data-testid="custom_properties"]').click();
cy.get('tbody').should('contain', propertyName);
@ -119,28 +168,90 @@ export const setValueForProperty = (propertyName, value: string) => {
interceptURL('PATCH', `/api/v1/*/*`, 'patchEntity');
// Checking for value text box or markdown box
cy.get('body').then(($body) => {
if ($body.find('[data-testid="value-input"]').length > 0) {
cy.get('[data-testid="value-input"]').clear().type(value);
cy.get('[data-testid="inline-save-btn"]').click();
} else if (
$body.find(
'.toastui-editor-md-container > .toastui-editor > .ProseMirror'
)
) {
switch (propertyType) {
case 'markdown':
cy.get('.toastui-editor-md-container > .toastui-editor > .ProseMirror')
.clear()
.type(value);
cy.get('[data-testid="save"]').click();
break;
case 'email':
cy.get('[data-testid="email-input"]').clear().type(value);
cy.get('[data-testid="inline-save-btn"]').click();
break;
case 'duration':
cy.get('[data-testid="duration-input"]').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"]').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"]').clear().type(startValue);
cy.get('[data-testid="end-input"]').clear().type(endValue);
cy.get('[data-testid="inline-save-btn"]').click();
break;
}
});
case 'string':
case 'integer':
case 'number':
cy.get('[data-testid="value-input"]').clear().type(value);
cy.get('[data-testid="inline-save-btn"]').click();
break;
default:
break;
}
verifyResponseStatusCode('@patchEntity', 200);
cy.get(`[data-row-key="${propertyName}"]`).should(
'contain',
value.replace(/\*|_/gi, '')
);
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 {
cy.get(`[data-row-key="${propertyName}"]`).should(
'contain',
value.replace(/\*|_/gi, '')
);
}
};
export const validateValueForProperty = (propertyName, value: string) => {
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"]',
@ -148,10 +259,21 @@ export const validateValueForProperty = (propertyName, value: string) => {
timeout: 10000,
}
).scrollIntoView();
cy.get(`[data-row-key="${propertyName}"]`).should(
'contain',
value.replace(/\*|_/gi, '')
);
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 {
cy.get(`[data-row-key="${propertyName}"]`).should(
'contain',
value.replace(/\*|_/gi, '')
);
}
};
export const generateCustomProperties = () => {
return {
@ -198,12 +320,21 @@ export const customPropertiesArray = Array(10)
.fill(null)
.map(() => generateCustomProperties());
export const addCustomPropertiesForEntity = (
propertyName: string,
customPropertyData: { description: string },
customType: string,
value: { values: string[]; multiSelect: boolean }
) => {
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[];
}) => {
// Add Custom property for selected entity
cy.get('[data-testid="add-field-button"]').click();
@ -258,20 +389,35 @@ export const addCustomPropertiesForEntity = (
cy.get('[data-testid="name"]').clear().type(propertyName);
cy.get('[data-testid="propertyType"]').click();
cy.get(`#root\\/propertyType`).clear().type(customType);
cy.get(`[title="${customType}"]`).click();
if (customType === 'Enum') {
value.values.forEach((val) => {
cy.get('#root\\/customPropertyConfig').type(`${val}{enter}`);
enumConfig.values.forEach((val) => {
cy.get('#root\\/enumConfig').type(`${val}{enter}`);
});
cy.clickOutside();
if (value.multiSelect) {
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);
@ -288,7 +434,7 @@ export const addCustomPropertiesForEntity = (
cy.clickOnLogo();
};
export const editCreatedProperty = (propertyName: string, type: string) => {
export const editCreatedProperty = (propertyName: string, type?: string) => {
// Fetching for edit button
cy.get(`[data-row-key="${propertyName}"]`)
.find('[data-testid="edit-button"]')
@ -310,6 +456,12 @@ export const editCreatedProperty = (propertyName: string, type: string) => {
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();
@ -328,6 +480,11 @@ export const editCreatedProperty = (propertyName: string, type: string) => {
.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) => {
@ -342,3 +499,84 @@ export const deleteCreatedProperty = (propertyName: string) => {
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'],
},
},
}
: {}),
},
})
.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;
}
>
);
});
});
});
});
});
};

View File

@ -25,3 +25,8 @@ export const CustomPropertySupportedEntityList = [
EntityType.GlossaryTerm,
EntityType.SearchIndex,
];
export const ENTITY_REFERENCE_PROPERTIES = [
'Entity Reference',
'Entity Reference List',
];

View File

@ -110,3 +110,19 @@ export enum SidebarItem {
SETTINGS = 'settings',
LOGOUT = 'logout',
}
export enum ENTITY_PATH {
tables = 'table',
topics = 'topic',
dashboards = 'dashboard',
pipelines = 'pipeline',
mlmodels = 'mlmodel',
containers = 'container',
tags = 'tag',
glossaries = 'glossary',
searchIndexes = 'searchIndex',
storedProcedures = 'storedProcedure',
glossaryTerm = 'glossaryTerm',
databases = 'database',
databaseSchemas = 'databaseSchema',
}

View File

@ -491,42 +491,108 @@ export const ENTITY_SERVICE_TYPE = {
};
export const ENTITIES = {
entity_table: {
name: 'table',
description: 'This is Table custom property',
integerValue: '45',
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,
},
entityObj: SEARCH_ENTITY_TABLE.table_1,
entityApiType: 'tables',
dateFormatConfig: 'yyyy-mm-dd',
dateTimeFormatConfig: 'yyyy-mm-dd hh:mm:ss',
entityReferenceConfig: ['User', 'Team'],
entityObj: {},
entityApiType: 'containers',
},
entity_topic: {
name: 'topic',
description: 'This is Topic custom property',
integerValue: '23',
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,
},
entityObj: SEARCH_ENTITY_TOPIC.topic_1,
entityApiType: 'topics',
dateFormatConfig: 'yyyy-mm-dd',
dateTimeFormatConfig: 'yyyy-mm-dd hh:mm:ss',
entityReferenceConfig: ['User', 'Team'],
entityObj: SEARCH_ENTITY_DASHBOARD.dashboard_1,
entityApiType: 'dashboards',
},
// commenting the dashboard test for not, need to make changes in dynamic data-test side
// entity_dashboard: {
// name: 'dashboard',
// description: 'This is Dashboard custom property',
// integerValue: '14',
// stringValue: 'This is string propery',
// markdownValue: 'This is markdown value',
// 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',
@ -537,9 +603,80 @@ export const ENTITIES = {
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 = {

View File

@ -21,6 +21,7 @@ import { deleteGlossary } from '../../common/GlossaryUtils';
import {
addCustomPropertiesForEntity,
customPropertiesArray,
CustomProperty,
CustomPropertyType,
deleteCreatedProperty,
deleteCustomProperties,
@ -37,7 +38,6 @@ import {
} from '../../common/Utils/Entity';
import { getToken } from '../../common/Utils/LocalStorage';
import {
DATA_ASSETS,
ENTITIES,
INVALID_NAMES,
NAME_MAX_LENGTH_VALIDATION_ERROR,
@ -46,7 +46,7 @@ import {
NEW_GLOSSARY_TERMS,
uuid,
} from '../../constants/constants';
import { SidebarItem } from '../../constants/Entity.interface';
import { EntityType, SidebarItem } from '../../constants/Entity.interface';
import { DATABASE_SERVICE } from '../../constants/EntityConstant';
const CREDENTIALS = {
@ -282,180 +282,71 @@ describe('Custom Properties should work properly', { tags: 'Settings' }, () => {
cy.login();
});
describe('Add update and delete Integer custom properties', () => {
Object.values(ENTITIES).forEach((entity) => {
const propertyName = `addcyentity${entity.name}test${uuid()}`;
[
'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 Integer custom property for ${entity.name} Entities`, () => {
interceptURL(
'GET',
`/api/v1/metadata/types/name/${entity.name}*`,
'getEntity'
);
cy.settingClick(entity.entityApiType, true);
it(`Add ${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);
verifyResponseStatusCode('@getEntity', 200);
// Getting the property
addCustomPropertiesForEntity(
propertyName,
entity,
'Integer',
entity.integerValue,
entity.entityObj
);
// Getting the property
addCustomPropertiesForEntity({
propertyName,
customPropertyData: entity,
customType: type,
});
// Navigating back to custom properties page
cy.settingClick(entity.entityApiType, true);
verifyResponseStatusCode('@getEntity', 200);
});
// 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'
);
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);
// Selecting the entity
cy.settingClick(entity.entityApiType, true);
verifyResponseStatusCode('@getEntity', 200);
editCreatedProperty(propertyName);
});
verifyResponseStatusCode('@getEntity', 200);
editCreatedProperty(propertyName);
});
it(`Delete created property for ${entity.name} entity`, () => {
interceptURL(
'GET',
`/api/v1/metadata/types/name/${entity.name}*`,
'getEntity'
);
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);
// Selecting the entity
cy.settingClick(entity.entityApiType, true);
verifyResponseStatusCode('@getEntity', 200);
deleteCreatedProperty(propertyName);
});
});
});
describe('Add update and delete String custom properties', () => {
Object.values(ENTITIES).forEach((entity) => {
const propertyName = `addcyentity${entity.name}test${uuid()}`;
it(`Add String 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,
entity,
'String',
entity.stringValue,
entity.entityObj
);
// 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('Add update and delete Markdown custom properties', () => {
Object.values(ENTITIES).forEach((entity) => {
const propertyName = `addcyentity${entity.name}test${uuid()}`;
it(`Add Markdown 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,
entity,
'Markdown',
entity.markdownValue,
entity.entityObj
);
// 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);
verifyResponseStatusCode('@getEntity', 200);
deleteCreatedProperty(propertyName);
});
});
});
});
@ -476,13 +367,12 @@ describe('Custom Properties should work properly', { tags: 'Settings' }, () => {
verifyResponseStatusCode('@getEntity', 200);
addCustomPropertiesForEntity(
addCustomPropertiesForEntity({
propertyName,
entity,
'Enum',
entity.enumConfig,
entity.entityObj
);
customPropertyData: entity,
customType: 'Enum',
enumConfig: entity.enumConfig,
});
// Navigating back to custom properties page
cy.settingClick(entity.entityApiType, true);
@ -520,6 +410,242 @@ describe('Custom Properties should work properly', { tags: 'Settings' }, () => {
});
});
describe('Add update and delete Entity Reference custom properties', () => {
Object.values(ENTITIES).forEach((entity) => {
const propertyName = `addcyentity${entity.name}test${uuid()}`;
it(`Add 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);
});
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, 'Entity Reference');
});
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('Add update and delete Entity Reference List custom properties', () => {
Object.values(ENTITIES).forEach((entity) => {
const propertyName = `addcyentity${entity.name}test${uuid()}`;
it(`Add 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);
});
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, 'Entity Reference List');
});
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('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);
});
});
});
describe('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(', ');
@ -527,13 +653,11 @@ describe('Custom Properties should work properly', { tags: 'Settings' }, () => {
it('test custom properties in advanced search modal', () => {
cy.settingClick(glossaryTerm.entityApiType, true);
addCustomPropertiesForEntity(
addCustomPropertiesForEntity({
propertyName,
glossaryTerm,
'Integer',
'45',
null
);
customPropertyData: glossaryTerm,
customType: 'Integer',
});
// Navigating to explore page
cy.sidebarClick(SidebarItem.EXPLORE);
@ -593,29 +717,33 @@ describe('Custom Properties should work properly', { tags: 'Settings' }, () => {
cy.settingClick(glossaryTerm.entityApiType, true);
Object.values(CustomPropertyType).forEach((type) => {
addCustomPropertiesForEntity(
lowerCase(type),
glossaryTerm,
type,
`${type}-(${uuid()})`,
null
);
addCustomPropertiesForEntity({
propertyName: lowerCase(type),
customPropertyData: glossaryTerm,
customType: type,
});
cy.settingClick(glossaryTerm.entityApiType, true);
});
visitEntityDetailsPage({
term: NEW_GLOSSARY_TERMS.term_1.name,
serviceName: NEW_GLOSSARY_TERMS.term_1.fullyQualifiedName,
entity: 'glossaryTerms',
entity: 'glossaryTerms' as EntityType,
dataTestId: 'Cypress Glossary-CypressPurchase',
});
// set custom property value
Object.values(CustomPropertyType).forEach((type) => {
setValueForProperty(lowerCase(type), customPropertyValue[type].value);
setValueForProperty(
lowerCase(type),
customPropertyValue[type].value,
lowerCase(type)
);
validateValueForProperty(
lowerCase(type),
customPropertyValue[type].value
customPropertyValue[type].value,
lowerCase(type)
);
});
@ -623,21 +751,26 @@ describe('Custom Properties should work properly', { tags: 'Settings' }, () => {
Object.values(CustomPropertyType).forEach((type) => {
setValueForProperty(
lowerCase(type),
customPropertyValue[type].newValue
customPropertyValue[type].newValue,
lowerCase(type)
);
validateValueForProperty(
lowerCase(type),
customPropertyValue[type].newValue
customPropertyValue[type].newValue,
lowerCase(type)
);
});
// delete custom properties
Object.values(CustomPropertyType).forEach((customPropertyType) => {
const type = glossaryTerm.entityApiType;
const type = glossaryTerm.entityApiType as EntityType;
const property = customPropertyValue[customPropertyType].property ?? {};
deleteCustomPropertyForEntity({
property: { ...property, name: lowerCase(customPropertyType) },
property: {
...property,
name: lowerCase(customPropertyType),
} as CustomProperty,
type,
});
});
@ -699,7 +832,7 @@ describe('Custom Properties should work properly', { tags: 'Settings' }, () => {
visitEntityDetailsPage({
term: DATABASE_SERVICE.entity.name,
serviceName: DATABASE_SERVICE.service.name,
entity: DATA_ASSETS.tables,
entity: EntityType.Table,
});
verifyCustomPropertyRows();
});

View File

@ -12,7 +12,7 @@
*/
import EntityClass from '../../common/Entities/EntityClass';
import { CustomPropertyType } from '../../common/Utils/CustomProperty';
import { CustomPropertyTypeByName } from '../../common/Utils/CustomProperty';
import DatabaseClass from './../../common/Entities/DatabaseClass';
import DatabaseSchemaClass from './../../common/Entities/DatabaseSchemaClass';
import StoreProcedureClass from './../../common/Entities/StoredProcedureClass';
@ -111,7 +111,7 @@ describe('Database hierarchy details page', { tags: 'DataAssets' }, () => {
entity.removeInactiveAnnouncement();
});
Object.values(CustomPropertyType).forEach((type) => {
Object.values(CustomPropertyTypeByName).forEach((type) => {
it(`Set ${type} Custom Property `, () => {
entity.setCustomProperty(
entity.customPropertyValue[type].property,

View File

@ -11,16 +11,16 @@
* limitations under the License.
*/
import ContainerClass from '../../common/Entities/ContainerClass';
import DashboardDataModelClass from '../../common/Entities/DataModelClass';
import EntityClass from '../../common/Entities/EntityClass';
import MlModelClass from '../../common/Entities/MlModelClass';
import PipelineClass from '../../common/Entities/PipelineClass';
import SearchIndexClass from '../../common/Entities/SearchIndexClass';
import TopicClass from '../../common/Entities/TopicClass';
import { CustomPropertySupportedEntityList } from '../../constants/CustomProperty.constant';
import ContainerClass from './../../common/Entities/ContainerClass';
import DashboardClass from './../../common/Entities/DashboardClass';
import DashboardDataModelClass from './../../common/Entities/DataModelClass';
import MlModelClass from './../../common/Entities/MlModelClass';
import PipelineClass from './../../common/Entities/PipelineClass';
import SearchIndexClass from './../../common/Entities/SearchIndexClass';
import TopicClass from './../../common/Entities/TopicClass';
import { CustomPropertyType } from './../../common/Utils/CustomProperty';
import { CustomPropertyTypeByName } from './../../common/Utils/CustomProperty';
// Run tests over all entities except Database, Schema, Table and Store Procedure
// Those tests are covered in cypress/new-tests/Database.spec.js
@ -122,10 +122,10 @@ describe('Entity detail page', { tags: 'DataAssets' }, () => {
// Create custom property only for supported entities
if (CustomPropertySupportedEntityList.includes(entity.endPoint)) {
const properties = Object.values(CustomPropertyType).join(', ');
const properties = Object.values(CustomPropertyTypeByName).join(', ');
it(`Set ${properties} Custom Property `, () => {
Object.values(CustomPropertyType).forEach((type) => {
Object.values(CustomPropertyTypeByName).forEach((type) => {
entity.setCustomProperty(
entity.customPropertyValue[type].property,
entity.customPropertyValue[type].value
@ -134,7 +134,7 @@ describe('Entity detail page', { tags: 'DataAssets' }, () => {
});
it(`Update ${properties} Custom Property`, () => {
Object.values(CustomPropertyType).forEach((type) => {
Object.values(CustomPropertyTypeByName).forEach((type) => {
entity.updateCustomProperty(
entity.customPropertyValue[type].property,
entity.customPropertyValue[type].newValue

View File

@ -21,7 +21,7 @@ Describe your custom property to provide more information to your team.
$$
$$section
### Enum Values $(id="customPropertyConfig")
### Enum Values $(id="enumConfig")
Add the list of values for enum property.
$$
@ -30,4 +30,16 @@ $$section
### Multi Select $(id="multiSelect")
Enable multi select of values for enum property.
$$
$$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`.
$$
$$section
### Entity Reference type $(id="entityReferenceConfig")
Select the reference type for your custom property value type.
$$

View File

@ -125,11 +125,16 @@ const DataAssetAsyncSelectList: FC<DataAssetAsyncSelectListProps> = ({
const { value, reference, displayName } = option;
let label;
if (searchIndex === SearchIndex.USER) {
if (
searchIndex === SearchIndex.USER ||
searchIndex.includes('user') ||
searchIndex.includes('team')
) {
label = (
<Space>
<ProfilePicture
className="d-flex"
isTeam={reference.type === EntityType.TEAM}
name={option.name ?? ''}
type="circle"
width="24"

View File

@ -14,7 +14,7 @@
import { Button, Col, Form, Row } from 'antd';
import { AxiosError } from 'axios';
import { t } from 'i18next';
import { isUndefined, map, omit, startCase } from 'lodash';
import { isUndefined, map, omit, omitBy, startCase } from 'lodash';
import React, {
FocusEvent,
useCallback,
@ -23,7 +23,11 @@ import React, {
useState,
} from 'react';
import { useHistory, useParams } from 'react-router-dom';
import { SUPPORTED_FIELD_TYPES } from '../../../../constants/constants';
import {
ENTITY_REFERENCE_OPTIONS,
PROPERTY_TYPES_WITH_ENTITY_REFERENCE,
PROPERTY_TYPES_WITH_FORMAT,
} from '../../../../constants/CustomProperty.constants';
import { GlobalSettingsMenuCategory } from '../../../../constants/GlobalSettings.constants';
import { CUSTOM_PROPERTY_NAME_REGEX } from '../../../../constants/regex.constants';
import {
@ -44,6 +48,7 @@ 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';
@ -89,20 +94,34 @@ const AddCustomProperty = () => {
);
const propertyTypeOptions = useMemo(() => {
const supportedTypes = propertyTypes.filter((property) =>
SUPPORTED_FIELD_TYPES.includes(property.name)
);
return map(supportedTypes, (type) => ({
return map(propertyTypes, (type) => ({
key: type.name,
label: startCase(type.displayName ?? type.name),
value: type.id,
}));
}, [propertyTypes]);
const isEnumType =
propertyTypeOptions.find((option) => option.value === watchedPropertyType)
?.key === 'enum';
const { hasEnumConfig, hasFormatConfig, hasEntityReferenceConfig } =
useMemo(() => {
const watchedOption = propertyTypeOptions.find(
(option) => option.value === watchedPropertyType
);
const watchedOptionKey = watchedOption?.key ?? '';
const hasEnumConfig = watchedOptionKey === 'enum';
const hasFormatConfig =
PROPERTY_TYPES_WITH_FORMAT.includes(watchedOptionKey);
const hasEntityReferenceConfig =
PROPERTY_TYPES_WITH_ENTITY_REFERENCE.includes(watchedOptionKey);
return {
hasEnumConfig,
hasFormatConfig,
hasEntityReferenceConfig,
};
}, [watchedPropertyType, propertyTypeOptions]);
const fetchPropertyType = async () => {
try {
@ -135,13 +154,11 @@ const AddCustomProperty = () => {
* In CustomProperty the propertyType is type of entity reference, however from the form we
* get propertyType as string
*/
/**
* In CustomProperty the customPropertyConfig is type of CustomPropertyConfig, however from the
* form we get customPropertyConfig as string[]
*/
data: Exclude<CustomProperty, 'propertyType' | 'customPropertyConfig'> & {
data: Exclude<CustomProperty, 'propertyType'> & {
propertyType: string;
customPropertyConfig: string[];
enumConfig: string[];
formatConfig: string;
entityReferenceConfig: string[];
multiSelect?: boolean;
}
) => {
@ -151,24 +168,47 @@ const AddCustomProperty = () => {
try {
setIsCreating(true);
await addPropertyToEntity(typeDetail?.id ?? '', {
...omit(data, 'multiSelect'),
propertyType: {
id: data.propertyType,
type: 'type',
let customPropertyConfig;
if (hasEnumConfig) {
customPropertyConfig = {
config: {
multiSelect: Boolean(data?.multiSelect),
values: data.enumConfig,
},
};
}
if (hasFormatConfig) {
customPropertyConfig = {
config: data.formatConfig,
};
}
if (hasEntityReferenceConfig) {
customPropertyConfig = {
config: data.entityReferenceConfig,
};
}
const payload = omitBy(
{
...omit(data, [
'multiSelect',
'formatConfig',
'entityReferenceConfig',
'enumConfig',
]),
propertyType: {
id: data.propertyType,
type: 'type',
},
customPropertyConfig,
},
// Only add customPropertyConfig if it is an enum type
...(isEnumType
? {
customPropertyConfig: {
config: {
multiSelect: Boolean(data?.multiSelect),
values: data.customPropertyConfig,
},
},
}
: {}),
});
isUndefined
) as unknown as CustomProperty;
await addPropertyToEntity(typeDetail?.id ?? '', payload);
history.goBack();
} catch (error) {
showErrorToast(error as AxiosError);
@ -216,6 +256,12 @@ const AddCustomProperty = () => {
placeholder: `${t('label.select-field', {
field: t('label.type'),
})}`,
showSearch: true,
filterOption: (input: string, option: { label: string }) => {
return (option?.label ?? '')
.toLowerCase()
.includes(input.toLowerCase());
},
},
},
];
@ -232,14 +278,14 @@ const AddCustomProperty = () => {
},
};
const customPropertyConfigTypeValueField: FieldProp = {
name: 'customPropertyConfig',
const enumConfigField: FieldProp = {
name: 'enumConfig',
required: false,
label: t('label.enum-value-plural'),
id: 'root/customPropertyConfig',
id: 'root/enumConfig',
type: FieldTypes.SELECT,
props: {
'data-testid': 'customPropertyConfig',
'data-testid': 'enumConfig',
mode: 'tags',
placeholder: t('label.enum-value-plural'),
},
@ -265,6 +311,50 @@ const AddCustomProperty = () => {
formItemLayout: FormItemLayout.HORIZONTAL,
};
const formatConfigField: FieldProp = {
name: 'formatConfig',
required: false,
label: t('label.format'),
id: 'root/formatConfig',
type: FieldTypes.TEXT,
props: {
'data-testid': 'formatConfig',
autoComplete: 'off',
},
placeholder: t('label.format'),
rules: [
{
validator: (_, value) => {
if (!isValidDateFormat(value)) {
return Promise.reject(
t('label.field-invalid', {
field: t('label.format'),
})
);
}
return Promise.resolve();
},
},
],
};
const entityReferenceConfigField: FieldProp = {
name: 'entityReferenceConfig',
required: true,
label: t('label.entity-reference-types'),
id: 'root/entityReferenceConfig',
type: FieldTypes.SELECT,
props: {
mode: 'multiple',
options: ENTITY_REFERENCE_OPTIONS,
'data-testid': 'entityReferenceConfig',
placeholder: `${t('label.select-field', {
field: t('label.type'),
})}`,
},
};
const firstPanelChildren = (
<div className="max-width-md w-9/10 service-form-container">
<TitleBreadcrumb titleLinks={slashedBreadcrumb} />
@ -276,14 +366,21 @@ const AddCustomProperty = () => {
onFinish={handleSubmit}
onFocus={handleFieldFocus}>
{generateFormFields(formFields)}
{isEnumType && (
<>
{generateFormFields([
customPropertyConfigTypeValueField,
multiSelectField,
])}
</>
)}
{
// Only show enum value field if the property type has enum config
hasEnumConfig &&
generateFormFields([enumConfigField, multiSelectField])
}
{
// Only show format field if the property type has format config
hasFormatConfig && generateFormFields([formatConfigField])
}
{
// Only show entity reference field if the property type has entity reference config
hasEntityReferenceConfig &&
generateFormFields([entityReferenceConfigField])
}
{generateFormFields([descriptionField])}
<Row justify="end">
<Col>

View File

@ -12,7 +12,7 @@
*/
import { Button, Space, Tooltip, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { isEmpty, isString, isUndefined } from 'lodash';
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';
@ -66,16 +66,21 @@ export const CustomPropertyTable: FC<CustomPropertyTableProp> = ({
const handlePropertyUpdate = async (data: FormData) => {
const updatedProperties = customProperties.map((property) => {
if (property.name === selectedProperty.name) {
const config = data.customPropertyConfig;
const isEnumType = selectedProperty.propertyType.name === 'enum';
return {
...property,
description: data.description,
...(data.customPropertyConfig
...(config
? {
customPropertyConfig: {
config: {
multiSelect: Boolean(data?.multiSelect),
values: data.customPropertyConfig,
},
config: isEnumType
? {
multiSelect: Boolean(data?.multiSelect),
values: config,
}
: config,
},
}
: {}),
@ -116,14 +121,24 @@ export const CustomPropertyTable: FC<CustomPropertyTableProp> = ({
title: t('label.config'),
dataIndex: 'customPropertyConfig',
key: 'customPropertyConfig',
render: (data: CustomProperty['customPropertyConfig']) => {
render: (data: CustomProperty['customPropertyConfig'], record) => {
if (isUndefined(data)) {
return <span>--</span>;
}
const config = data.config;
if (!isString(config)) {
// If config is an array and not empty
if (isArray(config) && !isEmpty(config)) {
return (
<Typography.Text data-testid={`${record.name}-config`}>
{JSON.stringify(config ?? [])}
</Typography.Text>
);
}
// If config is an object, then it is a enum config
if (!isString(config) && !isArray(config)) {
return (
<Space data-testid="enum-config" direction="vertical" size={4}>
<Typography.Text>
@ -137,6 +152,7 @@ export const CustomPropertyTable: FC<CustomPropertyTableProp> = ({
);
}
// else it is a string
return <Typography.Text>{config}</Typography.Text>;
},
},

View File

@ -14,6 +14,10 @@ import { Form, Modal, Typography } from 'antd';
import { isUndefined, uniq } from 'lodash';
import React, { FC, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ENTITY_REFERENCE_OPTIONS,
PROPERTY_TYPES_WITH_ENTITY_REFERENCE,
} from '../../../../constants/CustomProperty.constants';
import {
CustomProperty,
EnumConfig,
@ -54,6 +58,18 @@ const EditCustomPropertyModal: FC<EditCustomPropertyModalProps> = ({
setIsSaving(false);
};
const { hasEnumConfig, hasEntityReferenceConfig } = useMemo(() => {
const propertyName = customProperty.propertyType.name ?? '';
const hasEnumConfig = propertyName === 'enum';
const hasEntityReferenceConfig =
PROPERTY_TYPES_WITH_ENTITY_REFERENCE.includes(propertyName);
return {
hasEnumConfig,
hasEntityReferenceConfig,
};
}, [customProperty]);
const formFields: FieldProp[] = [
{
name: 'description',
@ -68,7 +84,7 @@ const EditCustomPropertyModal: FC<EditCustomPropertyModalProps> = ({
},
];
const customPropertyConfigField: FieldProp = {
const enumConfigField: FieldProp = {
name: 'customPropertyConfig',
required: false,
label: t('label.enum-value-plural'),
@ -95,6 +111,37 @@ const EditCustomPropertyModal: FC<EditCustomPropertyModalProps> = ({
],
};
const entityReferenceConfigField: FieldProp = {
name: 'customPropertyConfig',
required: false,
label: t('label.entity-reference-types'),
id: 'root/customPropertyConfig',
type: FieldTypes.SELECT,
props: {
'data-testid': 'customPropertyConfig',
mode: 'multiple',
options: ENTITY_REFERENCE_OPTIONS,
placeholder: t('label.entity-reference-types'),
onChange: (value: string[]) => {
const entityReferenceConfig = customProperty.customPropertyConfig
?.config as string[];
const updatedValues = uniq([
...value,
...(entityReferenceConfig ?? []),
]);
form.setFieldsValue({ customPropertyConfig: updatedValues });
},
},
rules: [
{
required: true,
message: t('label.field-required', {
field: t('label.entity-reference-types'),
}),
},
],
};
const multiSelectField: FieldProp = {
name: 'multiSelect',
label: t('label.multi-select'),
@ -108,8 +155,7 @@ const EditCustomPropertyModal: FC<EditCustomPropertyModalProps> = ({
};
const initialValues = useMemo(() => {
const isEnumType = customProperty.propertyType.name === 'enum';
if (isEnumType) {
if (hasEnumConfig) {
const enumConfig = customProperty.customPropertyConfig
?.config as EnumConfig;
@ -124,7 +170,17 @@ const EditCustomPropertyModal: FC<EditCustomPropertyModalProps> = ({
description: customProperty.description,
customPropertyConfig: customProperty.customPropertyConfig?.config,
};
}, [customProperty]);
}, [customProperty, hasEnumConfig]);
const note = (
<Typography.Text
className="text-grey-muted"
style={{ display: 'block', marginTop: '-18px' }}>
{`Note: ${t(
'message.updating-existing-not-possible-can-add-new-values'
)}`}
</Typography.Text>
);
return (
<Modal
@ -160,15 +216,20 @@ const EditCustomPropertyModal: FC<EditCustomPropertyModalProps> = ({
{generateFormFields(formFields)}
{!isUndefined(customProperty.customPropertyConfig) && (
<>
{generateFormFields([customPropertyConfigField])}
<Typography.Text
className="text-grey-muted"
style={{ display: 'block', marginTop: '-18px' }}>
{`Note: ${t(
'message.updating-existing-not-possible-can-add-new-values'
)}`}
</Typography.Text>
{generateFormFields([multiSelectField])}
{hasEnumConfig && (
<>
{generateFormFields([enumConfigField])}
{note}
{generateFormFields([multiSelectField])}
</>
)}
{hasEntityReferenceConfig && (
<>
{generateFormFields([entityReferenceConfigField])}
{note}
</>
)}
</>
)}
</Form>

View File

@ -23,6 +23,8 @@ import { SearchIndex } from '../../../generated/entity/data/searchIndex';
import { StoredProcedure } from '../../../generated/entity/data/storedProcedure';
import { Table } from '../../../generated/entity/data/table';
import { Topic } from '../../../generated/entity/data/topic';
import { EntityReference } from '../../../generated/entity/type';
import { CustomProperty } from '../../../generated/type/customProperty';
export type ExtentionEntities = {
[EntityType.TABLE]: Table;
@ -51,3 +53,25 @@ export interface CustomPropertyProps<T extends ExtentionEntitiesKeys> {
maxDataCap?: number;
isRenderedInRightPanel?: boolean;
}
export interface PropertyValueProps {
property: CustomProperty;
extension: Table['extension'];
hasEditPermissions: boolean;
versionDataKeys?: string[];
isVersionView?: boolean;
onExtensionUpdate: (updatedExtension: Table['extension']) => Promise<void>;
}
export type TimeIntervalType = {
start: number;
end: number;
};
export type PropertyValueType =
| string
| number
| string[]
| EntityReference
| EntityReference[]
| TimeIntervalType;

View File

@ -12,7 +12,7 @@
*/
import { Table, Typography } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { isString, map } from 'lodash';
import { isObject, isString, map } from 'lodash';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import RichTextEditorPreviewer from '../RichTextEditor/RichTextEditorPreviewer';
@ -58,7 +58,7 @@ export const ExtensionTable = ({
return (
<Typography.Text className="break-all" data-testid="value">
{value}
{isObject(value) ? JSON.stringify(value ?? '{}', null, 2) : value}
</Typography.Text>
);
},

View File

@ -13,6 +13,7 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { PropertyValue } from './PropertyValue';
jest.mock('../../common/RichTextEditor/RichTextEditorPreviewer', () => {
@ -40,6 +41,21 @@ jest.mock('./PropertyInput', () => ({
)),
}));
jest.mock('../../Database/SchemaEditor/SchemaEditor', () =>
jest.fn().mockReturnValue(<div data-testid="SchemaEditor">SchemaEditor</div>)
);
jest.mock(
'../../DataAssets/DataAssetAsyncSelectList/DataAssetAsyncSelectList',
() =>
jest
.fn()
.mockReturnValue(
<div data-testid="entity-reference-select">
DataAssetAsyncSelectList
</div>
)
);
const mockUpdate = jest.fn();
const mockData = {
@ -114,7 +130,7 @@ describe('Test PropertyValue Component', () => {
});
it('Should render select component for enum type', async () => {
const extension = { yNumber: 'enumValue' };
const extension = { yNumber: ['enumValue1', 'enumValue2'] };
const propertyType = {
...mockData.property.propertyType,
name: 'enum',
@ -129,10 +145,329 @@ describe('Test PropertyValue Component', () => {
const iconElement = await screen.findByTestId('edit-icon');
expect(await screen.findByTestId('enum-value')).toHaveTextContent(
'enumValue1, enumValue2'
);
await act(async () => {
fireEvent.click(iconElement);
});
expect(await screen.findByTestId('enum-select')).toBeInTheDocument();
});
it('Should render date picker component for "date" type', async () => {
const extension = { yNumber: '20-03-2024' };
const propertyType = {
...mockData.property.propertyType,
name: 'date',
};
render(
<PropertyValue
{...mockData}
extension={extension}
property={{ ...mockData.property, propertyType: propertyType }}
/>
);
const iconElement = await screen.findByTestId('edit-icon');
expect(await screen.findByTestId('value')).toHaveTextContent('20-03-2024');
await act(async () => {
fireEvent.click(iconElement);
});
expect(await screen.findByTestId('date-time-picker')).toBeInTheDocument();
});
it('Should render date picker component for "dateTime" type', async () => {
const extension = {
yNumber: '20-03-2024 2:00:00',
};
const propertyType = {
...mockData.property.propertyType,
name: 'dateTime',
};
render(
<PropertyValue
{...mockData}
extension={extension}
property={{ ...mockData.property, propertyType: propertyType }}
/>
);
const iconElement = await screen.findByTestId('edit-icon');
expect(await screen.findByTestId('value')).toHaveTextContent(
'20-03-2024 2:00:00'
);
await act(async () => {
fireEvent.click(iconElement);
});
expect(await screen.findByTestId('date-time-picker')).toBeInTheDocument();
});
it('Should render time picker component for "time" type', async () => {
const extension = {
yNumber: '2:00:00',
};
const propertyType = {
...mockData.property.propertyType,
name: 'time',
};
render(
<PropertyValue
{...mockData}
extension={extension}
property={{ ...mockData.property, propertyType: propertyType }}
/>
);
const iconElement = await screen.findByTestId('edit-icon');
expect(await screen.findByTestId('value')).toHaveTextContent('2:00:00');
await act(async () => {
fireEvent.click(iconElement);
});
expect(await screen.findByTestId('time-picker')).toBeInTheDocument();
});
it('Should render email input component for "email" type', async () => {
const extension = {
yNumber: 'john@doe.com',
};
const propertyType = {
...mockData.property.propertyType,
name: 'email',
};
render(
<PropertyValue
{...mockData}
extension={extension}
property={{ ...mockData.property, propertyType: propertyType }}
/>
);
const iconElement = await screen.findByTestId('edit-icon');
expect(await screen.findByTestId('value')).toHaveTextContent(
'john@doe.com'
);
await act(async () => {
fireEvent.click(iconElement);
});
expect(await screen.findByTestId('email-input')).toBeInTheDocument();
});
it('Should render timestamp input component for "timestamp" type', async () => {
const extension = {
yNumber: 1736255200000,
};
const propertyType = {
...mockData.property.propertyType,
name: 'timestamp',
};
render(
<PropertyValue
{...mockData}
extension={extension}
property={{ ...mockData.property, propertyType: propertyType }}
/>
);
const iconElement = await screen.findByTestId('edit-icon');
expect(await screen.findByTestId('value')).toHaveTextContent(
'1736255200000'
);
await act(async () => {
fireEvent.click(iconElement);
});
expect(await screen.findByTestId('timestamp-input')).toBeInTheDocument();
});
it('Should render start and end input component for "timeInterval" type', async () => {
const extension = {
yNumber: {
start: '1736255200000',
end: '1736255200020',
},
};
const propertyType = {
...mockData.property.propertyType,
name: 'timeInterval',
};
render(
<PropertyValue
{...mockData}
extension={extension}
property={{ ...mockData.property, propertyType: propertyType }}
/>
);
const iconElement = await screen.findByTestId('edit-icon');
expect(await screen.findByTestId('time-interval-value')).toHaveTextContent(
'StartTime: 1736255200000EndTime: 1736255200020'
);
await act(async () => {
fireEvent.click(iconElement);
});
expect(await screen.findByTestId('start-input')).toBeInTheDocument();
expect(await screen.findByTestId('end-input')).toBeInTheDocument();
});
it('Should render duration input component for "duration" type', async () => {
const extension = {
yNumber: 'P1Y2M3DT4H5M6S',
};
const propertyType = {
...mockData.property.propertyType,
name: 'duration',
};
render(
<PropertyValue
{...mockData}
extension={extension}
property={{ ...mockData.property, propertyType: propertyType }}
/>
);
const iconElement = await screen.findByTestId('edit-icon');
expect(await screen.findByTestId('value')).toHaveTextContent(
'P1Y2M3DT4H5M6S'
);
await act(async () => {
fireEvent.click(iconElement);
});
expect(await screen.findByTestId('duration-input')).toBeInTheDocument();
});
it('Should render sqlQuery editor component for "sqlQuery" type', async () => {
const extension = {};
const propertyType = {
...mockData.property.propertyType,
name: 'sqlQuery',
};
render(
<PropertyValue
{...mockData}
extension={extension}
property={{ ...mockData.property, propertyType: propertyType }}
/>
);
const iconElement = await screen.findByTestId('edit-icon');
await act(async () => {
fireEvent.click(iconElement);
});
expect(await screen.findByTestId('enum-select')).toBeInTheDocument();
expect(await screen.findByTestId('SchemaEditor')).toBeInTheDocument();
});
it('Should render entity reference select component for "entityReference" type', async () => {
const extension = {
yNumber: {
id: 'entityReferenceId',
name: 'entityReferenceName',
fullyQualifiedName: 'entityReferenceFullyQualifiedName',
type: 'entityReference',
},
};
const propertyType = {
...mockData.property.propertyType,
name: 'entityReference',
};
render(
<PropertyValue
{...mockData}
extension={extension}
property={{ ...mockData.property, propertyType: propertyType }}
/>,
{ wrapper: MemoryRouter }
);
const iconElement = await screen.findByTestId('edit-icon');
expect(
await screen.findByTestId('entityReference-value')
).toBeInTheDocument();
expect(
await screen.findByTestId('entityReference-value-name')
).toHaveTextContent('entityReferenceName');
await act(async () => {
fireEvent.click(iconElement);
});
expect(
await screen.findByTestId('entity-reference-select')
).toBeInTheDocument();
});
it('Should render entity reference select component for "entityReferenceList" type', async () => {
const extension = {
yNumber: [
{
id: 'entityReferenceId',
name: 'entityReferenceName',
fullyQualifiedName: 'entityReferenceFullyQualifiedName',
type: 'entityReference',
},
{
id: 'entityReferenceId2',
name: 'entityReferenceName2',
fullyQualifiedName: 'entityReferenceFullyQualifiedName2',
type: 'entityReference',
},
],
};
const propertyType = {
...mockData.property.propertyType,
name: 'entityReferenceList',
};
render(
<PropertyValue
{...mockData}
extension={extension}
property={{ ...mockData.property, propertyType: propertyType }}
/>,
{
wrapper: MemoryRouter,
}
);
const iconElement = await screen.findByTestId('edit-icon');
expect(
await screen.findByTestId('entityReferenceName')
).toBeInTheDocument();
expect(
await screen.findByTestId('entityReferenceName2')
).toBeInTheDocument();
await act(async () => {
fireEvent.click(iconElement);
});
expect(
await screen.findByTestId('entity-reference-select')
).toBeInTheDocument();
});
});

View File

@ -12,34 +12,53 @@
*/
import Icon from '@ant-design/icons';
import { Form, Select, Tooltip, Typography } from 'antd';
import {
Button,
DatePicker,
Form,
Input,
Select,
TimePicker,
Tooltip,
Typography,
} from 'antd';
import { AxiosError } from 'axios';
import { t } from 'i18next';
import { isArray, isEmpty, isUndefined, noop, toNumber } from 'lodash';
import React, { FC, Fragment, useState } from 'react';
import { isArray, isUndefined, noop, toNumber, toUpper } from 'lodash';
import moment, { Moment } from 'moment';
import React, { CSSProperties, FC, Fragment, useState } from 'react';
import { Link } from 'react-router-dom';
import { ReactComponent as EditIconComponent } from '../../../assets/svg/edit-new.svg';
import { DE_ACTIVE_COLOR, ICON_DIMENSION } from '../../../constants/constants';
import { Table } from '../../../generated/entity/data/table';
import {
CustomProperty,
EnumConfig,
} from '../../../generated/type/customProperty';
DE_ACTIVE_COLOR,
ICON_DIMENSION,
VALIDATION_MESSAGES,
} from '../../../constants/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 entityUtilClassBase from '../../../utils/EntityUtilClassBase';
import { getEntityName } from '../../../utils/EntityUtils';
import { getEntityIcon } from '../../../utils/TableUtils';
import { showErrorToast } from '../../../utils/ToastUtils';
import DataAssetAsyncSelectList from '../../DataAssets/DataAssetAsyncSelectList/DataAssetAsyncSelectList';
import { DataAssetOption } from '../../DataAssets/DataAssetAsyncSelectList/DataAssetAsyncSelectList.interface';
import SchemaEditor from '../../Database/SchemaEditor/SchemaEditor';
import { ModalWithMarkdownEditor } from '../../Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor';
import InlineEdit from '../InlineEdit/InlineEdit.component';
import ProfilePicture from '../ProfilePicture/ProfilePicture';
import RichTextEditorPreviewer from '../RichTextEditor/RichTextEditorPreviewer';
import {
PropertyValueProps,
PropertyValueType,
TimeIntervalType,
} from './CustomPropertyTable.interface';
import './property-value.less';
import { PropertyInput } from './PropertyInput';
interface Props {
versionDataKeys?: string[];
isVersionView?: boolean;
property: CustomProperty;
extension: Table['extension'];
onExtensionUpdate: (updatedExtension: Table['extension']) => Promise<void>;
hasEditPermissions: boolean;
}
export const PropertyValue: FC<Props> = ({
export const PropertyValue: FC<PropertyValueProps> = ({
isVersionView,
versionDataKeys,
extension,
@ -55,28 +74,29 @@ export const PropertyValue: FC<Props> = ({
const [showInput, setShowInput] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const onShowInput = () => {
setShowInput(true);
};
const onShowInput = () => setShowInput(true);
const onHideInput = () => {
setShowInput(false);
};
const onHideInput = () => setShowInput(false);
const onInputSave = async (updatedValue: string | number | string[]) => {
const onInputSave = async (updatedValue: PropertyValueType) => {
const isEnum = propertyType.name === 'enum';
const isArrayType = isArray(updatedValue);
const enumValue = isArrayType ? updatedValue : [updatedValue];
const propertyValue = isEnum ? enumValue : updatedValue;
try {
const updatedExtension = {
...(extension || {}),
[propertyName]:
propertyType.name === 'integer'
? toNumber(updatedValue || 0)
: propertyValue,
[propertyName]: ['integer', 'number'].includes(propertyType.name ?? '')
? toNumber(updatedValue || 0)
: propertyValue,
};
setIsLoading(true);
await onExtensionUpdate(updatedExtension);
} catch (error) {
showErrorToast(error as AxiosError);
@ -87,26 +107,39 @@ export const PropertyValue: FC<Props> = ({
};
const getPropertyInput = () => {
const commonStyle: CSSProperties = {
marginBottom: '0px',
minWidth: '250px',
};
switch (propertyType.name) {
case 'string':
case 'integer':
case 'number': {
const inputType = ['integer', 'number'].includes(propertyType.name)
? 'number'
: 'text';
return (
<PropertyInput
isLoading={isLoading}
propertyName={propertyName}
type={propertyType.name === 'integer' ? 'number' : 'text'}
type={inputType}
value={value}
onCancel={onHideInput}
onSave={onInputSave}
/>
);
case 'markdown':
}
case 'markdown': {
const header = t('label.edit-entity-name', {
entityType: t('label.property'),
entityName: propertyName,
});
return (
<ModalWithMarkdownEditor
header={t('label.edit-entity-name', {
entityType: t('label.property'),
entityName: propertyName,
})}
header={header}
placeholder={t('label.enter-property-value')}
value={value || ''}
visible={showInput}
@ -114,14 +147,22 @@ export const PropertyValue: FC<Props> = ({
onSave={onInputSave}
/>
);
}
case 'enum': {
const enumConfig = property.customPropertyConfig?.config as EnumConfig;
const isMultiSelect = Boolean(enumConfig?.multiSelect);
const options = enumConfig?.values?.map((option) => ({
label: option,
value: option,
}));
const initialValues = {
enumValues: (isArray(value) ? value : [value]).filter(Boolean),
};
return (
<InlineEdit
isLoading={isLoading}
@ -134,9 +175,7 @@ export const PropertyValue: FC<Props> = ({
onSave={noop}>
<Form
id="enum-form"
initialValues={{
enumValues: (isArray(value) ? value : [value]).filter(Boolean),
}}
initialValues={initialValues}
layout="vertical"
onFinish={(values: { enumValues: string | string[] }) =>
onInputSave(values.enumValues)
@ -151,13 +190,61 @@ export const PropertyValue: FC<Props> = ({
}),
},
]}
style={{ marginBottom: '0px' }}>
style={commonStyle}>
<Select
data-testid="enum-select"
disabled={isLoading}
mode={isMultiSelect ? 'multiple' : undefined}
options={options}
placeholder={t('label.enum-value-plural')}
/>
</Form.Item>
</Form>
</InlineEdit>
);
}
case 'date':
case 'dateTime': {
const format = toUpper(property.customPropertyConfig?.config as string);
const initialValues = {
dateTimeValue: value ? moment(value, format) : undefined,
};
return (
<InlineEdit
isLoading={isLoading}
saveButtonProps={{
disabled: isLoading,
htmlType: 'submit',
form: 'dateTime-form',
}}
onCancel={onHideInput}
onSave={noop}>
<Form
id="dateTime-form"
initialValues={initialValues}
layout="vertical"
onFinish={(values: { dateTimeValue: Moment }) => {
onInputSave(values.dateTimeValue.format(format));
}}>
<Form.Item
name="dateTimeValue"
rules={[
{
required: true,
message: t('label.field-required', {
field: propertyType.name,
}),
},
]}
style={commonStyle}>
<DatePicker
data-testid="date-time-picker"
disabled={isLoading}
format={format}
showTime={propertyType.name === 'dateTime'}
style={{ width: '250px' }}
/>
</Form.Item>
@ -166,6 +253,385 @@ export const PropertyValue: FC<Props> = ({
);
}
case 'time': {
const format = 'HH:mm:ss';
const initialValues = {
time: value ? moment(value, format) : undefined,
};
return (
<InlineEdit
isLoading={isLoading}
saveButtonProps={{
disabled: isLoading,
htmlType: 'submit',
form: 'time-form',
}}
onCancel={onHideInput}
onSave={noop}>
<Form
id="time-form"
initialValues={initialValues}
layout="vertical"
validateMessages={VALIDATION_MESSAGES}
onFinish={(values: { time: Moment }) => {
onInputSave(values.time.format(format));
}}>
<Form.Item
name="time"
rules={[
{
required: true,
},
]}
style={commonStyle}>
<TimePicker
data-testid="time-picker"
disabled={isLoading}
style={{ width: '250px' }}
/>
</Form.Item>
</Form>
</InlineEdit>
);
}
case 'email': {
const initialValues = {
email: value,
};
return (
<InlineEdit
isLoading={isLoading}
saveButtonProps={{
disabled: isLoading,
htmlType: 'submit',
form: 'email-form',
}}
onCancel={onHideInput}
onSave={noop}>
<Form
id="email-form"
initialValues={initialValues}
layout="vertical"
validateMessages={VALIDATION_MESSAGES}
onFinish={(values: { email: string }) => {
onInputSave(values.email);
}}>
<Form.Item
name="email"
rules={[
{
required: true,
min: 6,
max: 127,
type: 'email',
},
]}
style={commonStyle}>
<Input
data-testid="email-input"
disabled={isLoading}
placeholder="john@doe.com"
/>
</Form.Item>
</Form>
</InlineEdit>
);
}
case 'timestamp': {
const initialValues = {
timestamp: value,
};
return (
<InlineEdit
isLoading={isLoading}
saveButtonProps={{
disabled: isLoading,
htmlType: 'submit',
form: 'timestamp-form',
}}
onCancel={onHideInput}
onSave={noop}>
<Form
id="timestamp-form"
initialValues={initialValues}
layout="vertical"
onFinish={(values: { timestamp: string }) => {
onInputSave(toNumber(values.timestamp));
}}>
<Form.Item
name="timestamp"
rules={[
{
required: true,
pattern: TIMESTAMP_UNIX_IN_MILLISECONDS_REGEX,
},
]}
style={commonStyle}>
<Input
data-testid="timestamp-input"
disabled={isLoading}
placeholder={t('message.unix-epoch-time-in-ms', {
prefix: '',
})}
/>
</Form.Item>
</Form>
</InlineEdit>
);
}
case 'timeInterval': {
const initialValues = {
start: value?.start ? value.start?.toString() : undefined,
end: value?.end ? value.end?.toString() : undefined,
};
return (
<InlineEdit
isLoading={isLoading}
saveButtonProps={{
disabled: isLoading,
htmlType: 'submit',
form: 'timeInterval-form',
}}
onCancel={onHideInput}
onSave={noop}>
<Form
id="timeInterval-form"
initialValues={initialValues}
layout="vertical"
onFinish={(values: { start: string; end: string }) => {
onInputSave({
start: toNumber(values.start),
end: toNumber(values.end),
});
}}>
<Form.Item
name="start"
rules={[
{
required: true,
pattern: TIMESTAMP_UNIX_IN_MILLISECONDS_REGEX,
},
]}
style={{ ...commonStyle, marginBottom: '16px' }}>
<Input
data-testid="start-input"
disabled={isLoading}
placeholder={t('message.unix-epoch-time-in-ms', {
prefix: 'Start',
})}
/>
</Form.Item>
<Form.Item
name="end"
rules={[
{
required: true,
pattern: TIMESTAMP_UNIX_IN_MILLISECONDS_REGEX,
},
]}
style={commonStyle}>
<Input
data-testid="end-input"
disabled={isLoading}
placeholder={t('message.unix-epoch-time-in-ms', {
prefix: 'End',
})}
/>
</Form.Item>
</Form>
</InlineEdit>
);
}
case 'duration': {
const initialValues = {
duration: value,
};
return (
<InlineEdit
isLoading={isLoading}
saveButtonProps={{
disabled: isLoading,
htmlType: 'submit',
form: 'duration-form',
}}
onCancel={onHideInput}
onSave={noop}>
<Form
id="duration-form"
initialValues={initialValues}
layout="vertical"
validateMessages={VALIDATION_MESSAGES}
onFinish={(values: { duration: string }) => {
onInputSave(values.duration);
}}>
<Form.Item
name="duration"
rules={[
{
required: true,
},
]}
style={commonStyle}>
<Input
data-testid="duration-input"
disabled={isLoading}
placeholder={t('message.duration-in-iso-format')}
/>
</Form.Item>
</Form>
</InlineEdit>
);
}
case 'sqlQuery': {
const initialValues = {
sqlQuery: value,
};
return (
<InlineEdit
isLoading={isLoading}
saveButtonProps={{
disabled: isLoading,
htmlType: 'submit',
form: 'sqlQuery-form',
}}
onCancel={onHideInput}
onSave={noop}>
<Form
id="sqlQuery-form"
initialValues={initialValues}
layout="vertical"
validateMessages={VALIDATION_MESSAGES}
onFinish={(values: { sqlQuery: string }) => {
onInputSave(values.sqlQuery);
}}>
<Form.Item
name="sqlQuery"
rules={[
{
required: true,
message: t('label.field-required', {
field: t('label.sql-uppercase-query'),
}),
},
]}
style={commonStyle}
trigger="onChange">
<SchemaEditor
className="custom-query-editor query-editor-h-200 custom-code-mirror-theme"
mode={{ name: CSMode.SQL }}
options={{
readOnly: false,
}}
/>
</Form.Item>
</Form>
</InlineEdit>
);
}
case 'entityReference':
case 'entityReferenceList': {
const mode =
propertyType.name === 'entityReferenceList' ? 'multiple' : undefined;
const index = (property.customPropertyConfig?.config as string[]) ?? [];
let initialOptions: DataAssetOption[] = [];
let initialValue: string[] | string | undefined;
if (!isUndefined(value)) {
if (isArray(value)) {
initialOptions = value.map((item: EntityReference) => {
return {
displayName: getEntityName(item),
reference: item,
label: getEntityName(item),
value: item?.fullyQualifiedName ?? '',
};
});
initialValue = value.map(
(item: EntityReference) => item?.fullyQualifiedName ?? ''
);
} else {
initialOptions = [
{
displayName: getEntityName(value),
reference: value,
label: getEntityName(value),
value: value?.fullyQualifiedName ?? '',
},
];
initialValue = value?.fullyQualifiedName ?? '';
}
}
const initialValues = {
entityReference: initialValue,
};
return (
<InlineEdit
isLoading={isLoading}
saveButtonProps={{
disabled: isLoading,
htmlType: 'submit',
form: 'entity-reference-form',
}}
onCancel={onHideInput}
onSave={noop}>
<Form
id="entity-reference-form"
initialValues={initialValues}
layout="vertical"
validateMessages={VALIDATION_MESSAGES}
onFinish={(values: {
entityReference: DataAssetOption | DataAssetOption[];
}) => {
if (isArray(values.entityReference)) {
onInputSave(
values.entityReference.map((item) => item.reference)
);
} else {
onInputSave(values.entityReference.reference);
}
}}>
<Form.Item
name="entityReference"
rules={[
{
required: true,
},
]}
style={commonStyle}>
<DataAssetAsyncSelectList
initialOptions={initialOptions}
mode={mode}
placeholder={
mode === 'multiple'
? t('label.entity-reference')
: t('label.entity-reference-plural')
}
searchIndex={index.join(',') as SearchIndex}
/>
</Form.Item>
</Form>
</InlineEdit>
);
}
default:
return null;
}
@ -188,13 +654,144 @@ export const PropertyValue: FC<Props> = ({
case 'enum':
return (
<Typography.Text className="break-all" data-testid="value">
<Typography.Text className="break-all" data-testid="enum-value">
{isArray(value) ? value.join(', ') : value}
</Typography.Text>
);
case 'sqlQuery':
return (
<SchemaEditor
className="custom-query-editor query-editor-h-200 custom-code-mirror-theme"
mode={{ name: CSMode.SQL }}
options={{
readOnly: true,
}}
value={value ?? ''}
/>
);
case 'entityReferenceList': {
const entityReferences = (value as EntityReference[]) ?? [];
return (
<div className="entity-list-body">
{entityReferences.map((item) => {
return (
<div
className="entity-reference-list-item flex items-center justify-between"
data-testid={getEntityName(item)}
key={item.id}>
<div className="d-flex items-center">
<Link
to={entityUtilClassBase.getEntityLink(
item.type,
item.fullyQualifiedName as string
)}>
<Button
className="entity-button flex-center p-0 m--ml-1"
icon={
<div className="entity-button-icon m-r-xs">
{['user', 'team'].includes(item.type) ? (
<ProfilePicture
className="d-flex"
isTeam={item.type === 'team'}
name={item.name ?? ''}
type="circle"
width="18"
/>
) : (
getEntityIcon(item.type)
)}
</div>
}
type="text">
<Typography.Text
className="text-left text-xs"
ellipsis={{ tooltip: true }}>
{getEntityName(item)}
</Typography.Text>
</Button>
</Link>
</div>
</div>
);
})}
</div>
);
}
case 'entityReference': {
const item = value as EntityReference;
if (isUndefined(item)) {
return null;
}
return (
<div
className="d-flex items-center"
data-testid="entityReference-value">
<Link
to={entityUtilClassBase.getEntityLink(
item.type,
item.fullyQualifiedName as string
)}>
<Button
className="entity-button flex-center p-0 m--ml-1"
icon={
<div className="entity-button-icon m-r-xs">
{['user', 'team'].includes(item.type) ? (
<ProfilePicture
className="d-flex"
isTeam={item.type === 'team'}
name={item.name ?? ''}
type="circle"
width="18"
/>
) : (
getEntityIcon(item.type)
)}
</div>
}
type="text">
<Typography.Text
className="text-left text-xs"
data-testid="entityReference-value-name"
ellipsis={{ tooltip: true }}>
{getEntityName(item)}
</Typography.Text>
</Button>
</Link>
</div>
);
}
case 'timeInterval': {
const timeInterval = value as TimeIntervalType;
if (isUndefined(timeInterval)) {
return null;
}
return (
<Typography.Text
className="break-all"
data-testid="time-interval-value">
{`StartTime: ${timeInterval.start}`}
<br />
{`EndTime: ${timeInterval.end}`}
</Typography.Text>
);
}
case 'string':
case 'integer':
case 'number':
case 'date':
case 'dateTime':
case 'time':
case 'email':
case 'timestamp':
case 'duration':
default:
return (
<Typography.Text className="break-all" data-testid="value">
@ -206,24 +803,14 @@ export const PropertyValue: FC<Props> = ({
const getValueElement = () => {
const propertyValue = getPropertyValue();
const isInteger = propertyType.name === 'integer';
if (isInteger) {
return !isUndefined(value) ? (
propertyValue
) : (
<span className="text-grey-muted" data-testid="no-data">
{t('message.no-data')}
</span>
);
} else {
return !isEmpty(value) ? (
propertyValue
) : (
<span className="text-grey-muted" data-testid="no-data">
{t('message.no-data')}
</span>
);
}
return !isUndefined(value) ? (
propertyValue
) : (
<span className="text-grey-muted" data-testid="no-data">
{t('message.no-data')}
</span>
);
};
return (

View File

@ -0,0 +1,28 @@
/*
* 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.
*/
.entity-reference-list-item:last-child {
border-bottom: none;
}
.entity-reference-list-item {
.entity-button-icon {
height: 24px;
width: 24px;
display: flex;
align-items: center;
justify-content: center;
svg {
height: 18px;
width: 18px;
}
}
}

View File

@ -0,0 +1,96 @@
/*
* 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.
*/
export const PROPERTY_TYPES_WITH_FORMAT = ['date', 'dateTime'];
export const PROPERTY_TYPES_WITH_ENTITY_REFERENCE = [
'entityReference',
'entityReferenceList',
];
export const ENTITY_REFERENCE_OPTIONS = [
{
key: 'table',
value: 'table',
label: 'Table',
},
{
key: 'storedProcedure',
value: 'storedProcedure',
label: 'Stored Procedure',
},
{
key: 'databaseSchema',
value: 'databaseSchema',
label: 'Database Schema',
},
{
key: 'database',
value: 'database',
label: 'Database',
},
{
key: 'dashboard',
value: 'dashboard',
label: 'Dashboard',
},
{
key: 'dashboardDataModel',
value: 'dashboardDataModel',
label: 'Dashboard DataModel',
},
{
key: 'pipeline',
value: 'pipeline',
label: 'Pipeline',
},
{
key: 'topic',
value: 'topic',
label: 'Topic',
},
{
key: 'container',
value: 'container',
label: 'Container',
},
{
key: 'searchIndex',
value: 'searchIndex',
label: 'Search Index',
},
{
key: 'mlmodel',
value: 'mlmodel',
label: 'MLmodel',
},
{
key: 'glossaryTerm',
value: 'glossaryTerm',
label: 'Glossary Term',
},
{
key: 'tag',
value: 'tag',
label: 'Tag',
},
{
key: 'user',
value: 'user',
label: 'User',
},
{
key: 'team',
value: 'team',
label: 'Team',
},
];

View File

@ -47,7 +47,6 @@ export const LIGHT_GREEN_COLOR = '#4CAF50';
export const DEFAULT_CHART_OPACITY = 1;
export const HOVER_CHART_OPACITY = 0.3;
export const SUPPORTED_FIELD_TYPES = ['string', 'markdown', 'integer', 'enum'];
export const LOGGED_IN_USER_STORAGE_KEY = 'loggedInUsers';
export const ACTIVE_DOMAIN_STORAGE_KEY = 'activeDomain';
export const DEFAULT_DOMAIN_VALUE = 'All Domains';

View File

@ -50,3 +50,5 @@ export const VALID_OBJECT_KEY_REGEX = /^[_$a-zA-Z][_$a-zA-Z0-9]*$/;
export const HEX_COLOR_CODE_REGEX = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
export const TASK_SANITIZE_VALUE_REGEX = /^"|"$/g;
export const TIMESTAMP_UNIX_IN_MILLISECONDS_REGEX = /^\d{13}$/;

View File

@ -413,6 +413,9 @@
"entity-plural": "Entitäten",
"entity-proportion": "{{entity}}-Anteil",
"entity-record-plural": "{{entity}} Records",
"entity-reference": "Entity Reference",
"entity-reference-plural": "Entity References",
"entity-reference-types": "Entity Reference Types",
"entity-service": "{{entity}}-Dienst",
"entity-type-plural": "{{entity}}-Typen",
"entity-version-detail-plural": "Details zu {{entity}}-Versionen",
@ -479,6 +482,7 @@
"for-lowercase": "für",
"foreign-key": "Fremdschlüssel",
"forgot-password": "Passwort vergessen",
"format": "Format",
"fqn-uppercase": "FQN",
"frequently-joined-column-plural": "Häufig verwendete Spalten in Verknüpfungen",
"frequently-joined-table-plural": "Häufig verwendete Tabellen in Verknüpfungen",
@ -1376,6 +1380,7 @@
"downstream-depth-tooltip": "Zeigen Sie bis zu 3 Knoten der nachgelagerten Verbindungslinie an, um das Ziel (Kinderebenen) zu identifizieren.",
"drag-and-drop-files-here": "Dateien hierher ziehen und ablegen",
"drag-and-drop-or-browse-csv-files-here": "Ziehen und ablegen oder <0>{{text}}</0> CSV-Datei hier auswählen",
"duration-in-iso-format": "Duration in ISO 8601 format 'PnYnMnDTnHnMnS'",
"edit-entity-style-description": "Change icon and badge color for the {{entity}}.",
"edit-glossary-display-name-help": "Display-Namen aktualisieren",
"edit-glossary-name-help": "Die Änderung des Namens entfernt das vorhandene Tag und erstellt ein neues mit dem angegebenen Namen.",
@ -1730,6 +1735,7 @@
"unable-to-connect-to-your-dbt-cloud-instance": "URL zum Verbinden mit Ihrer dbt Cloud-Instanz. Zum Beispiel \n https://cloud.getdbt.com oder https://emea.dbt.com/",
"unable-to-error-elasticsearch": "Wir können {{error}} Elasticsearch für Entität-Indizes nicht durchführen.",
"uninstall-app": "Uninstalling this {{app}} application will remove it from OpenMetaData",
"unix-epoch-time-in-ms": "{{prefix}} Unix epoch time in milliseconds",
"update-description-message": "Request to update description for",
"update-displayName-entity": "Aktualisieren Sie die Anzeigenamen für das {{entity}}.",
"update-profiler-settings": "Update profiler setting.",

View File

@ -413,6 +413,9 @@
"entity-plural": "Entities",
"entity-proportion": "{{entity}} Proportion",
"entity-record-plural": "{{entity}} Records",
"entity-reference": "Entity Reference",
"entity-reference-plural": "Entity References",
"entity-reference-types": "Entity Reference Types",
"entity-service": "{{entity}} Service",
"entity-type-plural": "{{entity}} Type",
"entity-version-detail-plural": "{{entity}} Version Details",
@ -479,6 +482,7 @@
"for-lowercase": "for",
"foreign-key": "Foreign Key",
"forgot-password": "Forgot Password",
"format": "Format",
"fqn-uppercase": "FQN",
"frequently-joined-column-plural": "Frequently Joined Columns",
"frequently-joined-table-plural": "Frequently Joined Tables",
@ -1376,6 +1380,7 @@
"downstream-depth-tooltip": "Display up to 3 nodes of downstream lineage to identify the target (child levels).",
"drag-and-drop-files-here": "Drag & drop files here",
"drag-and-drop-or-browse-csv-files-here": "Drag & Drop or <0>{{text}}</0> CSV file here",
"duration-in-iso-format": "Duration in ISO 8601 format 'PnYnMnDTnHnMnS'",
"edit-entity-style-description": "Change icon and badge color for the {{entity}}.",
"edit-glossary-display-name-help": "Update Display Name",
"edit-glossary-name-help": "Changing Name will remove the existing tag and create new one with mentioned name",
@ -1730,6 +1735,7 @@
"unable-to-connect-to-your-dbt-cloud-instance": "URL to connect to your dbt cloud instance. E.g., \n https://cloud.getdbt.com or https://emea.dbt.com/",
"unable-to-error-elasticsearch": "We are unable to {{error}} Elasticsearch for entity indexes.",
"uninstall-app": "Uninstalling this {{app}} application will remove it from OpenMetaData",
"unix-epoch-time-in-ms": "{{prefix}} Unix epoch time in milliseconds",
"update-description-message": "Request to update description for",
"update-displayName-entity": "Update Display Name for the {{entity}}.",
"update-profiler-settings": "Update profiler setting.",

View File

@ -413,6 +413,9 @@
"entity-plural": "Entidades",
"entity-proportion": "Proporción de {{entity}}",
"entity-record-plural": "{{entity}} Records",
"entity-reference": "Entity Reference",
"entity-reference-plural": "Entity References",
"entity-reference-types": "Entity Reference Types",
"entity-service": "Servicio de {{entity}}",
"entity-type-plural": "Tipo de {{entity}}",
"entity-version-detail-plural": "Detalles de versión de {{entity}}",
@ -479,6 +482,7 @@
"for-lowercase": "para",
"foreign-key": "Llave Foránea",
"forgot-password": "Olvidé mi contraseña",
"format": "Format",
"fqn-uppercase": "FQN",
"frequently-joined-column-plural": "Columnas frecuentemente unidas",
"frequently-joined-table-plural": "Tablas frecuentemente unidas",
@ -1376,6 +1380,7 @@
"downstream-depth-tooltip": "Muestre hasta 3 nodos de linaje para identificar el objetivo (niveles descendientes).",
"drag-and-drop-files-here": "Arrastre y suelte archivos aquí",
"drag-and-drop-or-browse-csv-files-here": "Arrastre y suelte o <0>{{text}}</0> archivo CSV aquí",
"duration-in-iso-format": "Duration in ISO 8601 format 'PnYnMnDTnHnMnS'",
"edit-entity-style-description": "Cambie el icono y el color de la insignia para {{entity}}.",
"edit-glossary-display-name-help": "Actualizar el nombre de visualización",
"edit-glossary-name-help": "Cambiar el nombre eliminará la etiqueta existente y creará una nueva con el nombre mencionado.",
@ -1730,6 +1735,7 @@
"unable-to-connect-to-your-dbt-cloud-instance": "URL para conectarse a su instancia de dbt cloud. Por ejemplo, \n https://cloud.getdbt.com o https://emea.dbt.com/",
"unable-to-error-elasticsearch": "No podemos {{error}} Elasticsearch para los índices de entidades.",
"uninstall-app": "Desinstalar esta aplicación {{app}} la eliminará de OpenMetaData",
"unix-epoch-time-in-ms": "{{prefix}} Unix epoch time in milliseconds",
"update-description-message": "Solicitud para actualizar la descripción de",
"update-displayName-entity": "Actualizar el nombre visualizado para el {{entity}}.",
"update-profiler-settings": "Actualizar la configuración del perfilador.",

View File

@ -413,6 +413,9 @@
"entity-plural": "Entités",
"entity-proportion": "Proportion de {{entity}}",
"entity-record-plural": "{{entity}} Records",
"entity-reference": "Entity Reference",
"entity-reference-plural": "Entity References",
"entity-reference-types": "Entity Reference Types",
"entity-service": "Service de {{entity}}",
"entity-type-plural": "{{entity}} Types",
"entity-version-detail-plural": "Détails des Versions de {{entity}}",
@ -479,6 +482,7 @@
"for-lowercase": "pour",
"foreign-key": "Clé Étrangère",
"forgot-password": "Mot de passe oublié",
"format": "Format",
"fqn-uppercase": "FQN",
"frequently-joined-column-plural": "Colonnes fréquemment utilisées dans les jointures",
"frequently-joined-table-plural": "Tables Fréquemment Utilisées dans les Jointures",
@ -1376,6 +1380,7 @@
"downstream-depth-tooltip": "Afficher jusqu'à 3 nœuds de lignée descendante pour identifier la cible (niveaux enfants).",
"drag-and-drop-files-here": "Glisser et déposer les fichiers ici",
"drag-and-drop-or-browse-csv-files-here": "Glisser et déposer ou <0>{{text}}</0> fichier CSV ici",
"duration-in-iso-format": "Duration in ISO 8601 format 'PnYnMnDTnHnMnS'",
"edit-entity-style-description": "Change icon and badge color for the {{entity}}.",
"edit-glossary-display-name-help": "Mise à jour du nom d'affichage",
"edit-glossary-name-help": "Changer le nom supprimera le tag existant et en créera un nouveau avec le nom mentionné",
@ -1730,6 +1735,7 @@
"unable-to-connect-to-your-dbt-cloud-instance": "URL de connexion à votre instance dbt cloud. Par exemple, \n https://cloud.getdbt.com ou https://emea.dbt.com/",
"unable-to-error-elasticsearch": "Nous ne sommes pas en mesure de {{error}} Elasticsearch pour les index d'entités.",
"uninstall-app": "Uninstalling this {{app}} application will remove it from OpenMetaData",
"unix-epoch-time-in-ms": "{{prefix}} Unix epoch time in milliseconds",
"update-description-message": "Request to update description for",
"update-displayName-entity": "Mettre à Jour le Nom d'Affichage de {{entity}}.",
"update-profiler-settings": "Update profiler setting.",

View File

@ -413,6 +413,9 @@
"entity-plural": "ישויות",
"entity-proportion": "יחס {{entity}}",
"entity-record-plural": "{{entity}} Records",
"entity-reference": "Entity Reference",
"entity-reference-plural": "Entity References",
"entity-reference-types": "Entity Reference Types",
"entity-service": "שירות {{entity}}",
"entity-type-plural": "סוגי {{entity}}",
"entity-version-detail-plural": "גרסאות פרטי {{entity}}",
@ -479,6 +482,7 @@
"for-lowercase": "עבור",
"foreign-key": "מפתח זר",
"forgot-password": "שכחת סיסמה",
"format": "Format",
"fqn-uppercase": "FQN",
"frequently-joined-column-plural": "עמודות הצטרפות תדירות",
"frequently-joined-table-plural": "טבלאות הצטרפות תדירות",
@ -1376,6 +1380,7 @@
"downstream-depth-tooltip": "מציג עד ל-3 צמתים של לוקייניאג' מטה כדי לזהות את היעד (רמות הילדים).",
"drag-and-drop-files-here": "גרור ושחרר קבצים לכאן",
"drag-and-drop-or-browse-csv-files-here": "גרור ושחרר או <0>{{text}}</0> קובץ CSV לכאן",
"duration-in-iso-format": "Duration in ISO 8601 format 'PnYnMnDTnHnMnS'",
"edit-entity-style-description": "שנה את צבע הסמל והביסוס עבור {{entity}}.",
"edit-glossary-display-name-help": "עדכן שם תצוגה",
"edit-glossary-name-help": "שינוי שם יסיר את התג הקיים ויצור תג חדש בשם שצוין",
@ -1730,6 +1735,7 @@
"unable-to-connect-to-your-dbt-cloud-instance": "כתובת ה-URL להתחברות למופע שלך של dbt Cloud. לדוגמה, https://cloud.getdbt.com או https://emea.dbt.com/",
"unable-to-error-elasticsearch": "אנו לא יכולים ל{{error}} לאלסטיקסרץ' שלך עבור אינדקסים של ישויות.",
"uninstall-app": "הסרת התקנת {{app}} תסיר אותו מ-OpenMetadata",
"unix-epoch-time-in-ms": "{{prefix}} Unix epoch time in milliseconds",
"update-description-message": "Request to update description for",
"update-displayName-entity": "עדכן את השם המוצג עבור {{entity}}.",
"update-profiler-settings": "עדכן הגדרות הפרופיילר.",

View File

@ -413,6 +413,9 @@
"entity-plural": "エンティティ",
"entity-proportion": "{{entity}} Proportion",
"entity-record-plural": "{{entity}} Records",
"entity-reference": "Entity Reference",
"entity-reference-plural": "Entity References",
"entity-reference-types": "Entity Reference Types",
"entity-service": "{{entity}}サービス",
"entity-type-plural": "{{entity}} Type",
"entity-version-detail-plural": "{{entity}} Version Details",
@ -479,6 +482,7 @@
"for-lowercase": "for",
"foreign-key": "外部キー",
"forgot-password": "パスワードを忘れた場合はこちら",
"format": "Format",
"fqn-uppercase": "FQN",
"frequently-joined-column-plural": "よく結合されるカラム",
"frequently-joined-table-plural": "よく結合されるテーブル",
@ -1376,6 +1380,7 @@
"downstream-depth-tooltip": "Display up to 3 nodes of downstream lineage to identify the target (child levels).",
"drag-and-drop-files-here": "ここにファイルをドラッグ&ドロップ",
"drag-and-drop-or-browse-csv-files-here": "Drag & Drop or <0>{{text}}</0> CSV file here",
"duration-in-iso-format": "Duration in ISO 8601 format 'PnYnMnDTnHnMnS'",
"edit-entity-style-description": "Change icon and badge color for the {{entity}}.",
"edit-glossary-display-name-help": "Update Display Name",
"edit-glossary-name-help": "Changing Name will remove the existing tag and create new one with mentioned name",
@ -1730,6 +1735,7 @@
"unable-to-connect-to-your-dbt-cloud-instance": "URL to connect to your dbt cloud instance. E.g., \n https://cloud.getdbt.com or https://emea.dbt.com/",
"unable-to-error-elasticsearch": "We are unable to {{error}} Elasticsearch for entity indexes.",
"uninstall-app": "Uninstalling this {{app}} application will remove it from OpenMetaData",
"unix-epoch-time-in-ms": "{{prefix}} Unix epoch time in milliseconds",
"update-description-message": "Request to update description for",
"update-displayName-entity": "Update Display Name for the {{entity}}.",
"update-profiler-settings": "Update profiler setting.",

View File

@ -413,6 +413,9 @@
"entity-plural": "Entiteiten",
"entity-proportion": "{{entity}}-verhouding",
"entity-record-plural": "{{entity}} Records",
"entity-reference": "Entity Reference",
"entity-reference-plural": "Entity References",
"entity-reference-types": "Entity Reference Types",
"entity-service": "{{entity}}-service",
"entity-type-plural": "{{entity}}-type",
"entity-version-detail-plural": "{{entity}}-versie-details",
@ -479,6 +482,7 @@
"for-lowercase": "voor",
"foreign-key": "Vreemde sleutel",
"forgot-password": "Wachtwoord vergeten",
"format": "Format",
"fqn-uppercase": "VZV",
"frequently-joined-column-plural": "Vaak gejoinde kolommen",
"frequently-joined-table-plural": "Vaak gejoinde tabellen",
@ -1376,6 +1380,7 @@
"downstream-depth-tooltip": "Toon maximaal 3 knooppunten van downstream-lineage om het doel (kind-levels) te identificeren.",
"drag-and-drop-files-here": "Bestanden hierheen slepen en neerzetten",
"drag-and-drop-or-browse-csv-files-here": "Sleep & neerzetten, of <0>{{text}}</0> CSV-bestand hier",
"duration-in-iso-format": "Duration in ISO 8601 format 'PnYnMnDTnHnMnS'",
"edit-entity-style-description": "Wijzig het pictogram en de badgekleur voor de {{entity}}.",
"edit-glossary-display-name-help": "Werk de weergavenaam bij",
"edit-glossary-name-help": "Het wijzigen van de naam verwijdert het bestaande label en maakt een nieuw label met de vermelde naam aan",
@ -1730,6 +1735,7 @@
"unable-to-connect-to-your-dbt-cloud-instance": "URL om connectie te maken met je dbt-cloudinstantie. Bijvoorbeeld, \n https://cloud.getdbt.com of https://emea.dbt.com/",
"unable-to-error-elasticsearch": "We kunnen geen connectie maken met Elasticsearch voor entiteitsindexen.",
"uninstall-app": "Het verwijderen van deze {{app}}-toepassing verwijdert deze uit OpenMetaData",
"unix-epoch-time-in-ms": "{{prefix}} Unix epoch time in milliseconds",
"update-description-message": "Verzoek om de beschrijving aan te passen voor",
"update-displayName-entity": "Update de weergavenaam voor de {{entity}}.",
"update-profiler-settings": "Profielinstellingen updaten.",

View File

@ -413,6 +413,9 @@
"entity-plural": "Entidades",
"entity-proportion": "Proporção de {{entity}}",
"entity-record-plural": "{{entity}} Records",
"entity-reference": "Entity Reference",
"entity-reference-plural": "Entity References",
"entity-reference-types": "Entity Reference Types",
"entity-service": "Serviço de {{entity}}",
"entity-type-plural": "Tipo de {{entity}}",
"entity-version-detail-plural": "Detalhes da Versão de {{entity}}",
@ -479,6 +482,7 @@
"for-lowercase": "para",
"foreign-key": "Chave Estrangeira",
"forgot-password": "Esqueceu a Senha",
"format": "Format",
"fqn-uppercase": "FQN",
"frequently-joined-column-plural": "Colunas Frequentemente Unidas",
"frequently-joined-table-plural": "Tabelas Frequentemente Unidas",
@ -1376,6 +1380,7 @@
"downstream-depth-tooltip": "Exibir até 3 nós de linhagem a jusante para identificar o alvo (níveis filhos).",
"drag-and-drop-files-here": "Arraste e solte arquivos aqui",
"drag-and-drop-or-browse-csv-files-here": "Arraste e Solte ou <0>{{text}}</0> arquivo CSV aqui",
"duration-in-iso-format": "Duration in ISO 8601 format 'PnYnMnDTnHnMnS'",
"edit-entity-style-description": "Mude o ícone e a cor do distintivo para a {{entity}}.",
"edit-glossary-display-name-help": "Atualizar Nome de Exibição",
"edit-glossary-name-help": "Mudar o Nome removerá a tag existente e criará uma nova com o nome mencionado",
@ -1730,6 +1735,7 @@
"unable-to-connect-to-your-dbt-cloud-instance": "URL para se conectar à sua instância do dbt cloud. Exemplo: \n https://cloud.getdbt.com ou https://emea.dbt.com/",
"unable-to-error-elasticsearch": "Não é possível {{error}} Elasticsearch para índices de entidade.",
"uninstall-app": "Desinstalar este aplicativo {{app}} removerá ele do OpenMetaData",
"unix-epoch-time-in-ms": "{{prefix}} Unix epoch time in milliseconds",
"update-description-message": "Solicitar atualização de descrição para",
"update-displayName-entity": "Atualizar o Nome de Exibição para {{entity}}.",
"update-profiler-settings": "Atualizar configurações do examinador.",

View File

@ -413,6 +413,9 @@
"entity-plural": "Сущности",
"entity-proportion": "Доля \"{{entity}}\"",
"entity-record-plural": "{{entity}} Records",
"entity-reference": "Entity Reference",
"entity-reference-plural": "Entity References",
"entity-reference-types": "Entity Reference Types",
"entity-service": "Сервис {{entity}}",
"entity-type-plural": "Тип {{entity}}",
"entity-version-detail-plural": "{{entity}} Version Details",
@ -479,6 +482,7 @@
"for-lowercase": "для",
"foreign-key": "Внешний ключ",
"forgot-password": "Забыл пароль",
"format": "Format",
"fqn-uppercase": "FQN",
"frequently-joined-column-plural": "Часто соединяемые столбцы",
"frequently-joined-table-plural": "Часто соединяемые таблицы",
@ -1376,6 +1380,7 @@
"downstream-depth-tooltip": "Отобразите до 3 узлов нисходящей линии для идентификации цели (дочерние уровни).",
"drag-and-drop-files-here": "Перетащите файлы сюда",
"drag-and-drop-or-browse-csv-files-here": "Перетащите или <0>{{text}}</0> CSV-файл сюда ",
"duration-in-iso-format": "Duration in ISO 8601 format 'PnYnMnDTnHnMnS'",
"edit-entity-style-description": "Change icon and badge color for the {{entity}}.",
"edit-glossary-display-name-help": "Обновить отображаемое имя",
"edit-glossary-name-help": "Изменение имени удалит существующий тег и создаст новый с указанным именем.",
@ -1730,6 +1735,7 @@
"unable-to-connect-to-your-dbt-cloud-instance": "URL для подключения к вашему экземпляру облака dbt. Например,\n https://cloud.getdbt.com или https://emea.dbt.com/",
"unable-to-error-elasticsearch": "Мы не можем выполнить {{error}} Elasticsearch для индексов сущностей.",
"uninstall-app": "Uninstalling this {{app}} application will remove it from OpenMetaData",
"unix-epoch-time-in-ms": "{{prefix}} Unix epoch time in milliseconds",
"update-description-message": "Request to update description for",
"update-displayName-entity": "Обновите отображаемое имя для {{entity}}.",
"update-profiler-settings": "Update profiler setting.",

View File

@ -413,6 +413,9 @@
"entity-plural": "实体",
"entity-proportion": "{{entity}}比例",
"entity-record-plural": "{{entity}} Records",
"entity-reference": "Entity Reference",
"entity-reference-plural": "Entity References",
"entity-reference-types": "Entity Reference Types",
"entity-service": "{{entity}}服务",
"entity-type-plural": "{{entity}}类型",
"entity-version-detail-plural": "{{entity}}版本详情",
@ -479,6 +482,7 @@
"for-lowercase": "为了",
"foreign-key": "外键",
"forgot-password": "忘记密码",
"format": "Format",
"fqn-uppercase": "FQN",
"frequently-joined-column-plural": "经常连接查询的列 (Frequently Joined Column)",
"frequently-joined-table-plural": "经常连接查询的表 (Frequently Joined Table)",
@ -1376,6 +1380,7 @@
"downstream-depth-tooltip": "显示最多三个下游谱系节点以确定目标(子级)",
"drag-and-drop-files-here": "拖放文件到此处",
"drag-and-drop-or-browse-csv-files-here": "拖放或者<0>{{text}}</0> CSV 文件到此处",
"duration-in-iso-format": "Duration in ISO 8601 format 'PnYnMnDTnHnMnS'",
"edit-entity-style-description": "Change icon and badge color for the {{entity}}.",
"edit-glossary-display-name-help": "更新显示名称",
"edit-glossary-name-help": "修改名称将会删除现有的标签,并在新名称下新建标签",
@ -1730,6 +1735,7 @@
"unable-to-connect-to-your-dbt-cloud-instance": "连接到您的 dbt 云实例的 URL。例如\n https://cloud.getdbt.com 或 https://emea.dbt.com/",
"unable-to-error-elasticsearch": "无法为 Elasticsearch 进行实体索引{{error}}",
"uninstall-app": "Uninstalling this {{app}} application will remove it from OpenMetaData",
"unix-epoch-time-in-ms": "{{prefix}} Unix epoch time in milliseconds",
"update-description-message": "Request to update description for",
"update-displayName-entity": "更改{{entity}}的显示名",
"update-profiler-settings": "Update profiler setting.",

View File

@ -18,6 +18,7 @@ import {
formatDateTimeFromSeconds,
formatDateTimeLong,
formatTimeDurationFromSeconds,
isValidDateFormat,
} from './DateTimeUtils';
const systemLocale = Settings.defaultLocale;
@ -62,3 +63,23 @@ describe('DateTimeUtils tests', () => {
expect(customFormatDateTime(0, 'yyyy/MM/dd')).toBe(`1970/01/01`);
});
});
describe('Date and DateTime Format Validation', () => {
it('isValidDateFormat should validate date format correctly', () => {
expect(isValidDateFormat('yyyy-MM-dd')).toBe(true);
expect(isValidDateFormat('dd-MM-yyyy')).toBe(true);
expect(isValidDateFormat('MM/dd/yyyy')).toBe(true);
expect(isValidDateFormat('dd/MM/yyyy')).toBe(true);
expect(isValidDateFormat('yyyy/MM/dd')).toBe(true);
expect(isValidDateFormat('invalid-format')).toBe(false);
});
it('isValidDateFormat should validate dateTime format correctly', () => {
expect(isValidDateFormat('yyyy-MM-dd HH:mm:ss')).toBe(true);
expect(isValidDateFormat('dd-MM-yyyy HH:mm:ss')).toBe(true);
expect(isValidDateFormat('MM/dd/yyyy HH:mm:ss')).toBe(true);
expect(isValidDateFormat('dd/MM/yyyy HH:mm:ss')).toBe(true);
expect(isValidDateFormat('yyyy/MM/dd HH:mm:ss')).toBe(true);
expect(isValidDateFormat('invalid-format')).toBe(false);
});
});

View File

@ -187,3 +187,13 @@ export const getDaysRemaining = (timestamp: number) =>
toInteger(
-DateTime.now().diff(DateTime.fromMillis(timestamp), ['days']).days
);
export const isValidDateFormat = (format: string) => {
try {
const dt = DateTime.fromFormat(DateTime.now().toFormat(format), format);
return dt.isValid;
} catch (error) {
return false;
}
};