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 <harsha@getcollate.io>
This commit is contained in:
Sachin Chaurasiya 2022-05-31 23:58:55 +05:30 committed by GitHub
parent a5055a5585
commit db0eaa8cbb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1740 additions and 1 deletions

View File

@ -36,6 +36,7 @@ module.exports = {
'^.+\\.ts|tsx?$': 'ts-jest',
'^.+\\.js|jsx?$': '<rootDir>/node_modules/babel-jest',
},
transformIgnorePatterns: ['node_modules/?!(react-markdown)'],
// "scriptPreprocessor": "<rootDir>/node_modules/babel-jest",
// "moduleFileExtensions": ["js", "json","jsx" ],

View File

@ -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<AxiosResponse> => {
const path = `/metadata/types`;
const params = { category, limit: '12' };
return APIClient.get(path, { params });
};
export const getTypeByFQN = (typeFQN: string): Promise<AxiosResponse> => {
const path = `/metadata/types/name/${typeFQN}`;
const params = { fields: 'customFields' };
return APIClient.get(path, { params });
};
export const addFieldToEntity = (
entityTypeId: string,
data: CustomField
): Promise<AxiosResponse> => {
const path = `/metadata/types/${entityTypeId}`;
return APIClient.put(path, data);
};
export const updateType = (
entityTypeId: string,
data: Operation[]
): Promise<AxiosResponse> => {
const configOptions = {
headers: { 'Content-type': 'application/json-patch+json' },
};
const path = `/metadata/types/${entityTypeId}`;
return APIClient.patch(path, data, configOptions);
};

View File

