From db0eaa8cbb73af89987fc0714750a8cec028dda2 Mon Sep 17 00:00:00 2001 From: Sachin Chaurasiya Date: Tue, 31 May 2022 23:58:55 +0530 Subject: [PATCH] Feat : #5057 UI : Add support for adding a custom field to an entity (#5184) * Feat : #5057 UI : Add support for adding a custom field to an entity * Add suport for deeplink * Add unit test for custom entity page component * Add unit test for custom entity detail * Fix : params code styling for get list method * Fetch type details on type select * Fix failing unit test * Fix code styling * Fix #5042: Add a field to an entity API is failing * Add unit test * Add unit test * Style : change customfields table styling. * Add unit test * Add support for deleting custom field * Add unit test for delete field flow * Fix failing unit test * Add operation state * Add support for edit field * Add unit test * Fix no-data row styling * Complete todo items * Add check for if no entity data available * Add unit test * Fix code smell * Fix feed issue for entity `type` * Addressing review comment * Addressing review comments Co-authored-by: Sriharsha Chintalapani --- .../src/main/resources/ui/jest.config.js | 1 + .../ui/src/axiosAPIs/metadataTypeAPI.ts | 56 ++++ .../FeedCardHeader/FeedCardHeader.tsx | 2 + .../AddCustomField/AddCustomField.test.tsx | 309 ++++++++++++++++++ .../AddCustomField/AddCustomField.tsx | 247 ++++++++++++++ .../AddCustomField/RightPanel.test.tsx | 30 ++ .../AddCustomField/RightPanel.tsx | 33 ++ .../CustomEntityDetail.test.tsx | 182 +++++++++++ .../CustomEntityDetail/CustomEntityDetail.tsx | 181 ++++++++++ .../CustomFieldTable.test.tsx | 158 +++++++++ .../CustomEntityDetail/CustomFieldTable.tsx | 188 +++++++++++ .../CustomEntityDetail/LeftPanel.test.tsx | 85 +++++ .../CustomEntityDetail/LeftPanel.tsx | 63 ++++ .../components/common/TabsPane/TabsPane.tsx | 3 +- .../resources/ui/src/constants/constants.ts | 24 ++ .../resources/ui/src/enums/entity.enum.ts | 1 + .../src/main/resources/ui/src/jsons/en.ts | 1 + .../CustomEntityPage.test.tsx | 85 +++++ .../CustomEntityPage/CustomEntityPage.tsx | 65 ++++ .../ui/src/router/AuthenticatedAppRouter.tsx | 17 + .../main/resources/ui/src/styles/x-master.css | 4 + .../resources/ui/src/utils/TableUtils.tsx | 4 + .../src/main/resources/ui/tailwind.config.js | 2 + 23 files changed, 1740 insertions(+), 1 deletion(-) create mode 100644 openmetadata-ui/src/main/resources/ui/src/axiosAPIs/metadataTypeAPI.ts create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/AddCustomField/AddCustomField.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/AddCustomField/AddCustomField.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/AddCustomField/RightPanel.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/AddCustomField/RightPanel.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/CustomEntityDetail.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/CustomEntityDetail.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/CustomFieldTable.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/CustomFieldTable.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/LeftPanel.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/LeftPanel.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/CustomEntityPage/CustomEntityPage.test.tsx create mode 100644 openmetadata-ui/src/main/resources/ui/src/pages/CustomEntityPage/CustomEntityPage.tsx diff --git a/openmetadata-ui/src/main/resources/ui/jest.config.js b/openmetadata-ui/src/main/resources/ui/jest.config.js index 1e570a0b413..0b6f71c1714 100644 --- a/openmetadata-ui/src/main/resources/ui/jest.config.js +++ b/openmetadata-ui/src/main/resources/ui/jest.config.js @@ -36,6 +36,7 @@ module.exports = { '^.+\\.ts|tsx?$': 'ts-jest', '^.+\\.js|jsx?$': '/node_modules/babel-jest', }, + transformIgnorePatterns: ['node_modules/?!(react-markdown)'], // "scriptPreprocessor": "/node_modules/babel-jest", // "moduleFileExtensions": ["js", "json","jsx" ], diff --git a/openmetadata-ui/src/main/resources/ui/src/axiosAPIs/metadataTypeAPI.ts b/openmetadata-ui/src/main/resources/ui/src/axiosAPIs/metadataTypeAPI.ts new file mode 100644 index 00000000000..a275056bd60 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/axiosAPIs/metadataTypeAPI.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AxiosResponse } from 'axios'; +import { Operation } from 'fast-json-patch'; +import { Category, CustomField } from '../generated/entity/type'; +import APIClient from './index'; + +export const getTypeListByCategory = ( + category: Category +): Promise => { + const path = `/metadata/types`; + + const params = { category, limit: '12' }; + + return APIClient.get(path, { params }); +}; + +export const getTypeByFQN = (typeFQN: string): Promise => { + const path = `/metadata/types/name/${typeFQN}`; + + const params = { fields: 'customFields' }; + + return APIClient.get(path, { params }); +}; + +export const addFieldToEntity = ( + entityTypeId: string, + data: CustomField +): Promise => { + const path = `/metadata/types/${entityTypeId}`; + + return APIClient.put(path, data); +}; + +export const updateType = ( + entityTypeId: string, + data: Operation[] +): Promise => { + const configOptions = { + headers: { 'Content-type': 'application/json-patch+json' }, + }; + const path = `/metadata/types/${entityTypeId}`; + + return APIClient.patch(path, data, configOptions); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/FeedCardHeader/FeedCardHeader.tsx b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/FeedCardHeader/FeedCardHeader.tsx index 6b7ac630e4c..bac844dfd15 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/FeedCardHeader/FeedCardHeader.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/ActivityFeed/FeedCardHeader/FeedCardHeader.tsx @@ -181,6 +181,7 @@ const FeedCardHeader: FC = ({ EntityType.DASHBOARD_SERVICE, EntityType.MESSAGING_SERVICE, EntityType.PIPELINE_SERVICE, + EntityType.TYPE, ].includes(entityType as EntityType) ) { displayName = getPartialNameFromFQN(entityFQN, ['service']); @@ -207,6 +208,7 @@ const FeedCardHeader: FC = ({ EntityType.WEBHOOK, EntityType.GLOSSARY, EntityType.GLOSSARY_TERM, + EntityType.TYPE, ]; const entityLink = getEntityLink(entityType, entityFQN); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/AddCustomField/AddCustomField.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/AddCustomField/AddCustomField.test.tsx new file mode 100644 index 00000000000..f6614e93574 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/AddCustomField/AddCustomField.test.tsx @@ -0,0 +1,309 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { findByTestId, fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { addFieldToEntity } from '../../../axiosAPIs/metadataTypeAPI'; +import AddCustomField from './AddCustomField'; + +const mockFieldTypes = [ + { + id: '153a0c07-6480-404e-990b-555a42c8a7b5', + name: 'date', + fullyQualifiedName: 'date', + displayName: 'date', + description: '"Date in ISO 8601 format in UTC. Example - \'2018-11-13\'."', + category: 'field', + nameSpace: 'basic', + schema: '', + version: 0.1, + updatedAt: 1653976591924, + updatedBy: 'admin', + href: 'http://localhost:8585/api/v1/metadata/types/153a0c07-6480-404e-990b-555a42c8a7b5', + }, + { + id: '6ce245d8-80c0-4641-9b60-32cf03ca79a2', + name: 'dateTime', + fullyQualifiedName: 'dateTime', + displayName: 'dateTime', + description: + '"Date and time in ISO 8601 format. Example - \'2018-11-13T20:20:39+00:00\'."', + category: 'field', + nameSpace: 'basic', + schema: '', + version: 0.1, + updatedAt: 1653976591924, + updatedBy: 'admin', + href: 'http://localhost:8585/api/v1/metadata/types/6ce245d8-80c0-4641-9b60-32cf03ca79a2', + }, + { + id: 'f5b7d80a-8429-4918-b092-548714ba5a0d', + name: 'duration', + fullyQualifiedName: 'duration', + displayName: 'duration', + description: + '"Duration in ISO 8601 format in UTC. Example - \'P23DT23H\'."', + category: 'field', + nameSpace: 'basic', + schema: '', + version: 0.1, + updatedAt: 1653976591924, + updatedBy: 'admin', + href: 'http://localhost:8585/api/v1/metadata/types/f5b7d80a-8429-4918-b092-548714ba5a0d', + }, + { + id: 'cb405660-95ea-4de5-a5a9-d484b612f33d', + name: 'email', + fullyQualifiedName: 'email', + displayName: 'email', + description: '"Email address of a user or other entities."', + category: 'field', + nameSpace: 'basic', + schema: '', + version: 0.1, + updatedAt: 1653976591924, + updatedBy: 'admin', + href: 'http://localhost:8585/api/v1/metadata/types/cb405660-95ea-4de5-a5a9-d484b612f33d', + }, + { + id: 'be5f2241-8915-4f93-810a-d3c56fe43f29', + name: 'integer', + fullyQualifiedName: 'integer', + displayName: 'integer', + description: '"An integer type."', + category: 'field', + nameSpace: 'basic', + schema: '', + version: 0.1, + updatedAt: 1653976591924, + updatedBy: 'admin', + href: 'http://localhost:8585/api/v1/metadata/types/be5f2241-8915-4f93-810a-d3c56fe43f29', + }, + { + id: '080d393a-7520-44cf-989d-14430668bc97', + name: 'markdown', + fullyQualifiedName: 'markdown', + displayName: 'markdown', + description: '"Text in Markdown format"', + category: 'field', + nameSpace: 'basic', + schema: '', + version: 0.1, + updatedAt: 1653976591924, + updatedBy: 'admin', + href: 'http://localhost:8585/api/v1/metadata/types/080d393a-7520-44cf-989d-14430668bc97', + }, + { + id: '7057cd7c-710b-4a8f-b14a-1950adf87cc0', + name: 'number', + fullyQualifiedName: 'number', + displayName: 'number', + description: + '"A numeric type that includes integer or floating point numbers."', + category: 'field', + nameSpace: 'basic', + schema: '', + version: 0.1, + updatedAt: 1653976591924, + updatedBy: 'admin', + href: 'http://localhost:8585/api/v1/metadata/types/7057cd7c-710b-4a8f-b14a-1950adf87cc0', + }, + { + id: '149f852f-c8b2-4581-84bd-e1d492836009', + name: 'sqlQuery', + fullyQualifiedName: 'sqlQuery', + displayName: 'sqlQuery', + description: '"SQL query statement. Example - \'select * from orders\'."', + category: 'field', + nameSpace: 'basic', + schema: '', + version: 0.1, + updatedAt: 1653976591924, + updatedBy: 'admin', + href: 'http://localhost:8585/api/v1/metadata/types/149f852f-c8b2-4581-84bd-e1d492836009', + }, + { + id: '05e7b2f2-cf1e-4f9f-ae8b-3011372f361e', + name: 'string', + fullyQualifiedName: 'string', + displayName: 'string', + description: '"A String type."', + category: 'field', + nameSpace: 'basic', + schema: '', + version: 0.1, + updatedAt: 1653976591924, + updatedBy: 'admin', + href: 'http://localhost:8585/api/v1/metadata/types/05e7b2f2-cf1e-4f9f-ae8b-3011372f361e', + }, + { + id: '5db5e3ef-b4f5-41a7-a512-8d10409d9b63', + name: 'timeInterval', + fullyQualifiedName: 'timeInterval', + displayName: 'timeInterval', + description: '"Time interval in unixTimeMillis."', + category: 'field', + nameSpace: 'basic', + schema: '', + version: 0.1, + updatedAt: 1653976591924, + updatedBy: 'admin', + href: 'http://localhost:8585/api/v1/metadata/types/5db5e3ef-b4f5-41a7-a512-8d10409d9b63', + }, + { + id: '4ae124a9-c799-42cc-8bd4-048362b4b4e6', + name: 'timestamp', + fullyQualifiedName: 'timestamp', + displayName: 'timestamp', + description: '"Timestamp in Unix epoch time milliseconds."', + category: 'field', + nameSpace: 'basic', + schema: '', + version: 0.1, + updatedAt: 1653976591924, + updatedBy: 'admin', + href: 'http://localhost:8585/api/v1/metadata/types/4ae124a9-c799-42cc-8bd4-048362b4b4e6', + }, +]; + +jest.mock('react-router-dom', () => ({ + useHistory: jest.fn(), + useParams: jest.fn().mockReturnValue({ + entityTypeFQN: 'entityTypeFQN', + }), +})); + +jest.mock('../../../axiosAPIs/metadataTypeAPI', () => ({ + addFieldToEntity: jest + .fn() + .mockImplementation(() => Promise.resolve({ data: mockFieldTypes[0] })), + getTypeByFQN: jest + .fn() + .mockImplementation(() => Promise.resolve({ data: mockFieldTypes[0] })), + getTypeListByCategory: jest + .fn() + .mockImplementation(() => + Promise.resolve({ data: { data: mockFieldTypes } }) + ), +})); + +jest.mock('../../../utils/CommonUtils', () => ({ + errorMsg: jest.fn(), + requiredField: jest.fn(), +})); + +jest.mock('../../../utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), +})); + +jest.mock('../../common/rich-text-editor/RichTextEditor', () => { + return jest + .fn() + .mockReturnValue(
RichTextEditor
); +}); + +jest.mock('../../containers/PageContainer', () => { + return jest + .fn() + .mockImplementation(({ children }: { children: React.ReactNode }) => ( +
{children}
+ )); +}); + +jest.mock( + '../../containers/PageLayout', + () => + ({ + children, + rightPanel, + }: { + children: React.ReactNode; + rightPanel: React.ReactNode; + }) => + ( +
+
{rightPanel}
+ {children} +
+ ) +); + +describe('Test Add Custom Field Component', () => { + it('Should render component', async () => { + const { container } = render(, { + wrapper: MemoryRouter, + }); + + const rightPanel = await findByTestId(container, 'right-panel-content'); + + expect(rightPanel).toBeInTheDocument(); + + const formContainer = await findByTestId(container, 'form-container'); + + expect(formContainer).toBeInTheDocument(); + + const nameField = await findByTestId(container, 'name'); + + const typeField = await findByTestId(container, 'type'); + + const descriptionField = await findByTestId(container, 'richtext-editor'); + + const backButton = await findByTestId(container, 'cancel-custom-field'); + + const createButton = await findByTestId(container, 'create-custom-field'); + + expect(nameField).toBeInTheDocument(); + expect(typeField).toBeInTheDocument(); + expect(descriptionField).toBeInTheDocument(); + + expect(backButton).toBeInTheDocument(); + expect(createButton).toBeInTheDocument(); + }); + + it('Test create field flow', async () => { + const { container } = render(, { + wrapper: MemoryRouter, + }); + + const formContainer = await findByTestId(container, 'form-container'); + + expect(formContainer).toBeInTheDocument(); + + const nameField = await findByTestId(container, 'name'); + + const typeField = await findByTestId(container, 'type'); + + const descriptionField = await findByTestId(container, 'richtext-editor'); + + const backButton = await findByTestId(container, 'cancel-custom-field'); + + const createButton = await findByTestId(container, 'create-custom-field'); + + expect(nameField).toBeInTheDocument(); + expect(typeField).toBeInTheDocument(); + expect(descriptionField).toBeInTheDocument(); + + expect(backButton).toBeInTheDocument(); + expect(createButton).toBeInTheDocument(); + + fireEvent.change(nameField, { target: { value: 'updatedBy' } }); + fireEvent.change(typeField, { + target: { value: '05e7b2f2-cf1e-4f9f-ae8b-3011372f361e' }, + }); + + fireEvent.click(createButton); + + expect(addFieldToEntity).toBeCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/AddCustomField/AddCustomField.tsx b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/AddCustomField/AddCustomField.tsx new file mode 100644 index 00000000000..8ea2b18e52d --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/AddCustomField/AddCustomField.tsx @@ -0,0 +1,247 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AxiosError, AxiosResponse } from 'axios'; +import { uniqueId } from 'lodash'; +import { EditorContentRef, FormErrorData } from 'Models'; +import React, { useEffect, useRef, useState } from 'react'; +import { useHistory, useParams } from 'react-router-dom'; +import { + addFieldToEntity, + getTypeByFQN, + getTypeListByCategory, +} from '../../../axiosAPIs/metadataTypeAPI'; +import { Category, Type } from '../../../generated/entity/type'; +import { errorMsg, requiredField } from '../../../utils/CommonUtils'; +import { showErrorToast } from '../../../utils/ToastUtils'; +import { Button } from '../../buttons/Button/Button'; +import RichTextEditor from '../../common/rich-text-editor/RichTextEditor'; +import PageContainer from '../../containers/PageContainer'; +import PageLayout from '../../containers/PageLayout'; +import { Field } from '../../Field/Field'; +import { RightPanel } from './RightPanel'; + +const InitialFormData = { + name: '', + type: '', +}; + +const AddCustomField = () => { + const { entityTypeFQN } = useParams<{ [key: string]: string }>(); + const history = useHistory(); + const markdownRef = useRef(); + + const [typeDetail, setTypeDetail] = useState({} as Type); + + const [fieldTypes, setFieldTypes] = useState>([]); + + const [formData, setFormData] = + useState>(InitialFormData); + + const [formErrorData, setFormErrorData] = useState( + {} as FormErrorData + ); + + const getDescription = () => markdownRef.current?.getEditorContent() || ''; + + const fetchFieldType = () => { + getTypeListByCategory(Category.Field) + .then((res: AxiosResponse) => { + setFieldTypes(res.data.data); + }) + .catch((err: AxiosError) => { + showErrorToast(err); + }); + }; + + const fetchTypeDetail = (typeFQN: string) => { + getTypeByFQN(typeFQN) + .then((res: AxiosResponse) => { + setTypeDetail(res.data); + }) + .catch((err: AxiosError) => showErrorToast(err)); + }; + + const validateName = (name: string) => { + const nameRegEx = /^[a-z][a-zA-Z0-9]+$/; + + return nameRegEx.test(name); + }; + + const validateType = (type: string) => { + return Boolean(type); + }; + + const handleError = (flag: boolean, field: string) => { + const message = + field === 'name' ? 'Invalid Field Name' : 'Type is required'; + + setFormErrorData((preVdata) => ({ + ...preVdata, + [field]: !flag ? message : '', + })); + }; + + const onChangeHandler = ( + e: React.ChangeEvent + ) => { + const { name, value } = e.target; + + switch (name) { + case 'name': { + const newData = { ...formData, name: value }; + const isValidName = validateName(value); + handleError(isValidName, 'name'); + setFormData(newData); + + break; + } + case 'type': { + const newData = { ...formData, type: value }; + const isValidType = validateType(value); + handleError(isValidType, 'type'); + setFormData(newData); + + break; + } + + default: + break; + } + }; + + const onCancel = () => { + history.goBack(); + }; + + const onSave = () => { + const isValidName = validateName(formData.name); + const isValidType = validateType(formData.type); + if (isValidName && isValidType) { + const fieldData = { + description: getDescription(), + name: formData.name, + fieldType: { + id: formData.type, + type: 'type', + }, + }; + addFieldToEntity(typeDetail.id as string, fieldData) + .then(() => { + history.goBack(); + }) + .catch((err: AxiosError) => { + showErrorToast(err); + }); + } else { + handleError(isValidName, 'name'); + handleError(isValidType, 'type'); + } + }; + + useEffect(() => { + fetchTypeDetail(entityTypeFQN); + }, [entityTypeFQN]); + + useEffect(() => { + fetchFieldType(); + }, []); + + return ( + + }> +
+
Add Custom Field
+ + + + + {formErrorData?.name && errorMsg(formErrorData.name)} + + + + + + {formErrorData?.type && errorMsg(formErrorData.type)} + + + + + + + + + + +
+
+
+ ); +}; + +export default AddCustomField; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/AddCustomField/RightPanel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/AddCustomField/RightPanel.test.tsx new file mode 100644 index 00000000000..e17d49fd0b2 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/AddCustomField/RightPanel.test.tsx @@ -0,0 +1,30 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { RightPanel } from './RightPanel'; + +describe('Test Add custom field right panel component', () => { + it('Should render right panel component', async () => { + const { findByTestId } = render(); + + const panelHeader = await findByTestId('header'); + + const panelBody = await findByTestId('body'); + + expect(panelHeader).toBeInTheDocument(); + + expect(panelBody).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/AddCustomField/RightPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/AddCustomField/RightPanel.tsx new file mode 100644 index 00000000000..0b8a6180e86 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/AddCustomField/RightPanel.tsx @@ -0,0 +1,33 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { Fragment } from 'react'; + +export const RightPanel = () => { + return ( + +
+ Add a Custom Field +
+
+ OpenMetadata supports custom fields in the Table entity. Create a custom + field by adding a unique field name. The name must start with a + lowercase letter, as preferred in the camelCase format. Uppercase + letters and numbers can be included in the field name; but spaces, + underscores, and dots are not supported. Select the preferred field Type + from among the options provided. Describe your custom field to provide + more information to your team. +
+
+ ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/CustomEntityDetail.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/CustomEntityDetail.test.tsx new file mode 100644 index 00000000000..c21d577a3e0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/CustomEntityDetail.test.tsx @@ -0,0 +1,182 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { Type } from '../../generated/entity/type'; +import { Tab } from '../common/TabsPane/TabsPane'; +import CustomEntityDetail from './CustomEntityDetail'; + +const mockData = { + id: '32f81349-d7d7-4a6a-8fc7-d767f233b674', + name: 'table', + fullyQualifiedName: 'table', + displayName: 'table', + description: + // eslint-disable-next-line max-len + '"This schema defines the Table entity. A Table organizes data in rows and columns and is defined by a Schema. OpenMetadata does not have a separate abstraction for Schema. Both Table and Schema are captured in this entity."', + category: 'entity', + nameSpace: 'data', + schema: '', + version: 0.1, + updatedAt: 1653626359971, + updatedBy: 'admin', + href: 'http://localhost:8585/api/v1/metadata/types/32f81349-d7d7-4a6a-8fc7-d767f233b674', +} as Type; + +const mockPush = jest.fn(); + +const MOCK_HISTORY = { + push: mockPush, +}; + +jest.mock('react-router-dom', () => ({ + useHistory: jest.fn().mockImplementation(() => MOCK_HISTORY), +})); + +jest.mock('../../axiosAPIs/metadataTypeAPI', () => ({ + getTypeByFQN: jest + .fn() + .mockImplementation(() => Promise.resolve({ data: mockData })), +})); + +jest.mock('../containers/PageContainer', () => { + return jest + .fn() + .mockImplementation(({ children }: { children: React.ReactNode }) => ( +
{children}
+ )); +}); + +jest.mock('../../constants/constants', () => ({ + getAddCustomFieldPath: jest.fn().mockReturnValue('/custom-entity/table'), +})); + +jest.mock('../common/TabsPane/TabsPane', () => + jest.fn().mockImplementation(({ setActiveTab, tabs }) => { + return ( +
+ +
+ ); + }) +); + +jest.mock('../schema-editor/SchemaEditor', () => + jest + .fn() + .mockReturnValue(
Schema Editor
) +); + +jest.mock('./CustomFieldTable', () => ({ + CustomFieldTable: jest + .fn() + .mockReturnValue( +
CustomFieldTable
+ ), +})); + +jest.mock('./LeftPanel', () => ({ + LeftPanel: jest + .fn() + .mockReturnValue(
LeftPanel
), +})); + +describe('Test Custom Entity Detail Component', () => { + it('Should render custom entity component', async () => { + const { findByTestId } = render( + , + { + wrapper: MemoryRouter, + } + ); + + const leftPanel = await findByTestId('LeftPanel'); + const tabContainer = await findByTestId('tabs'); + const schema = await findByTestId('schema-editor'); + + const schemTab = await findByTestId('Schema'); + const customFieldTab = await findByTestId('Custom Fields'); + + expect(leftPanel).toBeInTheDocument(); + expect(tabContainer).toBeInTheDocument(); + expect(schema).toBeInTheDocument(); + expect(schemTab).toBeInTheDocument(); + expect(customFieldTab).toBeInTheDocument(); + }); + + it('Should render custom fields table if active tab is Custom Fields', async () => { + const { findByTestId } = render( + , + { + wrapper: MemoryRouter, + } + ); + + const leftPanel = await findByTestId('LeftPanel'); + const tabContainer = await findByTestId('tabs'); + + expect(leftPanel).toBeInTheDocument(); + expect(tabContainer).toBeInTheDocument(); + + const customFieldTab = await findByTestId('Custom Fields'); + + expect(customFieldTab).toBeInTheDocument(); + + fireEvent.click(customFieldTab); + + expect(await findByTestId('CustomFieldTable')).toBeInTheDocument(); + }); + + it('Should call history.push method on click of Add field button', async () => { + const { findByTestId } = render( + , + { + wrapper: MemoryRouter, + } + ); + + const tabContainer = await findByTestId('tabs'); + + expect(tabContainer).toBeInTheDocument(); + + const customFieldTab = await findByTestId('Custom Fields'); + + expect(customFieldTab).toBeInTheDocument(); + + fireEvent.click(customFieldTab); + + expect(await findByTestId('CustomFieldTable')).toBeInTheDocument(); + + const addFieldButton = await findByTestId('add-field-button'); + + expect(addFieldButton).toBeInTheDocument(); + + fireEvent.click(addFieldButton); + + expect(mockPush).toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/CustomEntityDetail.tsx b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/CustomEntityDetail.tsx new file mode 100644 index 00000000000..86cdde7fbad --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/CustomEntityDetail.tsx @@ -0,0 +1,181 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AxiosError, AxiosResponse } from 'axios'; +import { compare } from 'fast-json-patch'; +import { isEmpty } from 'lodash'; +import React, { FC, useEffect, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { getTypeByFQN, updateType } from '../../axiosAPIs/metadataTypeAPI'; +import { getAddCustomFieldPath } from '../../constants/constants'; +import { Type } from '../../generated/entity/type'; +import jsonData from '../../jsons/en'; +import { showErrorToast } from '../../utils/ToastUtils'; +import { Button } from '../buttons/Button/Button'; +import ErrorPlaceHolder from '../common/error-with-placeholder/ErrorPlaceHolder'; +import TabsPane from '../common/TabsPane/TabsPane'; +import PageContainer from '../containers/PageContainer'; +import PageLayout from '../containers/PageLayout'; +import SchemaEditor from '../schema-editor/SchemaEditor'; +import { CustomFieldTable } from './CustomFieldTable'; +import { LeftPanel } from './LeftPanel'; + +interface Props { + entityTypes: Array; + entityTypeFQN?: string; +} + +const CustomEntityDetail: FC = ({ entityTypes, entityTypeFQN }) => { + const history = useHistory(); + + const [activeTab, setActiveTab] = useState(1); + const [selectedEntityType, setSelectedEntityType] = useState( + {} as Type + ); + const [selectedEntityTypeDetail, setSelectedEntityTypeDetail] = + useState({} as Type); + + const fetchTypeDetail = (typeFQN: string) => { + getTypeByFQN(typeFQN) + .then((res: AxiosResponse) => { + setSelectedEntityTypeDetail(res.data); + }) + .catch((err: AxiosError) => showErrorToast(err)); + }; + + const onTabChange = (tab: number) => { + setActiveTab(tab); + }; + + const onEntityTypeSelect = (entityType: Type) => { + setSelectedEntityType(entityType); + }; + + const handleAddField = () => { + const path = getAddCustomFieldPath( + selectedEntityTypeDetail.fullyQualifiedName as string + ); + history.push(path); + }; + + const schemaCheck = activeTab === 1 && !isEmpty(selectedEntityTypeDetail); + const schemaValue = selectedEntityTypeDetail.schema || '{}'; + + const customFieldsCheck = + activeTab === 2 && !isEmpty(selectedEntityTypeDetail); + const customFields = selectedEntityTypeDetail.customFields || []; + + const tabs = [ + { + name: 'Schema', + isProtected: false, + position: 1, + }, + { + name: 'Custom Fields', + isProtected: false, + position: 2, + count: customFields.length, + }, + ]; + + const componentCheck = Boolean(entityTypes.length); + + const updateEntityType = (fields: Type['customFields']) => { + const patch = compare(selectedEntityTypeDetail, { + ...selectedEntityTypeDetail, + customFields: fields, + }); + + updateType(selectedEntityTypeDetail.id as string, patch) + .then((res: AxiosResponse) => { + const { customFields: Fields } = res.data; + + setSelectedEntityTypeDetail((prev) => ({ + ...prev, + customFields: Fields, + })); + }) + .catch((err: AxiosError) => showErrorToast(err)); + }; + + useEffect(() => { + if (entityTypes.length) { + const entityType = + entityTypes.find((type) => type.fullyQualifiedName === entityTypeFQN) || + entityTypes[0]; + onEntityTypeSelect(entityType); + } + }, [entityTypes, entityTypeFQN]); + + useEffect(() => { + if (!isEmpty(selectedEntityType)) { + fetchTypeDetail(selectedEntityType.fullyQualifiedName as string); + } + }, [selectedEntityType]); + + return ( + + {componentCheck ? ( + + }> + +
+ {schemaCheck && ( +
+ +
+ )} + {customFieldsCheck && ( +
+
+ +
+ +
+ )} +
+
+ ) : ( + + {jsonData['message']['no-custom-entity']} + + )} +
+ ); +}; + +export default CustomEntityDetail; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/CustomFieldTable.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/CustomFieldTable.test.tsx new file mode 100644 index 00000000000..78acf3d02b7 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/CustomFieldTable.test.tsx @@ -0,0 +1,158 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + findAllByTestId, + findByTestId, + fireEvent, + render, +} from '@testing-library/react'; +import React from 'react'; +import { CustomFieldTable } from './CustomFieldTable'; + +jest.mock('../../utils/CommonUtils', () => ({ + getEntityName: jest.fn().mockReturnValue('entityName'), + isEven: jest.fn().mockReturnValue(true), +})); + +jest.mock('../common/rich-text-editor/RichTextEditorPreviewer', () => { + return jest.fn().mockReturnValue(

RichTextEditorPreview

); +}); + +const mockUpdateEntityType = jest.fn(); +const mockFields = [ + { + name: 'tableCreatedBy', + description: 'To track of who created the table.', + fieldType: { + id: '1815eba0-a7e7-4880-8af5-8eee8710d279', + type: 'type', + name: 'string', + fullyQualifiedName: 'string', + description: '"A String type."', + displayName: 'string', + href: 'http://localhost:8585/api/v1/metadata/types/1815eba0-a7e7-4880-8af5-8eee8710d279', + }, + }, + { + name: 'tableUpdatedBy', + description: 'To track who updated the table.', + fieldType: { + id: '1815eba0-a7e7-4880-8af5-8eee8710d279', + type: 'type', + name: 'string', + fullyQualifiedName: 'string', + description: '"A String type."', + displayName: 'string', + href: 'http://localhost:8585/api/v1/metadata/types/1815eba0-a7e7-4880-8af5-8eee8710d279', + }, + }, +]; + +const mockProp = { + customFields: mockFields, + updateEntityType: mockUpdateEntityType, +}; + +describe('Test CustomField Table Component', () => { + it('Should render table component', async () => { + const { findByTestId, findAllByTestId } = render( + + ); + + const table = await findByTestId('entity-custom-fields-table'); + + expect(table).toBeInTheDocument(); + + const tableHeader = await findByTestId('table-header'); + + const tableBody = await findByTestId('table-body'); + + expect(tableHeader).toBeInTheDocument(); + + expect(tableBody).toBeInTheDocument(); + + const dataRows = await findAllByTestId('data-row'); + + expect(dataRows).toHaveLength(mockFields.length); + }); + + it('Test delete field flow', async () => { + const { container } = render(); + + const table = await findByTestId(container, 'entity-custom-fields-table'); + + expect(table).toBeInTheDocument(); + + const tableHeader = await findByTestId(container, 'table-header'); + + const tableBody = await findByTestId(container, 'table-body'); + + expect(tableHeader).toBeInTheDocument(); + + expect(tableBody).toBeInTheDocument(); + + const dataRows = await findAllByTestId(container, 'data-row'); + + expect(dataRows).toHaveLength(mockFields.length); + + const dataRow = dataRows[0]; + + const deleteButton = await findByTestId(dataRow, 'delete-button'); + + expect(deleteButton).toBeInTheDocument(); + + fireEvent.click(deleteButton); + + // confirmation modal should be visible on click of delete button + const confirmationModal = await findByTestId( + container, + 'confirmation-modal' + ); + + expect(confirmationModal).toBeInTheDocument(); + + const confirmButton = await findByTestId(confirmationModal, 'save-button'); + + fireEvent.click(confirmButton); + + // update type callback should get called on click of confirm button + expect(mockUpdateEntityType).toHaveBeenCalled(); + }); + + it('Should render no data row if there is no custom fields', async () => { + const { findByTestId, queryAllByTestId } = render( + + ); + + const table = await findByTestId('entity-custom-fields-table'); + + expect(table).toBeInTheDocument(); + + const tableHeader = await findByTestId('table-header'); + + const tableBody = await findByTestId('table-body'); + + expect(tableHeader).toBeInTheDocument(); + + expect(tableBody).toBeInTheDocument(); + + const dataRows = queryAllByTestId('data-row'); + + expect(dataRows).toHaveLength(0); + + const noDataRow = await findByTestId('no-data-row'); + + expect(noDataRow).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/CustomFieldTable.tsx b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/CustomFieldTable.tsx new file mode 100644 index 00000000000..9a9a9cb8e52 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/CustomFieldTable.tsx @@ -0,0 +1,188 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import classNames from 'classnames'; +import { isEmpty, uniqueId } from 'lodash'; +import React, { FC, Fragment, useState } from 'react'; +import { CustomField, Type } from '../../generated/entity/type'; +import { getEntityName, isEven } from '../../utils/CommonUtils'; +import SVGIcons, { Icons } from '../../utils/SvgUtils'; +import RichTextEditorPreviewer from '../common/rich-text-editor/RichTextEditorPreviewer'; +import ConfirmationModal from '../Modals/ConfirmationModal/ConfirmationModal'; +import { ModalWithMarkdownEditor } from '../Modals/ModalWithMarkdownEditor/ModalWithMarkdownEditor'; + +interface CustomFieldTableProp { + customFields: CustomField[]; + updateEntityType: (customFields: Type['customFields']) => void; +} + +type Operation = 'delete' | 'update' | 'no-operation'; + +export const CustomFieldTable: FC = ({ + customFields, + updateEntityType, +}) => { + const [selectedField, setSelectedField] = useState( + {} as CustomField + ); + + const [operation, setOperation] = useState('no-operation'); + + const resetSelectedField = () => { + setSelectedField({} as CustomField); + setOperation('no-operation' as Operation); + }; + + const handleFieldDelete = () => { + const updatedFields = customFields.filter( + (field) => field.name !== selectedField.name + ); + updateEntityType(updatedFields); + resetSelectedField(); + }; + + const handleFieldUpdate = (updatedDescription: string) => { + const updatedFields = customFields.map((field) => { + if (field.name === selectedField.name) { + return { ...field, description: updatedDescription }; + } else { + return field; + } + }); + updateEntityType(updatedFields); + resetSelectedField(); + }; + + const deleteCheck = !isEmpty(selectedField) && operation === 'delete'; + const updateCheck = !isEmpty(selectedField) && operation === 'update'; + + return ( + +
+ + + + + + + + + + + {customFields.length ? ( + customFields.map((field, index) => ( + + + + + + + )) + ) : ( + + + + )} + +
+ Name + + Type + + Description + + Actions +
{field.name} + {getEntityName(field.fieldType)} + + {field.description ? ( + + ) : ( + + No description{' '} + + )} + +
+ + +
+
+ No data +
+
+ {deleteCheck && ( + + )} + {updateCheck && ( + + )} +
+ ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/LeftPanel.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/LeftPanel.test.tsx new file mode 100644 index 00000000000..08b7a0ec547 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/LeftPanel.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { Type } from '../../generated/entity/type'; +import { LeftPanel } from './LeftPanel'; + +const typeList = [ + { + id: '311ff2b1-472d-4307-878a-6e41b36a852d', + name: 'table', + fullyQualifiedName: 'table', + displayName: 'table', + description: '""', + category: 'entity', + nameSpace: 'data', + schema: '', + version: 0.2, + updatedAt: 1653903654718, + updatedBy: 'anonymous', + href: 'http://localhost:8585/api/v1/metadata/types/311ff2b1-472d-4307-878a-6e41b36a852d', + }, +] as Array; + +const selectedType = typeList[0]; + +const mockPush = jest.fn(); + +const MOCK_HISTORY = { + push: mockPush, +}; + +jest.mock('react-router-dom', () => ({ + useHistory: jest.fn().mockImplementation(() => MOCK_HISTORY), +})); + +jest.mock('../../constants/constants', () => { + return { + getCustomEntityPath: jest.fn().mockReturnValue('entityPath'), + }; +}); + +const mockProp = { + selectedType, + typeList, +}; + +describe('Test CustomEntity Detail Left Panel Component', () => { + it('Should render Left Panel Component', async () => { + const { findByTestId } = render(); + + const panelHeading = await findByTestId('panel-heading'); + + expect(panelHeading).toBeInTheDocument(); + + typeList.forEach(async (type) => { + expect( + await findByTestId(`entity-${type.displayName}`) + ).toBeInTheDocument(); + }); + }); + + it('Should call history.push on click of entity name', async () => { + const { findByTestId } = render(); + + const entity = await findByTestId(`entity-${typeList[0].displayName}`); + + expect(entity).toBeInTheDocument(); + + fireEvent.click(entity); + + expect(mockPush).toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/LeftPanel.tsx b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/LeftPanel.tsx new file mode 100644 index 00000000000..34fbe5fd9c0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/CustomEntityDetail/LeftPanel.tsx @@ -0,0 +1,63 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import classNames from 'classnames'; +import { startCase, uniqueId } from 'lodash'; +import React, { FC } from 'react'; +import { useHistory } from 'react-router-dom'; +import { getCustomEntityPath } from '../../constants/constants'; +import { Type } from '../../generated/entity/type'; + +interface LeftPanelProp { + typeList: Array; + selectedType: Type; +} + +export const LeftPanel: FC = ({ typeList, selectedType }) => { + const history = useHistory(); + + const getActiveClass = (typeName: string) => { + return typeName === selectedType.name + ? 'tw-bg-primary-lite tw-text-primary tw-font-bold tw-border-l-2 tw-border-primary' + : 'tw-bg-body-main'; + }; + + const handleLabelClick = (typeFQN: string) => { + const path = getCustomEntityPath(typeFQN); + history.push(path); + }; + + return ( +
+
+ Schema & Custom Fields +
+ {typeList.map((type) => ( +
handleLabelClick(type.fullyQualifiedName || '')}> +

{`${startCase( + type.displayName + )}s`}

+
+ ))} +
+ ); +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/common/TabsPane/TabsPane.tsx b/openmetadata-ui/src/main/resources/ui/src/components/common/TabsPane/TabsPane.tsx index 4a568d0fc51..e5c2c189bac 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/common/TabsPane/TabsPane.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/common/TabsPane/TabsPane.tsx @@ -17,7 +17,8 @@ import React from 'react'; import { TITLE_FOR_NON_OWNER_ACTION } from '../../../constants/constants'; import { getCountBadge } from '../../../utils/CommonUtils'; import NonAdminAction from '../non-admin-action/NonAdminAction'; -type Tab = { + +export type Tab = { name: string; icon?: { alt: string; diff --git a/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts b/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts index da16dbd1966..49a19e832d5 100644 --- a/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts +++ b/openmetadata-ui/src/main/resources/ui/src/constants/constants.ts @@ -70,6 +70,7 @@ const PLACEHOLDER_ROUTE_ENTITY_FQN = ':entityFQN'; const PLACEHOLDER_WEBHOOK_NAME = ':webhookName'; const PLACEHOLDER_USER_NAME = ':username'; const PLACEHOLDER_BOTS_NAME = ':botsName'; +const PLACEHOLDER_ENTITY_TYPE_FQN = ':entityTypeFQN'; export const pagingObject = { after: '', before: '', total: 0 }; @@ -212,6 +213,9 @@ export const ROUTES = { ADD_GLOSSARY_TERMS_CHILD: `/glossary/${PLACEHOLDER_GLOSSARY_NAME}/term/${PLACEHOLDER_GLOSSARY_TERMS_FQN}/add-term`, BOTS: `/bots`, BOTS_PROFILE: `/bots/${PLACEHOLDER_BOTS_NAME}`, + CUSTOM_ENTITY: `/custom-entity`, + CUSTOM_ENTITY_DETAIL: `/custom-entity/${PLACEHOLDER_ENTITY_TYPE_FQN}`, + ADD_CUSTOM_FIELD: `/custom-entity/${PLACEHOLDER_ENTITY_TYPE_FQN}/add-field`, }; export const IN_PAGE_SEARCH_ROUTES: Record> = { @@ -363,6 +367,20 @@ export const getBotsPath = (botsName: string) => { return path; }; +export const getAddCustomFieldPath = (entityTypeFQN: string) => { + let path = ROUTES.ADD_CUSTOM_FIELD; + path = path.replace(PLACEHOLDER_ENTITY_TYPE_FQN, entityTypeFQN); + + return path; +}; + +export const getCustomEntityPath = (entityTypeFQN: string) => { + let path = ROUTES.CUSTOM_ENTITY_DETAIL; + path = path.replace(PLACEHOLDER_ENTITY_TYPE_FQN, entityTypeFQN); + + return path; +}; + export const TIMEOUT = { USER_LIST: 60000, // 60 seconds for user retrieval TOAST_DELAY: 5000, // 5 seconds timeout for toaster autohide delay @@ -376,6 +394,12 @@ export const navLinkDevelop = [ export const navLinkSettings = [ { name: 'Bots', to: '/bots', disabled: false, isAdminOnly: true }, + { + name: 'Custom Entity', + to: '/custom-entity', + disabled: false, + isAdminOnly: true, + }, { name: 'Glossaries', to: '/glossary', disabled: false }, { name: 'Roles', to: '/roles', disabled: false, isAdminOnly: true }, { name: 'Services', to: '/services', disabled: false }, diff --git a/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts b/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts index 46ce5e594a3..b8b114c01db 100644 --- a/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts +++ b/openmetadata-ui/src/main/resources/ui/src/enums/entity.enum.ts @@ -26,6 +26,7 @@ export enum EntityType { DASHBOARD_SERVICE = 'dashboardService', PIPELINE_SERVICE = 'pipelineService', WEBHOOK = 'webhook', + TYPE = 'type', } export enum AssetsType { diff --git a/openmetadata-ui/src/main/resources/ui/src/jsons/en.ts b/openmetadata-ui/src/main/resources/ui/src/jsons/en.ts index 3da37ba6ddc..67aa4624bfc 100644 --- a/openmetadata-ui/src/main/resources/ui/src/jsons/en.ts +++ b/openmetadata-ui/src/main/resources/ui/src/jsons/en.ts @@ -163,6 +163,7 @@ const jsonData = { message: { 'no-services': 'No services', 'fail-to-deploy-pipeline': 'Failed to deploy Ingestion Pipeline!', + 'no-custom-entity': 'No custom entity data available', }, }; diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/CustomEntityPage/CustomEntityPage.test.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/CustomEntityPage/CustomEntityPage.test.tsx new file mode 100644 index 00000000000..b8bc501db86 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/CustomEntityPage/CustomEntityPage.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { getTypeListByCategory } from '../../axiosAPIs/metadataTypeAPI'; +import CustomEntityPage from './CustomEntityPage'; + +const mockData = { + id: '32f81349-d7d7-4a6a-8fc7-d767f233b674', + name: 'table', + fullyQualifiedName: 'table', + displayName: 'table', + description: + // eslint-disable-next-line max-len + '"This schema defines the Table entity. A Table organizes data in rows and columns and is defined by a Schema. OpenMetadata does not have a separate abstraction for Schema. Both Table and Schema are captured in this entity."', + category: 'entity', + nameSpace: 'data', + schema: '', + version: 0.1, + updatedAt: 1653626359971, + updatedBy: 'admin', + href: 'http://localhost:8585/api/v1/metadata/types/32f81349-d7d7-4a6a-8fc7-d767f233b674', +}; + +jest.mock('react-router-dom', () => ({ + useParams: jest.fn().mockReturnValue({ + entityTypeFQN: 'table', + }), +})); + +jest.mock('../../axiosAPIs/metadataTypeAPI', () => ({ + getTypeListByCategory: jest + .fn() + .mockImplementation(() => Promise.resolve({ data: { data: [mockData] } })), +})); + +jest.mock('../../components/CustomEntityDetail/CustomEntityDetail', () => { + return jest + .fn() + .mockReturnValue( +
CustomEntityDetail
+ ); +}); + +jest.mock('../../components/Loader/Loader', () => + jest.fn().mockReturnValue(
Loader
) +); + +const mockGetTypeListByCategory = getTypeListByCategory as jest.Mock; + +describe('Test CustomEntity Page Component', () => { + it('Should render Custom Entity Detail Component', async () => { + const { findByTestId } = render(, { + wrapper: MemoryRouter, + }); + + const detailComponent = await findByTestId('CustomEntityDetail'); + + expect(detailComponent).toBeInTheDocument(); + }); + + it('Should render error Component if API fails', async () => { + mockGetTypeListByCategory.mockImplementationOnce(() => Promise.reject()); + + const { findByTestId } = render(, { + wrapper: MemoryRouter, + }); + + const errorComponent = await findByTestId('error'); + + expect(errorComponent).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/pages/CustomEntityPage/CustomEntityPage.tsx b/openmetadata-ui/src/main/resources/ui/src/pages/CustomEntityPage/CustomEntityPage.tsx new file mode 100644 index 00000000000..89686abe944 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/pages/CustomEntityPage/CustomEntityPage.tsx @@ -0,0 +1,65 @@ +/* + * Copyright 2021 Collate + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AxiosError, AxiosResponse } from 'axios'; +import React, { Fragment, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { getTypeListByCategory } from '../../axiosAPIs/metadataTypeAPI'; +import ErrorPlaceHolder from '../../components/common/error-with-placeholder/ErrorPlaceHolder'; +import CustomEntityDetail from '../../components/CustomEntityDetail/CustomEntityDetail'; +import Loader from '../../components/Loader/Loader'; +import { Category, Type } from '../../generated/entity/type'; +import jsonData from '../../jsons/en'; +import { showErrorToast } from '../../utils/ToastUtils'; + +const CustomEntityPage = () => { + const { entityTypeFQN } = useParams<{ [key: string]: string }>(); + const [entityTypes, setEntityTypes] = useState>([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const fetchEntityType = () => { + setIsLoading(true); + getTypeListByCategory(Category.Entity) + .then((res: AxiosResponse) => { + setEntityTypes(res.data.data || []); + }) + .catch((err: AxiosError) => { + setIsError(true); + showErrorToast(err); + }) + .finally(() => { + setIsLoading(false); + }); + }; + + useEffect(() => { + fetchEntityType(); + }, []); + + const Component = () => + isError ? ( + + {jsonData['api-error-messages']['unexpected-server-response']} + + ) : ( + + ); + + return {isLoading ? : }; +}; + +export default CustomEntityPage; diff --git a/openmetadata-ui/src/main/resources/ui/src/router/AuthenticatedAppRouter.tsx b/openmetadata-ui/src/main/resources/ui/src/router/AuthenticatedAppRouter.tsx index 8308be28c69..1b88c61ff6b 100644 --- a/openmetadata-ui/src/main/resources/ui/src/router/AuthenticatedAppRouter.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/router/AuthenticatedAppRouter.tsx @@ -15,6 +15,7 @@ import { isEmpty } from 'lodash'; import React, { FunctionComponent } from 'react'; import { Redirect, Route, Switch } from 'react-router-dom'; import AppState from '../AppState'; +import AddCustomField from '../components/CustomEntityDetail/AddCustomField/AddCustomField'; import { ROUTES } from '../constants/constants'; import AddGlossaryPage from '../pages/AddGlossary/AddGlossaryPage.component'; import AddGlossaryTermPage from '../pages/AddGlossaryTermPage/AddGlossaryTermPage.component'; @@ -24,6 +25,7 @@ import AddWebhookPage from '../pages/AddWebhookPage/AddWebhookPage.component'; import BotsListPage from '../pages/BotsListpage/BotsListpage.component'; import BotsPage from '../pages/BotsPage/BotsPage.component'; import CreateUserPage from '../pages/CreateUserPage/CreateUserPage.component'; +import CustomEntityPage from '../pages/CustomEntityPage/CustomEntityPage'; import DashboardDetailsPage from '../pages/DashboardDetailsPage/DashboardDetailsPage.component'; import DatabaseDetails from '../pages/database-details/index'; import DatabaseSchemaPageComponent from '../pages/DatabaseSchemaPage/DatabaseSchemaPage.component'; @@ -172,6 +174,21 @@ const AuthenticatedAppRouter: FunctionComponent = () => { component={BotsPage} path={ROUTES.BOTS_PROFILE} /> + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/x-master.css b/openmetadata-ui/src/main/resources/ui/src/styles/x-master.css index 01b48862418..c789119b4e8 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/x-master.css +++ b/openmetadata-ui/src/main/resources/ui/src/styles/x-master.css @@ -965,3 +965,7 @@ code { -webkit-line-clamp: 2; -webkit-box-orient: vertical; } + +.custom-entity-schema > .CodeMirror { + height: 70vh; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/TableUtils.tsx b/openmetadata-ui/src/main/resources/ui/src/utils/TableUtils.tsx index 7a7bc94c2a5..6a0aac25fdf 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/TableUtils.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/utils/TableUtils.tsx @@ -19,6 +19,7 @@ import React, { Fragment } from 'react'; import PopOver from '../components/common/popover/PopOver'; import { FQN_SEPARATOR_CHAR } from '../constants/char.constants'; import { + getCustomEntityPath, getDashboardDetailsPath, getDatabaseDetailsPath, getDatabaseSchemaDetailsPath, @@ -200,6 +201,9 @@ export const getEntityLink = ( case EntityType.WEBHOOK: return getEditWebhookPath(fullyQualifiedName); + case EntityType.TYPE: + return getCustomEntityPath(fullyQualifiedName); + case SearchIndex.TABLE: case EntityType.TABLE: default: diff --git a/openmetadata-ui/src/main/resources/ui/tailwind.config.js b/openmetadata-ui/src/main/resources/ui/tailwind.config.js index a38441a52f9..2bafbd85032 100644 --- a/openmetadata-ui/src/main/resources/ui/tailwind.config.js +++ b/openmetadata-ui/src/main/resources/ui/tailwind.config.js @@ -49,6 +49,7 @@ const tagBG = '#EEEAF8'; const badgeBG = '#E3E5E8'; const primaryBG = '#7147E840'; // 'rgba(113, 71, 232, 0.25)'; const backdropBG = '#302E36'; +const lightBG = '#F4F0FD'; // Borders and Separators const mainBorder = '#DCE3EC'; @@ -101,6 +102,7 @@ module.exports = { 'primary-hover': primaryHover, 'primary-active': primaryActive, 'primary-hover-lite': primaryHoverLite, + 'primary-lite': lightBG, secondary: secondary, 'secondary-lite': secondaryBG, 'body-main': bodyBG,