diff --git a/openmetadata-ui/src/main/resources/ui/.prettierignore b/openmetadata-ui/src/main/resources/ui/.prettierignore index 3494d0e3791..0fd16ba3e16 100644 --- a/openmetadata-ui/src/main/resources/ui/.prettierignore +++ b/openmetadata-ui/src/main/resources/ui/.prettierignore @@ -47,3 +47,10 @@ src/antlr/generated # Generated TS src/generated/ + + +# Assets +*.svg +*.png +*.ico +*.ttf diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-browse-file.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-browse-file.svg new file mode 100644 index 00000000000..697c9800719 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-browse-file.svg @@ -0,0 +1,4 @@ + + + + diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-export.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-export.svg new file mode 100644 index 00000000000..51ef116c1a3 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-export.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-import.svg b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-import.svg new file mode 100644 index 00000000000..514aece2650 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/assets/svg/ic-import.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ExportGlossaryModal/ExportGlossaryModal.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ExportGlossaryModal/ExportGlossaryModal.test.tsx new file mode 100644 index 00000000000..4ddbd5652d7 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ExportGlossaryModal/ExportGlossaryModal.test.tsx @@ -0,0 +1,102 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { exportGlossaryInCSVFormat } from 'rest/glossaryAPI'; +import ExportGlossaryModal from './ExportGlossaryModal'; + +const mockCancel = jest.fn(); +const mockOk = jest.fn(); + +const mockProps = { + isModalOpen: true, + onCancel: mockCancel, + onOk: mockOk, + glossaryName: 'Glossary1', +}; + +jest.mock('rest/glossaryAPI', () => ({ + exportGlossaryInCSVFormat: jest + .fn() + .mockImplementation(() => Promise.resolve()), +})); + +jest.mock('utils/TimeUtils', () => ({ + getCurrentLocaleDate: jest.fn().mockImplementation(() => 'data-string'), +})); + +describe('Export Glossary Modal', () => { + it('Should render the modal content', async () => { + render(); + + const container = await screen.getByTestId('export-glossary-modal'); + const title = await screen.getByText('label.export-glossary-terms'); + const input = await screen.getByTestId('file-name-input'); + + const cancelButton = await screen.getByText('label.cancel'); + const exportButton = await screen.getByText('label.export'); + + expect(cancelButton).toBeInTheDocument(); + + expect(exportButton).toBeInTheDocument(); + + expect(input).toBeInTheDocument(); + + expect(title).toBeInTheDocument(); + + expect(container).toBeInTheDocument(); + }); + + it('File name change should work', async () => { + render(); + + const input = await screen.getByTestId('file-name-input'); + + expect(input).toBeInTheDocument(); + + await act(async () => { + fireEvent.change(input, { target: { value: 'my_file_name' } }); + }); + + expect(input).toHaveValue('my_file_name'); + }); + + it('Export should work', async () => { + render(); + + const exportButton = await screen.getByText('label.export'); + + expect(exportButton).toBeInTheDocument(); + + await act(async () => { + userEvent.click(exportButton); + }); + + expect(exportGlossaryInCSVFormat).toHaveBeenCalledWith('Glossary1'); + }); + + it('Cancel should work', async () => { + render(); + + const cancelButton = await screen.getByText('label.cancel'); + + expect(cancelButton).toBeInTheDocument(); + + await act(async () => { + userEvent.click(cancelButton); + }); + + expect(mockCancel).toHaveBeenCalled(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ExportGlossaryModal/ExportGlossaryModal.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ExportGlossaryModal/ExportGlossaryModal.tsx new file mode 100644 index 00000000000..e1ff1c6d4c7 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ExportGlossaryModal/ExportGlossaryModal.tsx @@ -0,0 +1,98 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Form, Input, Modal } from 'antd'; +import { AxiosError } from 'axios'; +import React, { ChangeEvent, FC, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { exportGlossaryInCSVFormat } from 'rest/glossaryAPI'; +import { getCurrentLocaleDate } from 'utils/TimeUtils'; +import { showErrorToast } from 'utils/ToastUtils'; + +interface Props { + isModalOpen: boolean; + glossaryName: string; + onCancel: () => void; + onOk: () => void; +} + +const ExportGlossaryModal: FC = ({ + isModalOpen, + onCancel, + onOk, + glossaryName, +}) => { + const { t } = useTranslation(); + const [fileName, setFileName] = useState( + `${glossaryName}_${getCurrentLocaleDate()}` + ); + + const handleOnFileNameChange = (e: ChangeEvent) => + setFileName(e.target.value); + + /** + * Creates a downloadable file from csv string and download it on users system + * @param data - csv string + */ + const handleDownload = (data: string) => { + const element = document.createElement('a'); + + const file = new Blob([data], { type: 'text/plain' }); + + element.textContent = 'download-file'; + element.href = URL.createObjectURL(file); + element.download = `${fileName}.csv`; + document.body.appendChild(element); + element.click(); + + URL.revokeObjectURL(element.href); + document.body.removeChild(element); + }; + + const handleExport = async () => { + try { + const data = await exportGlossaryInCSVFormat(glossaryName); + + handleDownload(data); + onOk(); + } catch (error) { + showErrorToast(error as AxiosError); + } + }; + + return ( + +
+ + + +
+
+ ); +}; + +export default ExportGlossaryModal; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx index c928ba681ba..10f02a5cac3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.component.tsx @@ -29,7 +29,7 @@ import { AxiosError } from 'axios'; import { cloneDeep, isEmpty } from 'lodash'; import React, { Fragment, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; +import { Link, useHistory, useParams } from 'react-router-dom'; import { FQN_SEPARATOR_CHAR } from '../../constants/char.constants'; import { getUserPath } from '../../constants/constants'; import { GLOSSARIES_DOCS } from '../../constants/docs.constants'; @@ -44,7 +44,10 @@ import { checkPermission, DEFAULT_ENTITY_PERMISSION, } from '../../utils/PermissionsUtils'; -import { getGlossaryPath } from '../../utils/RouterUtils'; +import { + getGlossaryPath, + getGlossaryPathWithAction, +} from '../../utils/RouterUtils'; import SVGIcons, { Icons } from '../../utils/SvgUtils'; import { formatDateTime } from '../../utils/TimeUtils'; import { showErrorToast } from '../../utils/ToastUtils'; @@ -66,9 +69,14 @@ import { ResourceEntity, } from '../PermissionProvider/PermissionProvider.interface'; import GlossaryV1Skeleton from '../Skeleton/GlossaryV1/GlossaryV1LeftPanelSkeleton.component'; -import { GlossaryV1Props } from './GlossaryV1.interfaces'; +import { GlossaryAction, GlossaryV1Props } from './GlossaryV1.interfaces'; import './GlossaryV1.style.less'; +import { ReactComponent as ExportIcon } from 'assets/svg/ic-export.svg'; +import { ReactComponent as ImportIcon } from 'assets/svg/ic-import.svg'; +import ExportGlossaryModal from './ExportGlossaryModal/ExportGlossaryModal'; +import ImportGlossary from './ImportGlossary/ImportGlossary'; + const { Title } = Typography; const GlossaryV1 = ({ @@ -98,6 +106,8 @@ const GlossaryV1 = ({ onRelatedTermClick, currentPage, }: GlossaryV1Props) => { + const { action } = useParams<{ action: GlossaryAction }>(); + const history = useHistory(); const { DirectoryTree } = Tree; const { t } = useTranslation(); @@ -126,6 +136,28 @@ const GlossaryV1 = ({ const [glossaryTermPermission, setGlossaryTermPermission] = useState(DEFAULT_ENTITY_PERMISSION); + const handleGlossaryExport = () => + history.push( + getGlossaryPathWithAction(selectedData.name, GlossaryAction.EXPORT) + ); + + const handleCancelGlossaryExport = () => + history.push(getGlossaryPath(selectedData.name)); + + const handleGlossaryImport = () => + history.push( + getGlossaryPathWithAction(selectedData.name, GlossaryAction.IMPORT) + ); + + const isImportAction = useMemo( + () => action === GlossaryAction.IMPORT, + [action] + ); + const isExportAction = useMemo( + () => action === GlossaryAction.EXPORT, + [action] + ); + const fetchGlossaryPermission = async () => { try { const response = await getEntityPermission( @@ -266,6 +298,76 @@ const GlossaryV1 = ({ }); const manageButtonContent = [ + ...(isGlossaryActive + ? [ + { + label: ( + { + e.stopPropagation(); + handleGlossaryExport(); + setShowActions(false); + }}> + + + + + + + + {t('label.export')} + + + + + {t('label.export-glossary-terms')} + + + + + + ), + key: 'export-button', + }, + { + label: ( + { + e.stopPropagation(); + handleGlossaryImport(); + setShowActions(false); + }}> + + + + + + + + {t('label.import')} + + + + + {t('label.import-glossary-terms')} + + + + + + ), + key: 'import-button', + }, + ] + : []), { label: ( -
-
- -
-
- - - Add term - - - - - - - - -
-
- {isChildLoading ? ( - + {isImportAction ? ( + ) : ( <> -
- {isNameEditing ? ( - - - onDisplayNameChange(e.target.value)} - /> - - - - - - - ) : ( - - - {getEntityName(selectedData)} - +
+
+ +
+
+ + + Add term + + + + - setIsNameEditing(true)}> - - + - - )} + +
- - - {t('label.updated-by')} - - - {selectedData.updatedBy && selectedData.updatedAt ? ( - <> - {' '} - - - - {selectedData.updatedBy} - {' '} - {t('label.on-lowercase')}{' '} - {formatDateTime(selectedData.updatedAt || 0)} + {isChildLoading ? ( + + ) : ( + <> +
+ {isNameEditing ? ( + + + onDisplayNameChange(e.target.value)} + /> + + + + + + + ) : ( + + + {getEntityName(selectedData)} + + + setIsNameEditing(true)}> + + + + + )} +
+ + + {t('label.updated-by')} - - - ) : ( - '--' - )} - - {!isEmpty(selectedData) && - (isGlossaryActive ? ( - - ) : ( - - ))} + {selectedData.updatedBy && selectedData.updatedAt ? ( + <> + {' '} + + + + {selectedData.updatedBy} + {' '} + {t('label.on-lowercase')}{' '} + {formatDateTime(selectedData.updatedAt || 0)} + + + ) : ( + '--' + )} +
+ {!isEmpty(selectedData) && + (isGlossaryActive ? ( + + ) : ( + + ))} + + )} + {selectedData && ( + setIsDelete(false)} + onConfirm={handleDelete} + /> + )} + {isExportAction && ( + + )} )} - {selectedData && ( - setIsDelete(false)} - onConfirm={handleDelete} - /> - )} ) : ( diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.interfaces.ts b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.interfaces.ts index baa96b42fb8..452a619ca74 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.interfaces.ts +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.interfaces.ts @@ -43,3 +43,8 @@ export type GlossaryV1Props = { handleUserRedirection?: (name: string) => void; isChildLoading: boolean; }; + +export enum GlossaryAction { + EXPORT = 'export', + IMPORT = 'import', +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/Glossary.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx similarity index 75% rename from openmetadata-ui/src/main/resources/ui/src/components/Glossary/Glossary.test.tsx rename to openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx index 53b16b470a9..bfa60bb6c4d 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/Glossary.test.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/GlossaryV1.test.tsx @@ -19,13 +19,22 @@ import { queryByTestId, queryByText, render, + screen, } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { omit } from 'lodash'; import { LoadingState } from 'Models'; import React from 'react'; import { mockedAssetData, mockedGlossaries } from '../../mocks/Glossary.mock'; import GlossaryV1 from './GlossaryV1.component'; +let params = { + glossaryName: 'GlossaryName', + action: '', +}; + +const mockPush = jest.fn(); + jest.mock('../PermissionProvider/PermissionProvider', () => ({ usePermissionProvider: jest.fn().mockReturnValue({ getEntityPermission: jest.fn().mockReturnValue({ @@ -74,18 +83,16 @@ jest.mock('../../utils/PermissionsUtils', () => ({ })); jest.mock('react-router-dom', () => ({ - useHistory: jest.fn(), - useParams: jest.fn().mockReturnValue({ - glossaryName: 'GlossaryName', - }), + useHistory: jest.fn().mockImplementation(() => ({ + push: mockPush, + })), + useParams: jest.fn().mockImplementation(() => params), + Link: jest.fn().mockImplementation(({ children }) => {children}), })); jest.mock('components/GlossaryDetails/GlossaryDetails.component', () => { return jest.fn().mockReturnValue(<>Glossary-Details component); }); -jest.mock('react-router-dom', () => ({ - Link: jest.fn().mockImplementation(({ children }) => {children}), -})); jest.mock('components/GlossaryTerms/GlossaryTermsV1.component', () => { return jest.fn().mockReturnValue(<>Glossary-Term component); @@ -109,6 +116,20 @@ jest.mock('../../utils/TimeUtils', () => ({ formatDateTime: jest.fn().mockReturnValue('Jan 15, 1970, 12:26 PM'), })); +jest.mock('./ExportGlossaryModal/ExportGlossaryModal', () => + jest + .fn() + .mockReturnValue( +
ExportGlossaryModal
+ ) +); + +jest.mock('./ImportGlossary/ImportGlossary', () => + jest + .fn() + .mockReturnValue(
ImportGlossary
) +); + const mockProps = { assetData: mockedAssetData, currentPage: 1, @@ -221,4 +242,54 @@ describe('Test Glossary component', () => { expect(updateByContainer).toBeInTheDocument(); expect(updateByDetails).not.toBeInTheDocument(); }); + + it('Should render import glossary component', async () => { + params = { ...params, action: 'import' }; + + await act(async () => { + const { container } = render(); + + const importGlossary = getByTestId(container, 'import-glossary'); + + expect(importGlossary).toBeInTheDocument(); + }); + }); + + it('Should render export glossary component', async () => { + params = { ...params, action: 'export' }; + await act(async () => { + const { container } = render(); + + const exportGlossary = getByTestId(container, 'export-glossary'); + + expect(exportGlossary).toBeInTheDocument(); + }); + }); + + it('Should render export and import option', async () => { + await act(async () => { + const { container } = render(); + + const manageButton = getByTestId(container, 'manage-button'); + + expect(manageButton).toBeInTheDocument(); + + await act(async () => { + userEvent.click(manageButton); + }); + + const exportOption = await screen.getByTestId('export-button'); + + const importOption = await screen.getByTestId('import-button'); + + expect(exportOption).toBeInTheDocument(); + expect(importOption).toBeInTheDocument(); + + await act(async () => { + userEvent.click(importOption); + }); + + expect(mockPush).toHaveBeenCalled(); + }); + }); }); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportGlossary/ImportGlossary.interface.ts b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportGlossary/ImportGlossary.interface.ts new file mode 100644 index 00000000000..7b1e80472ba --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportGlossary/ImportGlossary.interface.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export interface GlossaryCSVRecord { + status?: string; + details?: string; + parent?: string; + 'name*': string; + displayName?: string; + 'description*': string; + synonyms?: string; + relatedTerms?: string; + references?: string; + tags?: string; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportGlossary/ImportGlossary.less b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportGlossary/ImportGlossary.less new file mode 100644 index 00000000000..e4d1841776f --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportGlossary/ImportGlossary.less @@ -0,0 +1,25 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@info-color: #1890ff; +@bg-white: #ffffff; + +.ant-upload.file-dragger-wrapper { + border-color: @info-color; + border-width: 2px; + &:not(.ant-upload-disabled) { + &:hover { + border-color: @info-color; + } + } + background: @bg-white; +} diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportGlossary/ImportGlossary.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportGlossary/ImportGlossary.test.tsx new file mode 100644 index 00000000000..a72150fef78 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportGlossary/ImportGlossary.test.tsx @@ -0,0 +1,270 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + act, + fireEvent, + render, + screen, + waitForElement, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CSVImportResult, Status } from 'generated/type/csvImportResult'; +import React from 'react'; +import ImportGlossary from './ImportGlossary'; + +const mockPush = jest.fn(); +const glossaryName = 'Glossary1'; +let mockCsvImportResult = { + dryRun: true, + status: 'success', + numberOfRowsProcessed: 3, + numberOfRowsPassed: 3, + numberOfRowsFailed: 0, + importResultsCsv: `status,details,parent,name*,displayName,description*,synonyms,relatedTerms,references,tags\r + success,Entity updated,,Glossary2 Term,Glossary2 Term displayName,Description for Glossary2 Term,,,,\r + success,Entity updated,,Glossary2 term2,Glossary2 term2,Description data.,,,,\r`, +} as CSVImportResult; + +const mockCsvContent = `parent,name*,displayName,description*,synonyms,relatedTerms,references,tags +,Glossary2 Term,Glossary2 Term,Description for Glossary2 Term,,,, +,Glossary2 term2,Glossary2 term2,Description data.,,,,`; + +const mockIncorrectCsvContent = `parent,name*,displayName,description*,synonyms,relatedTerms,references,tags +,,Glossary2 Term,Glossary2 Term,Description for Glossary2 Term,,,, +,,Glossary2 term2,Glossary2 term2,Description data.,,,, +`; + +jest.mock('components/common/title-breadcrumb/title-breadcrumb.component', () => + jest.fn().mockReturnValue(
Breadcrumb
) +); + +jest.mock('components/Loader/Loader', () => + jest.fn().mockReturnValue(
Loader
) +); + +jest.mock('../ImportResult/ImportResult', () => + jest + .fn() + .mockReturnValue(
Import Result
) +); + +jest.mock('rest/glossaryAPI', () => ({ + importGlossaryInCSVFormat: jest + .fn() + .mockImplementation(() => Promise.resolve(mockCsvImportResult)), +})); + +jest.mock('react-router-dom', () => ({ + useHistory: jest.fn().mockImplementation(() => ({ + push: mockPush, + })), +})); + +jest.mock('utils/RouterUtils', () => ({ + getGlossaryPath: jest.fn().mockImplementation(() => 'glossary-path'), +})); + +jest.mock('utils/ToastUtils', () => ({ + showErrorToast: jest.fn(), +})); + +describe('Import Glossary', () => { + it('Should render the all components', async () => { + render(); + + const breadcrumb = await screen.getByTestId('breadcrumb'); + const title = await screen.getByTestId('title'); + const uploader = await screen.getByTestId('upload-file-widget'); + const uploadButton = await screen.getByTestId('upload-button'); + + expect(breadcrumb).toBeInTheDocument(); + expect(title).toBeInTheDocument(); + expect(uploader).toBeInTheDocument(); + expect(uploadButton).toBeInTheDocument(); + }); + + it('Import Should work', async () => { + const file = new File([mockCsvContent], 'glossary-terms.csv', { + type: 'text/plain', + }); + const flushPromises = () => new Promise(setImmediate); + + render(); + + const uploadDragger = await waitForElement(() => + screen.getByTestId('upload-file-widget') + ); + + expect(uploadDragger).toBeInTheDocument(); + + await act(async () => { + fireEvent.change(uploadDragger, { target: { files: [file] } }); + }); + await act(async () => { + await flushPromises(); + }); + + const successBadge = await screen.getByTestId('success-badge'); + const cancelButton = await screen.getByTestId('cancel-button'); + const previewButton = await screen.getByTestId('preview-button'); + const fileName = await screen.getByTestId('file-name'); + + expect(successBadge).toBeInTheDocument(); + expect(cancelButton).toBeInTheDocument(); + expect(previewButton).toBeInTheDocument(); + expect(fileName).toHaveTextContent('glossary-terms.csv'); + + // preview should work + + await act(async () => { + userEvent.click(previewButton); + }); + + const importButton = await screen.getByTestId('import-button'); + + expect(await screen.getByTestId('import-results')).toBeInTheDocument(); + expect(importButton).toBeInTheDocument(); + }); + + it('Import Should work for partial success', async () => { + mockCsvImportResult = { + ...mockCsvImportResult, + status: Status.PartialSuccess, + }; + + const file = new File([mockCsvContent], 'glossary-terms.csv', { + type: 'text/plain', + }); + const flushPromises = () => new Promise(setImmediate); + + render(); + + const uploadDragger = await waitForElement(() => + screen.getByTestId('upload-file-widget') + ); + + expect(uploadDragger).toBeInTheDocument(); + + await act(async () => { + fireEvent.change(uploadDragger, { target: { files: [file] } }); + }); + await act(async () => { + await flushPromises(); + }); + + const successBadge = await screen.getByTestId('success-badge'); + const cancelButton = await screen.getByTestId('cancel-button'); + const previewButton = await screen.getByTestId('preview-button'); + const fileName = await screen.getByTestId('file-name'); + + expect(successBadge).toBeInTheDocument(); + expect(cancelButton).toBeInTheDocument(); + expect(previewButton).toBeInTheDocument(); + expect(fileName).toHaveTextContent('glossary-terms.csv'); + + // preview should work + + await act(async () => { + userEvent.click(previewButton); + }); + + const importButton = await screen.getByTestId('import-button'); + + expect(await screen.getByTestId('import-results')).toBeInTheDocument(); + expect(importButton).toBeInTheDocument(); + }); + + it('Import Should not work for failure', async () => { + mockCsvImportResult = { ...mockCsvImportResult, status: Status.Failure }; + + const file = new File([mockIncorrectCsvContent], 'glossary-terms.csv', { + type: 'text/plain', + }); + const flushPromises = () => new Promise(setImmediate); + + render(); + + const uploadDragger = await waitForElement(() => + screen.getByTestId('upload-file-widget') + ); + + expect(uploadDragger).toBeInTheDocument(); + + await act(async () => { + fireEvent.change(uploadDragger, { target: { files: [file] } }); + }); + await act(async () => { + await flushPromises(); + }); + + // for in correct data should show the failure badge + const failureBadge = await screen.getByTestId('failure-badge'); + + const cancelButton = await screen.getByTestId('cancel-button'); + const previewButton = await screen.getByTestId('preview-button'); + const fileName = await screen.getByTestId('file-name'); + + expect(failureBadge).toBeInTheDocument(); + expect(cancelButton).toBeInTheDocument(); + expect(previewButton).toBeInTheDocument(); + expect(fileName).toBeInTheDocument(); + + // preview should work + + await act(async () => { + userEvent.click(previewButton); + }); + + const importButton = screen.queryByTestId('import-button'); + + expect(await screen.getByTestId('import-results')).toBeInTheDocument(); + + // for failure import button should not render + expect(importButton).not.toBeInTheDocument(); + }); + + it('Import Should not work for aborted', async () => { + mockCsvImportResult = { + ...mockCsvImportResult, + status: Status.Aborted, + abortReason: 'Something went wrong', + }; + + const file = new File([mockCsvContent], 'glossary-terms.csv', { + type: 'text/plain', + }); + const flushPromises = () => new Promise(setImmediate); + + render(); + + const uploadDragger = await waitForElement(() => + screen.getByTestId('upload-file-widget') + ); + + expect(uploadDragger).toBeInTheDocument(); + + await act(async () => { + fireEvent.change(uploadDragger, { target: { files: [file] } }); + }); + await act(async () => { + await flushPromises(); + }); + + const abortedReason = await screen.getByTestId('abort-reason'); + const cancelButton = await screen.getByTestId('cancel-button'); + + expect(abortedReason).toBeInTheDocument(); + expect(abortedReason).toHaveTextContent('Something went wrong'); + expect(cancelButton).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportGlossary/ImportGlossary.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportGlossary/ImportGlossary.tsx new file mode 100644 index 00000000000..87048ea6dfd --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportGlossary/ImportGlossary.tsx @@ -0,0 +1,271 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Button, + Card, + Col, + Divider, + Row, + Space, + Typography, + Upload, + UploadProps, +} from 'antd'; +import { ReactComponent as FailBadgeIcon } from 'assets/svg/fail-badge.svg'; +import { ReactComponent as BrowseFileIcon } from 'assets/svg/ic-browse-file.svg'; +import { ReactComponent as ImportIcon } from 'assets/svg/ic-import.svg'; +import { ReactComponent as SuccessBadgeIcon } from 'assets/svg/success-badge.svg'; +import { AxiosError } from 'axios'; +import TitleBreadcrumb from 'components/common/title-breadcrumb/title-breadcrumb.component'; +import { TitleBreadcrumbProps } from 'components/common/title-breadcrumb/title-breadcrumb.interface'; +import Loader from 'components/Loader/Loader'; +import { CSVImportResult, Status } from 'generated/type/csvImportResult'; +import { isUndefined } from 'lodash'; +import React, { FC, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHistory } from 'react-router-dom'; +import { importGlossaryInCSVFormat } from 'rest/glossaryAPI'; +import { getGlossaryPath } from 'utils/RouterUtils'; +import { showErrorToast } from 'utils/ToastUtils'; +import ImportResult from '../ImportResult/ImportResult'; +import './ImportGlossary.less'; + +interface Props { + glossaryName: string; +} + +const { Title } = Typography; +const { Dragger } = Upload; + +const ImportGlossary: FC = ({ glossaryName }) => { + const { t } = useTranslation(); + + const history = useHistory(); + + const [isLoading, setIsLoading] = useState(false); + const [isPreview, setIsPreview] = useState(false); + + const [fileName, setFileName] = useState(''); + + const [csvFileResult, setCsvFileResult] = useState(''); + + const [csvImportResult, setCsvImportResult] = useState(); + + const breadcrumbList: TitleBreadcrumbProps['titleLinks'] = useMemo( + () => [ + { + name: glossaryName, + url: getGlossaryPath(glossaryName), + }, + { + name: 'Import Glossary Terms', + url: '', + activeTitle: true, + }, + ], + [glossaryName] + ); + + const { isSuccess, isFailure, showAbortedResult, showSuccessResult } = + useMemo(() => { + const isSuccess = + csvImportResult?.status === Status.Success || + csvImportResult?.status === Status.PartialSuccess; + const isFailure = csvImportResult?.status === Status.Failure; + const showAbortedResult = csvImportResult?.status === Status.Aborted; + const showSuccessResult = + csvImportResult?.status === Status.Success || + csvImportResult?.status === Status.PartialSuccess || + csvImportResult?.status === Status.Failure; + + return { + isSuccess, + isFailure, + showAbortedResult, + showSuccessResult, + }; + }, [csvImportResult]); + + const handleUpload: UploadProps['customRequest'] = async (options) => { + setIsLoading(true); + try { + const reader = new FileReader(); + + reader.readAsText(options.file as Blob); + + reader.addEventListener('load', async (e) => { + const result = e.target?.result as string; + if (result) { + const response = await importGlossaryInCSVFormat( + glossaryName, + result + ); + + setCsvImportResult(response); + setCsvFileResult(result); + } + }); + + reader.addEventListener('error', () => { + throw t('server.unexpected-error'); + }); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsLoading(false); + } + }; + + const handleImport = async () => { + setIsLoading(true); + try { + await importGlossaryInCSVFormat(glossaryName, csvFileResult, false); + + history.push(getGlossaryPath(glossaryName)); + } catch (error) { + showErrorToast(error as AxiosError); + } finally { + setIsLoading(false); + } + }; + + return ( + + + + + + + + {isPreview ? glossaryName : t('label.import-glossary-terms')} + + {isPreview && !isUndefined(csvImportResult) && !isFailure && ( + + )} + + + {isPreview && !isUndefined(csvImportResult) ? ( + + + + ) : ( + + {isUndefined(csvImportResult) ? ( + { + setIsLoading(true); + setFileName(file.name); + }} + className="file-dragger-wrapper p-lg bg-white" + customRequest={handleUpload} + data-testid="upload-file-widget" + multiple={false} + showUploadList={false}> + {isLoading ? ( + + ) : ( + <> + + + + {t('label.drag-and-drop-files-here')} + + + + + {t('label.or-lowercase')} + + + + + )} + + ) : ( + + + {isSuccess && ( + + )} + {isFailure && ( + + )} + + {showSuccessResult && ( + <> + + {fileName}{' '} + {`${t('label.is-ready-for-preview')}.`} + + + + + + + )} + + {showAbortedResult && ( + <> + + {t('label.aborted')}{' '} + {csvImportResult.abortReason} + + + + )} + + + )} + + )} + + ); +}; + +export default ImportGlossary; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportResult/ImportResult.test.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportResult/ImportResult.test.tsx new file mode 100644 index 00000000000..2fbf13950e0 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportResult/ImportResult.test.tsx @@ -0,0 +1,81 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + findByText, + getAllByRole, + render, + screen, +} from '@testing-library/react'; +import { CSVImportResult } from 'generated/type/csvImportResult'; +import React from 'react'; +import ImportResult from './ImportResult'; + +const mockCsvImportResult = { + dryRun: true, + status: 'success', + numberOfRowsProcessed: 3, + numberOfRowsPassed: 3, + numberOfRowsFailed: 0, + importResultsCsv: `status,details,parent,name*,displayName,description*,synonyms,relatedTerms,references,tags\r + success,Entity updated,,Glossary2 Term,Glossary2 Term displayName,Description for Glossary2 Term,,,,\r + success,Entity updated,,Glossary2 term2,Glossary2 term2,Description data.,,,,\r`, +}; + +jest.mock('components/common/rich-text-editor/RichTextEditorPreviewer', () => + jest.fn().mockReturnValue(
RichTextViewer
) +); + +describe('Import Results', () => { + it('Should render the results', async () => { + render( + + ); + + const processedRow = await screen.getByTestId('processed-row'); + const passedRow = await screen.getByTestId('passed-row'); + const failedRow = await screen.getByTestId('failed-row'); + + expect(processedRow).toHaveTextContent('3'); + expect(passedRow).toHaveTextContent('3'); + expect(failedRow).toHaveTextContent('0'); + + expect(await screen.getByTestId('import-result-table')).toBeInTheDocument(); + }); + + it('Should render the parsed result', async () => { + const { container } = render( + + ); + + const tableRows = getAllByRole(container, 'row'); + + expect(tableRows).toHaveLength(3); + + const firstRow = tableRows[1]; + + const rowStatus = await findByText(firstRow, 'success'); + const rowDetails = await findByText(firstRow, 'Entity updated'); + const rowName = await findByText(firstRow, 'Glossary2 Term'); + const rowDisplayName = await findByText( + firstRow, + 'Glossary2 Term displayName' + ); + const rowDescription = await findByText(firstRow, 'RichTextViewer'); + + expect(rowStatus).toBeInTheDocument(); + expect(rowDetails).toBeInTheDocument(); + expect(rowName).toBeInTheDocument(); + expect(rowDisplayName).toBeInTheDocument(); + expect(rowDescription).toBeInTheDocument(); + }); +}); diff --git a/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportResult/ImportResult.tsx b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportResult/ImportResult.tsx new file mode 100644 index 00000000000..586087ae9d4 --- /dev/null +++ b/openmetadata-ui/src/main/resources/ui/src/components/Glossary/ImportResult/ImportResult.tsx @@ -0,0 +1,168 @@ +/* + * Copyright 2023 Collate. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Col, Row, Space, Typography } from 'antd'; +import Table, { ColumnsType } from 'antd/lib/table'; +import classNames from 'classnames'; +import RichTextEditorPreviewer from 'components/common/rich-text-editor/RichTextEditorPreviewer'; + +import { CSVImportResult, Status } from 'generated/type/csvImportResult'; +import React, { FC, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { parseCSV } from 'utils/GlossaryUtils'; +import { GlossaryCSVRecord } from '../ImportGlossary/ImportGlossary.interface'; + +interface Props { + csvImportResult: CSVImportResult; +} + +const ImportResult: FC = ({ csvImportResult }) => { + const { t } = useTranslation(); + + const parsedRecords: GlossaryCSVRecord[] = useMemo(() => { + const importResult = csvImportResult?.importResultsCsv; + + if (importResult) { + return parseCSV(importResult); + } else { + return []; + } + }, [csvImportResult]); + + const columns: ColumnsType = useMemo( + () => [ + { + title: t('label.status'), + dataIndex: 'status', + key: 'status', + render: (status: GlossaryCSVRecord['status']) => { + return ( + + {status} + + ); + }, + }, + { + title: t('label.detail-plural'), + dataIndex: 'details', + key: 'details', + render: (details: GlossaryCSVRecord['details']) => { + return {details ?? '--'}; + }, + }, + { + title: t('label.parent'), + dataIndex: 'parent', + key: 'parent', + render: (parent: GlossaryCSVRecord['parent']) => { + return {parent ?? '--'}; + }, + }, + { + title: t('label.name'), + dataIndex: 'name*', + key: 'name', + render: (name: GlossaryCSVRecord['name*']) => { + return {name}; + }, + }, + { + title: t('label.display-name'), + dataIndex: 'displayName', + key: 'displayName', + render: (displayName: GlossaryCSVRecord['displayName']) => { + return {displayName ?? '--'}; + }, + }, + { + title: t('label.description'), + dataIndex: 'description*', + key: 'description', + render: (description: GlossaryCSVRecord['description*']) => { + return ; + }, + }, + { + title: t('label.synonyms'), + dataIndex: 'synonyms', + key: 'synonyms', + render: (synonyms: GlossaryCSVRecord['synonyms']) => { + return {synonyms ?? '--'}; + }, + }, + { + title: t('label.tag-plural'), + dataIndex: 'tags', + key: 'tags', + render: (tags: GlossaryCSVRecord['tags']) => { + return {tags ?? '--'}; + }, + }, + ], + [] + ); + + return ( + + + +
+ {`${t( + 'label.number-of-rows' + )}: `} + + {csvImportResult.numberOfRowsProcessed} + +
+ {' | '} +
+ {`${t( + 'label.passed' + )}: `} + + {csvImportResult.numberOfRowsPassed} + +
+ {' | '} +
+ {`${t( + 'label.failed' + )}: `} + + {csvImportResult.numberOfRowsFailed} + +
+
+ + + + + + ); +}; + +export default ImportResult; diff --git a/openmetadata-ui/src/main/resources/ui/src/components/router/AuthenticatedAppRouter.tsx b/openmetadata-ui/src/main/resources/ui/src/components/router/AuthenticatedAppRouter.tsx index 82a07747ad1..28132e75ff9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/components/router/AuthenticatedAppRouter.tsx +++ b/openmetadata-ui/src/main/resources/ui/src/components/router/AuthenticatedAppRouter.tsx @@ -363,6 +363,12 @@ const AuthenticatedAppRouter: FunctionComponent = () => { hasPermission={glossaryPermission} path={ROUTES.GLOSSARY_DETAILS} /> + { `/glossaryTerms/${id}?recursive=true&hardDelete=true` ); }; + +export const exportGlossaryInCSVFormat = async (glossaryName: string) => { + const response = await APIClient.get( + `/glossaries/name/${glossaryName}/export` + ); + + return response.data; +}; + +export const importGlossaryInCSVFormat = async ( + glossaryName: string, + data: string, + dryRun = true +) => { + const configOptions = { + headers: { 'Content-type': 'text/plain' }, + }; + const response = await APIClient.put>( + `/glossaries/name/${glossaryName}/import?dryRun=${dryRun}`, + data, + configOptions + ); + + return response.data; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/styles/app.less b/openmetadata-ui/src/main/resources/ui/src/styles/app.less index c4c8679fb68..033aa3bf9d9 100644 --- a/openmetadata-ui/src/main/resources/ui/src/styles/app.less +++ b/openmetadata-ui/src/main/resources/ui/src/styles/app.less @@ -75,6 +75,14 @@ color: @text-color-secondary; } +.text-success { + color: #008376; +} + +.text-failure { + color: #cb2431; +} + // text alignment .text-center { diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.ts index 4ec10b342a0..aaecc38f6f3 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/GlossaryUtils.ts @@ -12,6 +12,7 @@ */ import { AxiosError } from 'axios'; +import { GlossaryCSVRecord } from 'components/Glossary/ImportGlossary/ImportGlossary.interface'; import { t } from 'i18next'; import { cloneDeep, isEmpty } from 'lodash'; import { ModifiedGlossaryData } from 'pages/GlossaryPage/GlossaryPageV1.component'; @@ -402,3 +403,26 @@ export const getEntityReferenceFromGlossary = ( name: glossary.name, }; }; + +export const parseCSV = (csvData: string) => { + const recordList: GlossaryCSVRecord[] = []; + + const lines = csvData.trim().split('\n').filter(Boolean); + + if (!isEmpty(lines)) { + const headers = lines[0].split(',').map((header) => header.trim()); + + lines.slice(1).forEach((line) => { + const record: GlossaryCSVRecord = {} as GlossaryCSVRecord; + const lineData = line.split(','); + + headers.forEach((header, index) => { + record[header as keyof GlossaryCSVRecord] = lineData[index]; + }); + + recordList.push(record); + }); + } + + return recordList; +}; diff --git a/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts b/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts index 582b0a9b198..3e1bf9f7a9a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts +++ b/openmetadata-ui/src/main/resources/ui/src/utils/RouterUtils.ts @@ -11,6 +11,7 @@ * limitations under the License. */ +import { GlossaryAction } from 'components/Glossary/GlossaryV1.interfaces'; import { ProfilerDashboardTab } from 'components/ProfilerDashboard/profilerDashboard.interface'; import { isUndefined } from 'lodash'; import { ServiceTypes } from 'Models'; @@ -21,6 +22,7 @@ import { IN_PAGE_SEARCH_ROUTES, LOG_ENTITY_NAME, LOG_ENTITY_TYPE, + PLACEHOLDER_ACTION, PLACEHOLDER_DASHBOARD_TYPE, PLACEHOLDER_ENTITY_TYPE_FQN, PLACEHOLDER_GLOSSARY_NAME, @@ -455,3 +457,16 @@ export const getLineageViewPath = (entity: EntityType, fqn: string) => { return path; }; + +export const getGlossaryPathWithAction = ( + fqn: string, + action: GlossaryAction +) => { + let path = ROUTES.GLOSSARY_DETAILS_WITH_ACTION; + + path = path + .replace(PLACEHOLDER_GLOSSARY_NAME, fqn) + .replace(PLACEHOLDER_ACTION, action); + + return path; +};