@ -181,6 +181,7 @@ const FeedCardHeader: FC<FeedHeaderProp> = ({
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<FeedHeaderProp> = ({
EntityType.WEBHOOK,
EntityType.GLOSSARY,
EntityType.GLOSSARY_TERM,
EntityType.TYPE,
];
const entityLink = getEntityLink(entityType, entityFQN);

View File

@ -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(<div data-testid="richtext-editor">RichTextEditor</div>);
});
jest.mock('../../containers/PageContainer', () => {
return jest
.fn()
.mockImplementation(({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
));
});
jest.mock(
'../../containers/PageLayout',
() =>
({
children,
rightPanel,
}: {
children: React.ReactNode;
rightPanel: React.ReactNode;
}) =>
(
<div data-testid="PageLayout">
<div data-testid="right-panel-content">{rightPanel}</div>
{children}
</div>
)
);
describe('Test Add Custom Field Component', () => {
it('Should render component', async () => {
const { container } = render(<AddCustomField />, {
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(<AddCustomField />, {
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();
});
});

View File

@ -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<EditorContentRef>();
const [typeDetail, setTypeDetail] = useState<Type>({} as Type);
const [fieldTypes, setFieldTypes] = useState<Array<Type>>([]);
const [formData, setFormData] =
useState<Record<string, string>>(InitialFormData);
const [formErrorData, setFormErrorData] = useState<FormErrorData>(
{} 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<HTMLInputElement | HTMLSelectElement>
) => {
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 (
<PageContainer>
<PageLayout
classes="tw-max-w-full-hd tw-h-full tw-pt-4"
rightPanel={<RightPanel />}>
<div
className="tw-bg-white tw-p-4 tw-border tw-border-main tw-rounded"
data-testid="form-container">
<h6 className="tw-heading tw-text-base">Add Custom Field</h6>
<Field>
<label className="tw-block tw-form-label" htmlFor="name">
{requiredField('Name:')}
</label>
<input
autoComplete="off"
className="tw-form-inputs tw-form-inputs-padding"
data-testid="name"
id="name"
name="name"
placeholder="Name"
type="text"
value={formData.name}
onChange={onChangeHandler}
/>
{formErrorData?.name && errorMsg(formErrorData.name)}
</Field>
<Field>
<label className="tw-block tw-form-label" htmlFor="type">
{requiredField('Type:')}
</label>
<select
className="tw-form-inputs tw-form-inputs-padding"
data-testid="type"
id="type"
name="type"
placeholder="type"
value={formData.type || ''}
onChange={onChangeHandler}>
<option value="">Select type</option>
{fieldTypes.map((fieldType) => (
<option key={uniqueId()} value={fieldType.id}>
{fieldType.displayName}
</option>
))}
</select>
{formErrorData?.type && errorMsg(formErrorData.type)}
</Field>
<Field>
<label
className="tw-block tw-form-label tw-mb-0"
htmlFor="description">
Description:
</label>
<RichTextEditor
data-testid="description"
initialValue=""
ref={markdownRef}
/>
</Field>
<Field className="tw-flex tw-justify-end">
<Button
data-testid="cancel-custom-field"
size="regular"
theme="primary"
variant="text"
onClick={onCancel}>
Back
</Button>
<Button
className="tw-px-3 tw-rounded"
data-testid="create-custom-field"
size="custom"
theme="primary"
type="submit"
onClick={onSave}>
Create
</Button>
</Field>
</div>
</PageLayout>
</PageContainer>
);
};
export default AddCustomField;

View File

@ -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(<RightPanel />);
const panelHeader = await findByTestId('header');
const panelBody = await findByTestId('body');
expect(panelHeader).toBeInTheDocument();
expect(panelBody).toBeInTheDocument();
});
});

View File

@ -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 (
<Fragment>
<h6 className="tw-heading tw-text-base" data-testid="header">
Add a Custom Field
</h6>
<div className="tw-mb-5" data-testid="body">
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.
</div>
</Fragment>
);
};

View File

@ -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 }) => (
<div>{children}</div>
));
});
jest.mock('../../constants/constants', () => ({
getAddCustomFieldPath: jest.fn().mockReturnValue('/custom-entity/table'),
}));
jest.mock('../common/TabsPane/TabsPane', () =>
jest.fn().mockImplementation(({ setActiveTab, tabs }) => {
return (
<div>
<nav
className="tw-flex tw-items-center tw-justify-between tw-gh-tabs-container tw-px-7"
data-testid="tabs"
id="tabs">
{tabs.map((tab: Tab) => (
<button
data-testid={tab.name}
key={tab.position}
onClick={() => setActiveTab?.(tab.position)}>
{tab.name}
</button>
))}
</nav>
</div>
);
})
);
jest.mock('../schema-editor/SchemaEditor', () =>
jest
.fn()
.mockReturnValue(<div data-testid="schema-editor">Schema Editor</div>)
);
jest.mock('./CustomFieldTable', () => ({
CustomFieldTable: jest
.fn()
.mockReturnValue(
<div data-testid="CustomFieldTable">CustomFieldTable</div>
),
}));
jest.mock('./LeftPanel', () => ({
LeftPanel: jest
.fn()
.mockReturnValue(<div data-testid="LeftPanel">LeftPanel</div>),
}));
describe('Test Custom Entity Detail Component', () => {
it('Should render custom entity component', async () => {
const { findByTestId } = render(
<CustomEntityDetail entityTypes={[mockData]} />,
{
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(
<CustomEntityDetail entityTypes={[mockData]} />,
{
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(
<CustomEntityDetail entityTypes={[mockData]} />,
{
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();
});
});

View File

@ -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<Type>;
entityTypeFQN?: string;
}
const CustomEntityDetail: FC<Props> = ({ entityTypes, entityTypeFQN }) => {
const history = useHistory();
const [activeTab, setActiveTab] = useState<number>(1);
const [selectedEntityType, setSelectedEntityType] = useState<Type>(
{} as Type
);
const [selectedEntityTypeDetail, setSelectedEntityTypeDetail] =
useState<Type>({} 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 (
<PageContainer>
{componentCheck ? (
<PageLayout
leftPanel={
<LeftPanel
selectedType={selectedEntityTypeDetail}
typeList={entityTypes}
/>
}>
<TabsPane
activeTab={activeTab}
setActiveTab={onTabChange}
tabs={tabs}
/>
<div className="tw-mt-6">
{schemaCheck && (
<div data-testid="entity-schema">
<SchemaEditor
className="tw-border tw-border-main tw-rounded-md tw-py-4"
editorClass="custom-entity-schema"
value={JSON.parse(schemaValue)}
/>
</div>
)}
{customFieldsCheck && (
<div data-testid="entity-custom-fields">
<div className="tw-flex tw-justify-end">
<Button
className="tw-mb-4 tw-py-1 tw-px-2 tw-rounded"
data-testid="add-field-button"
size="custom"
theme="primary"
onClick={() => handleAddField()}>
Add Field
</Button>
</div>
<CustomFieldTable
customFields={customFields}
updateEntityType={updateEntityType}
/>
</div>
)}
</div>
</PageLayout>
) : (
<ErrorPlaceHolder>
{jsonData['message']['no-custom-entity']}
</ErrorPlaceHolder>
)}
</PageContainer>
);
};
export default CustomEntityDetail;

View File

@ -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(<p>RichTextEditorPreview</p>);
});
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(
<CustomFieldTable {...mockProp} />
);
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(<CustomFieldTable {...mockProp} />);
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(
<CustomFieldTable {...mockProp} customFields={[]} />
);
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();
});
});

View File

@ -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<CustomFieldTableProp> = ({
customFields,
updateEntityType,
}) => {
const [selectedField, setSelectedField] = useState<CustomField>(
{} as CustomField
);
const [operation, setOperation] = useState<Operation>('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 (
<Fragment>
<div className="tw-bg-white tw-border tw-border-main tw-rounded tw-shadow">
<table className="tw-w-full" data-testid="entity-custom-fields-table">
<thead data-testid="table-header">
<tr className="tableHead-row tw-border-t-0 tw-border-l-0 tw-border-r-0">
<th className="tableHead-cell" data-testid="field-name">
Name
</th>
<th className="tableHead-cell" data-testid="field-type">
Type
</th>
<th className="tableHead-cell" data-testid="field-description">
Description
</th>
<th className="tableHead-cell" data-testid="field-actions">
Actions
</th>
</tr>
</thead>
<tbody data-testid="table-body">
{customFields.length ? (
customFields.map((field, index) => (
<tr
className={classNames(
`tableBody-row ${!isEven(index + 1) && 'odd-row'}`,
'tw-border-l-0 tw-border-r-0',
{
'tw-border-b-0': index === customFields.length - 1,
}
)}
data-testid="data-row"
key={uniqueId()}>
<td className="tableBody-cell">{field.name}</td>
<td className="tableBody-cell">
{getEntityName(field.fieldType)}
</td>
<td className="tableBody-cell">
{field.description ? (
<RichTextEditorPreviewer
markdown={field.description || ''}
/>
) : (
<span
className="tw-no-description tw-p-2 tw--ml-1.5"
data-testid="no-description">
No description{' '}
</span>
)}
</td>
<td className="tableBody-cell">
<div className="tw-flex">
<button
className="tw-cursor-pointer"
data-testid="edit-button"
onClick={() => {
setSelectedField(field);
setOperation('update');
}}>
<SVGIcons
alt="edit"
icon={Icons.EDIT}
title="Edit"
width="12px"
/>
</button>
<button
className="tw-cursor-pointer tw-ml-4"
data-testid="delete-button"
onClick={() => {
setSelectedField(field);
setOperation('delete');
}}>
<SVGIcons
alt="delete"
icon={Icons.DELETE}
title="Delete"
width="12px"
/>
</button>
</div>
</td>
</tr>
))
) : (
<tr
className="tableBody-row tw-border-l-0 tw-border-r-0 tw-border-b-0"
data-testid="no-data-row">
<td
className="tableBody-cell tw-text-grey-muted tw-text-center"
colSpan={4}>
No data
</td>
</tr>
)}
</tbody>
</table>
</div>
{deleteCheck && (
<ConfirmationModal
bodyText={`Are you sure you want to delete the field ${selectedField.name}`}
cancelText="Cancel"
confirmText="Confirm"
header={`Delete field ${selectedField.name}`}
onCancel={resetSelectedField}
onConfirm={handleFieldDelete}
/>
)}
{updateCheck && (
<ModalWithMarkdownEditor
header={`Edit Field: "${selectedField.name}"`}
placeholder="Enter Field Description"
value={selectedField.description || ''}
onCancel={resetSelectedField}
onSave={handleFieldUpdate}
/>
)}
</Fragment>
);
};

View File

@ -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<Type>;
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(<LeftPanel {...mockProp} />);
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(<LeftPanel {...mockProp} />);
const entity = await findByTestId(`entity-${typeList[0].displayName}`);
expect(entity).toBeInTheDocument();
fireEvent.click(entity);
expect(mockPush).toHaveBeenCalled();
});
});

View File

@ -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<Type>;
selectedType: Type;
}
export const LeftPanel: FC<LeftPanelProp> = ({ 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 (
<div className="tw-flex tw-flex-col tw-bg-white tw-h-screen tw-p-3 tw-border tw-border-main tw-rounded-md">
<h6 className="tw-heading tw-text-sm" data-testid="panel-heading">
Schema &amp; Custom Fields
</h6>
{typeList.map((type) => (
<div
className="tw-mb-3 tw-cursor-pointer"
data-testid={`entity-${type.displayName}`}
key={uniqueId()}
onClick={() => handleLabelClick(type.fullyQualifiedName || '')}>
<p
className={classNames(
'tw-px-3 tw-py-2 tw--mx-3',
getActiveClass(type.name)
)}
data-testid="entity-displayName">{`${startCase(
type.displayName
)}s`}</p>
</div>
))}
</div>
);
};

View File

@ -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;

View File

@ -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<string, Array<string>> = {
@ -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 },

View File

@ -26,6 +26,7 @@ export enum EntityType {
DASHBOARD_SERVICE = 'dashboardService',
PIPELINE_SERVICE = 'pipelineService',
WEBHOOK = 'webhook',
TYPE = 'type',
}
export enum AssetsType {

View File

@ -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',
},
};

View File

@ -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(
<div data-testid="CustomEntityDetail">CustomEntityDetail</div>
);
});
jest.mock('../../components/Loader/Loader', () =>
jest.fn().mockReturnValue(<div data-testid="loader">Loader</div>)
);
const mockGetTypeListByCategory = getTypeListByCategory as jest.Mock;
describe('Test CustomEntity Page Component', () => {
it('Should render Custom Entity Detail Component', async () => {
const { findByTestId } = render(<CustomEntityPage />, {
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(<CustomEntityPage />, {
wrapper: MemoryRouter,
});
const errorComponent = await findByTestId('error');
expect(errorComponent).toBeInTheDocument();
});
});

View File

@ -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<Array<Type>>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isError, setIsError] = useState<boolean>(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 ? (
<ErrorPlaceHolder>
{jsonData['api-error-messages']['unexpected-server-response']}
</ErrorPlaceHolder>
) : (
<CustomEntityDetail
entityTypeFQN={entityTypeFQN}
entityTypes={entityTypes}
/>
);
return <Fragment>{isLoading ? <Loader /> : <Component />}</Fragment>;
};
export default CustomEntityPage;

View File

@ -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}
/>
<AdminProtectedRoute
exact
component={CustomEntityPage}
path={ROUTES.CUSTOM_ENTITY}
/>
<AdminProtectedRoute
exact
component={CustomEntityPage}
path={ROUTES.CUSTOM_ENTITY_DETAIL}
/>
<AdminProtectedRoute
exact
component={AddCustomField}
path={ROUTES.ADD_CUSTOM_FIELD}
/>
<Redirect to={ROUTES.NOT_FOUND} />
</Switch>

View File

@ -965,3 +965,7 @@ code {
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.custom-entity-schema > .CodeMirror {
height: 70vh;
}

View File

@ -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:

View File

@ -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,